@marianmeres/stuic 3.160.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
  ============================================================================ */
@@ -40,36 +40,36 @@ interface RegisterFieldConfig {
40
40
 
41
41
  ## RegisterForm — Props
42
42
 
43
- | Prop | Type | Default | Description |
44
- | --------------------------- | --------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
45
- | `formData` | `RegisterFormData` | empty | Bindable form data. |
46
- | `onSubmit` | `(data: RegisterFormData) => void` | required | Called after client-side validation passes. |
47
- | `isSubmitting` | `boolean` | `false` | Disables the CTA during submission. |
48
- | `submitDisabled` | `boolean` | `false` | Consumer-owned submit block. Disables the CTA **and** blocks `onSubmit`. |
49
- | `errors` | `RegisterFormValidationError[]` | `[]` | Field-specific server errors (merged with internal validation). See [Server errors](#server-supplied-errors). |
50
- | `error` | `string` | - | General error rendered as a `DismissibleMessage` above the form. |
51
- | `showEmail` | `boolean` | `true` | Render the email field. `false` **unmounts** it and skips its validation. |
52
- | `showPassword` | `boolean` | `true` | Render the password field (and, transitively, the confirm field). |
53
- | `showPasswordConfirm` | `boolean` | `true` | Render the password-confirm field. Subordinate to `showPassword`. |
54
- | `passwordMinLength` | `number` | `8` | Minimum password length (fed into both the FieldInput attribute and the validator). |
55
- | `credentialsSlot` | `Snippet<[{ formData, fieldError }]>` | - | Rendered at the credentials position (after the core fields, before bottom extra fields). |
56
- | `emailFieldProps` | `Partial<FieldInputProps>` | - | Passthrough props for the built-in email field. |
57
- | `passwordFieldProps` | `Partial<FieldInputProps>` | - | Passthrough props for the built-in password field. |
58
- | `passwordConfirmFieldProps` | `Partial<FieldInputProps>` | - | Passthrough props for the built-in confirm field. |
59
- | `extraFields` | `RegisterFieldConfig[]` | `[]` | Declarative extra fields. Rendered as `FieldInput`s positioned top or bottom. |
60
- | `extraFieldsSlot` | `Snippet<[{ formData, fieldError }]>` | - | Escape hatch for non-FieldInput extras. Rendered after declarative bottom fields. |
61
- | `topFieldsSeparator` | `boolean` | auto | Section rule below the top-position extra fields. Auto-on when they are followed by the top social block or by a `credentialsSlot` that has replaced the credentials. |
62
- | `submitLabel` | `string` | i18n | Override the CTA label. |
63
- | `submittingLabel` | `string` | i18n | Override the CTA label while submitting. |
64
- | `submitButton` | `Snippet<[{ isSubmitting, disabled }]>` | - | Override the entire CTA section. `disabled` is `isSubmitting \|\| submitDisabled`. |
65
- | `socialLogins` | `Snippet` | - | Social/OAuth buttons. A divider is shown when set. |
66
- | `socialPosition` | `"top" \| "bottom"` | `"bottom"` | `"top"` renders the block above the credentials, with the divider **below** the buttons. |
67
- | `socialDividerLabel` | `string \| false` | i18n | Override (or hide with `false`) the divider. Defaults to `social_divider` ("or continue with") at the bottom, `social_divider_alt` ("or") at the top. |
68
- | `footer` | `Snippet` | - | Content below the form (e.g., "Already have an account? Log in"). |
69
- | `notifications` | `NotificationsStack` | - | When set, general errors are also pushed via `notifications.error()`. |
70
- | `t` | `TranslateFn` | English | i18n function. |
71
- | `unstyled` / `class` | - | - | Standard styling escape hatches. |
72
- | `el` | `HTMLFormElement` | - | Bindable form element. |
43
+ | Prop | Type | Default | Description |
44
+ | --------------------------- | --------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
45
+ | `formData` | `RegisterFormData` | empty | Bindable form data. |
46
+ | `onSubmit` | `(data: RegisterFormData) => void` | required | Called after client-side validation passes. |
47
+ | `isSubmitting` | `boolean` | `false` | Disables the CTA during submission. |
48
+ | `submitDisabled` | `boolean` | `false` | Consumer-owned submit block. Disables the CTA **and** blocks `onSubmit`. |
49
+ | `errors` | `RegisterFormValidationError[]` | `[]` | Field-specific server errors (merged with internal validation). See [Server errors](#server-supplied-errors). |
50
+ | `error` | `string` | - | General error rendered as a `DismissibleMessage` above the form. |
51
+ | `showEmail` | `boolean` | `true` | Render the email field. `false` **unmounts** it and skips its validation. |
52
+ | `showPassword` | `boolean` | `true` | Render the password field (and, transitively, the confirm field). |
53
+ | `showPasswordConfirm` | `boolean` | `true` | Render the password-confirm field. Subordinate to `showPassword`. |
54
+ | `passwordMinLength` | `number` | `8` | Minimum password length (fed into both the FieldInput attribute and the validator). |
55
+ | `credentialsSlot` | `Snippet<[{ formData, fieldError }]>` | - | Rendered at the credentials position (after the core fields, before bottom extra fields). |
56
+ | `emailFieldProps` | `Partial<FieldInputProps>` | - | Passthrough props for the built-in email field. |
57
+ | `passwordFieldProps` | `Partial<FieldInputProps>` | - | Passthrough props for the built-in password field. |
58
+ | `passwordConfirmFieldProps` | `Partial<FieldInputProps>` | - | Passthrough props for the built-in confirm field. |
59
+ | `extraFields` | `RegisterFieldConfig[]` | `[]` | Declarative extra fields. Rendered as `FieldInput`s positioned top or bottom. |
60
+ | `extraFieldsSlot` | `Snippet<[{ formData, fieldError }]>` | - | Escape hatch for non-FieldInput extras. Rendered after declarative bottom fields. |
61
+ | `topFieldsSeparator` | `boolean` | `true` | Section rule closing the top-position extra-field group. Drawn whenever at least one such field exists, whatever follows it. `false` opts out. |
62
+ | `submitLabel` | `string` | i18n | Override the CTA label. |
63
+ | `submittingLabel` | `string` | i18n | Override the CTA label while submitting. |
64
+ | `submitButton` | `Snippet<[{ isSubmitting, disabled }]>` | - | Override the entire CTA section. `disabled` is `isSubmitting \|\| submitDisabled`. |
65
+ | `socialLogins` | `Snippet` | - | Social/OAuth buttons. A divider is shown when set. |
66
+ | `socialPosition` | `"top" \| "bottom"` | `"bottom"` | `"top"` renders the block above the credentials, with the divider **below** the buttons. |
67
+ | `socialDividerLabel` | `string \| false` | i18n | Override (or hide with `false`) the divider. Defaults to `social_divider` ("or continue with") at the bottom, `social_divider_alt` ("or") at the top. |
68
+ | `footer` | `Snippet` | - | Content below the form (e.g., "Already have an account? Log in"). |
69
+ | `notifications` | `NotificationsStack` | - | When set, general errors are also pushed via `notifications.error()`. |
70
+ | `t` | `TranslateFn` | English | i18n function. |
71
+ | `unstyled` / `class` | - | - | Standard styling escape hatches. |
72
+ | `el` | `HTMLFormElement` | - | Bindable form element. |
73
73
 
74
74
  ### Imperative methods (via `bind:this`)
75
75
 
@@ -215,7 +215,7 @@ Once an external party has confirmed who the user is, the credential fields are
215
215
 
216
216
  Call `form.focusField("tenant_id")` right after the provider confirms: the button the user clicked is about to unmount, and without an explicit move focus falls to `<body>` — a keyboard user's next Tab restarts at the top of the document.
217
217
 
218
- Those top-position fields are closed off with a section rule here, automatically. At the form's ordinary field rhythm the last of them sits one field-gap above the first provider button and reads as a caption for it; the break says instead that the fields are settled and what follows is a choice. It turns itself on exactly when the fields are followed by something that is not another input — the top social block, or a `credentialsSlot` standing in for the whole credential block so it survives the switch from "pick a provider" to "signing up as jane@…" without the layout moving. `topFieldsSeparator={true | false}` overrides the decision in either direction.
218
+ Those top-position fields are closed off with a section rule. At the form's ordinary field rhythm the last of them sits one field-gap above the first provider button and reads as a caption for it; the break says instead that the workspace id is settled and what follows is the account. The rule is not conditioned on what comes next provider buttons, a `credentialsSlot`, or the plain credentials all get it so it does not blink in and out as this flow moves from "pick a provider" to "signing up as jane@…", and a reader of the call site does not have to work out which combination of other props switched it on. `topFieldsSeparator={false}` opts out.
219
219
 
220
220
  Other shapes the same three props cover:
221
221
 
@@ -310,7 +310,7 @@ The social block carries `data-position="top" \| "bottom"` (suppressed under `un
310
310
 
311
311
  `credentialsSlot` content is wrapped in `.stuic-register-form-credentials` (suppressed under `unstyled`) so it inherits the same bottom rhythm the fields have — the form itself is a zero-gap flex column.
312
312
 
313
- Top-position extra fields are wrapped in `.stuic-register-form-fields-top` (suppressed under `unstyled`), which carries `data-separator` when the rule is on. The wrapper is otherwise inert — the fields keep their own margins — so targeting `[data-separator]` is the way to restyle the break without touching the ungrouped case. The default padding stacks on top of the last field's own `margin-bottom`, which is why the two spacing tokens are not equal.
313
+ Top-position extra fields are wrapped in `.stuic-register-form-fields-top` (suppressed under `unstyled`), which carries `data-separator` unless `topFieldsSeparator={false}`. The wrapper is otherwise inert — the fields keep their own margins — so targeting `[data-separator]` is the way to restyle the break without touching the ungrouped case. The default padding stacks on top of the last field's own `margin-bottom`, which is why the two spacing tokens are not equal.
314
314
 
315
315
  ## Gotchas
316
316
 
@@ -128,17 +128,16 @@
128
128
  * Close the top-position extra fields with a section break — a hairline
129
129
  * rule plus extra space below the group.
130
130
  *
131
- * Those fields are the ones required whichever way the user signs up (a
132
- * workspace id, an invite code), so what follows them in an identity-first
133
- * layout is a *choice*: provider buttons, or a block standing in for the
134
- * credentials. At the form's ordinary field rhythm the last of them reads
135
- * as a caption for the first button below it the break makes the group a
136
- * section of its own instead.
131
+ * A field is at the top position because it is about something other than
132
+ * the credentials (a workspace id, an invite code, an org name), so the
133
+ * group is drawn as its own section. Without the break the last of them
134
+ * sits at the ordinary field gap above whatever comes next and reads as a
135
+ * caption for it a label over the first provider button, or just another
136
+ * row of the credential column.
137
137
  *
138
- * Default: auto on when the top fields are followed by the top-position
139
- * social block or by a `credentialsSlot` that has replaced the credentials
140
- * entirely; off otherwise, since a plain column of inputs does not want a
141
- * rule drawn through the middle of it.
138
+ * Default: true whenever there is at least one top-position field,
139
+ * regardless of what follows. Set `false` for a form whose top fields
140
+ * really do belong to the same column as the credentials.
142
141
  */
143
142
  topFieldsSeparator?: boolean;
144
143
 
@@ -268,15 +267,15 @@
268
267
  // validated against it).
269
268
  let renderPasswordConfirm = $derived(showPassword && showPasswordConfirm);
270
269
 
271
- // The section break below the top fields earns its place only when the next
272
- // thing down is NOT another plain input: the top social block, or a slot that
273
- // has taken the credentials' place. Anywhere else the form is a single column
274
- // of fields and a rule through the middle of it is noise.
275
- let renderTopFieldsSeparator = $derived(
276
- topFieldsSeparator ??
277
- ((socialPosition === "top" && !!socialLogins) ||
278
- (!!credentialsSlot && !showEmail && !showPassword))
279
- );
270
+ // A field at the top position is there because it is about something other
271
+ // than the credentials the workspace, the invite, the org so it is closed
272
+ // off as its own section whichever way the sign-up continues below it.
273
+ // Deliberately NOT conditioned on what follows (provider buttons, a
274
+ // credentialsSlot, plain inputs): the last top field otherwise sits one field
275
+ // gap above the next thing and reads as a caption for it either way, and a
276
+ // rule that comes and goes with an unrelated prop is not explicable from the
277
+ // call site. `topFieldsSeparator={false}` opts out.
278
+ let renderTopFieldsSeparator = $derived(topFieldsSeparator ?? true);
280
279
 
281
280
  // Internal validation errors (set on submit)
282
281
  let internalErrors = $state<RegisterFormValidationError[]>([]);
@@ -101,17 +101,16 @@ export interface Props extends Omit<HTMLAttributes<HTMLFormElement>, "children">
101
101
  * Close the top-position extra fields with a section break — a hairline
102
102
  * rule plus extra space below the group.
103
103
  *
104
- * Those fields are the ones required whichever way the user signs up (a
105
- * workspace id, an invite code), so what follows them in an identity-first
106
- * layout is a *choice*: provider buttons, or a block standing in for the
107
- * credentials. At the form's ordinary field rhythm the last of them reads
108
- * as a caption for the first button below it the break makes the group a
109
- * section of its own instead.
104
+ * A field is at the top position because it is about something other than
105
+ * the credentials (a workspace id, an invite code, an org name), so the
106
+ * group is drawn as its own section. Without the break the last of them
107
+ * sits at the ordinary field gap above whatever comes next and reads as a
108
+ * caption for it a label over the first provider button, or just another
109
+ * row of the credential column.
110
110
  *
111
- * Default: auto on when the top fields are followed by the top-position
112
- * social block or by a `credentialsSlot` that has replaced the credentials
113
- * entirely; off otherwise, since a plain column of inputs does not want a
114
- * rule drawn through the middle of it.
111
+ * Default: true whenever there is at least one top-position field,
112
+ * regardless of what follows. Set `false` for a form whose top fields
113
+ * really do belong to the same column as the credentials.
115
114
  */
116
115
  topFieldsSeparator?: boolean;
117
116
  /** Override CTA label */
@@ -75,7 +75,7 @@
75
75
  ]
76
76
  >;
77
77
 
78
- /** Section break below the top-position extra fields. Default: auto. */
78
+ /** Section break below the top-position extra fields. Default: true. */
79
79
  topFieldsSeparator?: InnerProps["topFieldsSeparator"];
80
80
 
81
81
  /** Override CTA label */
@@ -51,7 +51,7 @@ export interface Props {
51
51
  fieldError: (name: string) => string | undefined;
52
52
  }
53
53
  ]>;
54
- /** Section break below the top-position extra fields. Default: auto. */
54
+ /** Section break below the top-position extra fields. Default: true. */
55
55
  topFieldsSeparator?: InnerProps["topFieldsSeparator"];
56
56
  /** Override CTA label */
57
57
  submitLabel?: string;
@@ -46,11 +46,10 @@
46
46
  margin-bottom: var(--stuic-register-form-credentials-margin-bottom);
47
47
  }
48
48
 
49
- /* Top-position extra fields. The group wrapper is deliberately inert until
50
- `data-separator` is set those fields are otherwise just the first rows of
51
- the same single column, and a rule through the middle of a column of inputs
52
- is noise. With it, the group becomes its own section: what follows is a
53
- choice of sign-up path, not another field. */
49
+ /* Top-position extra fields. `data-separator` is on unless the consumer opts
50
+ out, so the group reads as its own section rather than as the first rows of
51
+ the credential column; the wrapper is inert without it, leaving the fields
52
+ their own margins. */
54
53
  .stuic-register-form-fields-top[data-separator] {
55
54
  padding-bottom: var(--stuic-register-form-fields-top-padding-bottom);
56
55
  margin-bottom: var(--stuic-register-form-fields-top-margin-bottom);
@@ -300,7 +300,7 @@ Prefix: `--stuic-login-form-*`
300
300
 
301
301
  Standalone registration form. Mirrors `LoginForm` conventions: `formData`, `onSubmit`, validation, errors, i18n, notifications, social-logins. Adds declarative `extraFields` (top/bottom positioning, custom validators) and an `extraFieldsSlot` escape hatch (e.g., terms checkbox).
302
302
 
303
- Also covers **identity-first signup** (identity established by an OAuth provider / invite / magic link before the account exists): `showEmail` / `showPassword` unmount the credential fields, `credentialsSlot` replaces them, `socialPosition="top"` moves the provider buttons above the credentials, and the top-position extra fields (workspace id, invite code — required on every path) are closed off with a section rule so they don't read as a caption for the first provider button (`topFieldsSeparator`, auto).
303
+ Also covers **identity-first signup** (identity established by an OAuth provider / invite / magic link before the account exists): `showEmail` / `showPassword` unmount the credential fields, `credentialsSlot` replaces them, `socialPosition="top"` moves the provider buttons above the credentials, and the top-position extra fields (workspace id, invite code — required on every path) are closed off with a section rule so they don't read as a caption for the first provider button (`topFieldsSeparator`, on by default whenever such a field exists).
304
304
 
305
305
  ### Exports
306
306
 
@@ -317,29 +317,29 @@ Also covers **identity-first signup** (identity established by an OAuth provider
317
317
 
318
318
  ### Key Props
319
319
 
320
- | Prop | Type | Default | Description |
321
- | ---------------------------------------------------------------------- | ------------------------------- | ---------- | -------------------------------------------------------------------------------- |
322
- | `formData` | `RegisterFormData` | empty | Bindable form data |
323
- | `onSubmit` | `(data) => void` | required | Submit callback |
324
- | `isSubmitting` | `boolean` | `false` | Disables CTA |
325
- | `submitDisabled` | `boolean` | `false` | Consumer-owned block: disables CTA + blocks submit |
326
- | `errors` | `RegisterFormValidationError[]` | `[]` | Server field errors — self-clearing (see below) |
327
- | `error` | `string` | — | General error (alert above form) |
328
- | `showEmail` | `boolean` | `true` | Render (mount) the email field |
329
- | `showPassword` | `boolean` | `true` | Render (mount) the password + confirm fields |
330
- | `showPasswordConfirm` | `boolean` | `true` | Render password-confirm field |
331
- | `passwordMinLength` | `number` | `8` | Min password length (input + validator) |
332
- | `credentialsSlot` | `Snippet` | — | Content at the credentials position |
333
- | `emailFieldProps` / `passwordFieldProps` / `passwordConfirmFieldProps` | `Partial<FieldInputProps>` | — | Passthrough props per core field (`validate` composed, `value` ignored) |
334
- | `extraFields` | `RegisterFieldConfig[]` | `[]` | Declarative extra fields (top/bottom) |
335
- | `extraFieldsSlot` | `Snippet` | — | Escape-hatch for non-FieldInput extras |
336
- | `topFieldsSeparator` | `boolean` | auto | Section rule below the top-position extra fields (auto-on when a choice follows) |
337
- | `submitButton` | `Snippet` | — | Custom CTA section |
338
- | `socialLogins` | `Snippet` | — | OAuth buttons |
339
- | `socialPosition` | `"top" \| "bottom"` | `"bottom"` | Social block above the credentials or after the CTA |
340
- | `footer` | `Snippet` | — | Content below form |
341
- | `notifications` | `NotificationsStack` | — | Route errors to notifications |
342
- | `t` | `TranslateFn` | built-in | Translation function |
320
+ | Prop | Type | Default | Description |
321
+ | ---------------------------------------------------------------------- | ------------------------------- | ---------- | ------------------------------------------------------------------------- |
322
+ | `formData` | `RegisterFormData` | empty | Bindable form data |
323
+ | `onSubmit` | `(data) => void` | required | Submit callback |
324
+ | `isSubmitting` | `boolean` | `false` | Disables CTA |
325
+ | `submitDisabled` | `boolean` | `false` | Consumer-owned block: disables CTA + blocks submit |
326
+ | `errors` | `RegisterFormValidationError[]` | `[]` | Server field errors — self-clearing (see below) |
327
+ | `error` | `string` | — | General error (alert above form) |
328
+ | `showEmail` | `boolean` | `true` | Render (mount) the email field |
329
+ | `showPassword` | `boolean` | `true` | Render (mount) the password + confirm fields |
330
+ | `showPasswordConfirm` | `boolean` | `true` | Render password-confirm field |
331
+ | `passwordMinLength` | `number` | `8` | Min password length (input + validator) |
332
+ | `credentialsSlot` | `Snippet` | — | Content at the credentials position |
333
+ | `emailFieldProps` / `passwordFieldProps` / `passwordConfirmFieldProps` | `Partial<FieldInputProps>` | — | Passthrough props per core field (`validate` composed, `value` ignored) |
334
+ | `extraFields` | `RegisterFieldConfig[]` | `[]` | Declarative extra fields (top/bottom) |
335
+ | `extraFieldsSlot` | `Snippet` | — | Escape-hatch for non-FieldInput extras |
336
+ | `topFieldsSeparator` | `boolean` | `true` | Section rule closing the top-position extra-field group; `false` opts out |
337
+ | `submitButton` | `Snippet` | — | Custom CTA section |
338
+ | `socialLogins` | `Snippet` | — | OAuth buttons |
339
+ | `socialPosition` | `"top" \| "bottom"` | `"bottom"` | Social block above the credentials or after the CTA |
340
+ | `footer` | `Snippet` | — | Content below form |
341
+ | `notifications` | `NotificationsStack` | — | Route errors to notifications |
342
+ | `t` | `TranslateFn` | built-in | Translation function |
343
343
 
344
344
  **Imperative** (`bind:this`): `validate()`, `scrollToFirstError(opts?)`, `focusField(name)` — also forwarded by `RegisterFormModal`.
345
345
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.160.0",
3
+ "version": "3.162.0",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "scripts": {
6
6
  "dev": "vite dev",