@revenexx/studio-shared 0.3.0 → 0.3.2

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.
Files changed (29) hide show
  1. package/dist/module.d.mts +1 -0
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +5 -0
  4. package/dist/runtime/components/DataTable/ColumnsMenu.d.vue.ts +2 -0
  5. package/dist/runtime/components/DataTable/ColumnsMenu.vue +90 -34
  6. package/dist/runtime/components/DataTable/ColumnsMenu.vue.d.ts +2 -0
  7. package/dist/runtime/components/DataTable/DataTable.d.vue.ts +26 -2
  8. package/dist/runtime/components/DataTable/DataTable.vue +256 -108
  9. package/dist/runtime/components/DataTable/DataTable.vue.d.ts +26 -2
  10. package/dist/runtime/components/DataTable/FilterMenu.d.vue.ts +5 -3
  11. package/dist/runtime/components/DataTable/FilterMenu.vue +32 -36
  12. package/dist/runtime/components/DataTable/FilterMenu.vue.d.ts +5 -3
  13. package/dist/runtime/components/DataTable/types.d.ts +15 -2
  14. package/dist/runtime/components/Shared/FloatingPanel.d.vue.ts +55 -0
  15. package/dist/runtime/components/Shared/FloatingPanel.vue +118 -0
  16. package/dist/runtime/components/Shared/FloatingPanel.vue.d.ts +55 -0
  17. package/dist/runtime/composables/useFloatingPanel.d.ts +69 -0
  18. package/dist/runtime/composables/useFloatingPanel.js +112 -0
  19. package/dist/runtime/composables/useTalkbackChannels.d.ts +21 -6
  20. package/dist/runtime/composables/useTalkbackChannels.js +16 -12
  21. package/dist/runtime/lib/columnOrder.d.ts +45 -0
  22. package/dist/runtime/lib/columnOrder.js +28 -0
  23. package/dist/runtime/lib/columnVisibility.d.ts +41 -0
  24. package/dist/runtime/lib/columnVisibility.js +12 -0
  25. package/dist/runtime/lib/dataTableFilters.d.ts +83 -0
  26. package/dist/runtime/lib/dataTableFilters.js +23 -0
  27. package/dist/runtime/lib/i18nBridge.d.ts +7 -2
  28. package/dist/types.d.mts +2 -0
  29. package/package.json +2 -2
@@ -1,23 +1,28 @@
1
1
  <script setup>
2
- import { AltArrowDown, Filter, Magnifier as Search, SidebarMinimalistic } from "#solar-icons";
2
+ import {
3
+ Eye,
4
+ EyeClosed,
5
+ Filter,
6
+ ListArrowDownMinimalistic,
7
+ Magnifier as Search,
8
+ SidebarMinimalistic,
9
+ Widget as Columns
10
+ } from "#solar-icons";
3
11
  import {
4
12
  Button,
5
- DropdownMenu,
6
- DropdownMenuContent,
7
- DropdownMenuItem,
8
- DropdownMenuSeparator,
9
- DropdownMenuSub,
10
- DropdownMenuSubContent,
11
- DropdownMenuSubTrigger,
12
- DropdownMenuTrigger,
13
- Input
13
+ ColumnPicker,
14
+ FilterPanel,
15
+ SearchField,
16
+ SegmentedToggle,
17
+ SegmentedToggleItem
14
18
  } from "@revenexx/studio";
19
+ import { orderColumnsBy } from "../../lib/columnOrder";
20
+ import { initiallyHidden, visibleColumnsOf } from "../../lib/columnVisibility";
21
+ import { countByValue, matchesFilter } from "../../lib/dataTableFilters";
15
22
  import SortHeader from "../Shared/SortHeader.vue";
16
- import ColumnsMenu from "./ColumnsMenu.vue";
17
23
  import FilterMenu from "./FilterMenu.vue";
18
24
  import SavedViewsMenu from "./SavedViewsMenu.vue";
19
25
  import SortMenu from "./SortMenu.vue";
20
- import ViewModeSwitch from "./ViewModeSwitch.vue";
21
26
  const props = defineProps({
22
27
  data: { type: Array, required: true },
23
28
  columns: { type: Array, required: true },
@@ -29,6 +34,7 @@ const props = defineProps({
29
34
  savedViews: { type: [Object, null], required: false, default: null },
30
35
  viewModes: { type: [Array, null], required: false, default: null },
31
36
  rail: { type: [Boolean, Object], required: false, default: false },
37
+ scrollBody: { type: [Boolean, Object], required: false },
32
38
  pagination: { type: [Boolean, Object], required: false, default: true },
33
39
  defaultSort: { type: Object, required: false },
34
40
  serverMode: { type: Boolean, required: false, default: false },
@@ -57,19 +63,20 @@ const pageSize = ref(
57
63
  typeof props.pagination === "object" && props.pagination?.pageSize ? props.pagination.pageSize : 20
58
64
  );
59
65
  const pageSizeOptions = computed(() => Array.from(/* @__PURE__ */ new Set([10, 20, 50, 100, pageSize.value])).sort((a, b) => a - b));
60
- const visibleKeys = ref(
61
- new Set(props.columns.filter((c) => c.visible !== false).map((c) => c.key))
62
- );
63
- function toggleColumn(key) {
64
- const next = new Set(visibleKeys.value);
65
- if (next.has(key)) next.delete(key);
66
- else next.add(key);
67
- visibleKeys.value = next;
66
+ const hidden = ref(initiallyHidden(props.columns));
67
+ const columnOrder = ref([]);
68
+ const arrangedColumns = computed(() => orderColumnsBy(props.columns, columnOrder.value));
69
+ const visibleColumns = computed(() => visibleColumnsOf(arrangedColumns.value, hidden.value));
70
+ const pickerColumns = computed(() => arrangedColumns.value.filter((c) => (c.label ?? "").trim().length > 0).map((c) => ({
71
+ key: c.key,
72
+ label: c.label ?? c.key,
73
+ visible: !hidden.value.has(c.key),
74
+ toggleable: c.toggleable !== false
75
+ })));
76
+ function onPickerUpdate(next) {
77
+ columnOrder.value = next.map((c) => c.key);
78
+ hidden.value = new Set(next.filter((c) => c.visible === false).map((c) => c.key));
68
79
  }
69
- const visibleColumns = computed(
70
- () => props.columns.filter((c) => visibleKeys.value.has(c.key))
71
- );
72
- const menuColumns = computed(() => props.columns);
73
80
  function searchOf(col, row) {
74
81
  if (col.searchGetter) return col.searchGetter(row).toLowerCase();
75
82
  if (col.sortGetter) {
@@ -86,23 +93,33 @@ const filterDims = computed(() => {
86
93
  return arr.map((f, i) => ({ key: f.key ?? f.label ?? `dim${i}`, label: f.label ?? t("shared.datatable.filter.dimension.label.default"), getter: f.getter, options: f.options, allLabel: f.allLabel }));
87
94
  });
88
95
  const activeFilters = ref({});
89
- const anyFilterActive = computed(() => Object.values(activeFilters.value).some(Boolean));
90
- function setFilter(key, value) {
91
- activeFilters.value = { ...activeFilters.value, [key]: value };
96
+ const panelSelection = computed(() => Object.entries(activeFilters.value).flatMap(([key, values]) => values.map((v) => `${key}:${v}`)));
97
+ function applyPanelSelection(selected) {
98
+ const next = {};
99
+ for (const id of selected) {
100
+ const at = id.indexOf(":");
101
+ if (at === -1) continue;
102
+ const key = id.slice(0, at);
103
+ (next[key] ??= []).push(id.slice(at + 1));
104
+ }
105
+ activeFilters.value = next;
92
106
  }
107
+ const filterFacets = computed(() => filterDims.value.map((d) => {
108
+ const counts = dimCounts(d);
109
+ return {
110
+ key: d.key,
111
+ label: d.label,
112
+ options: d.options.map((o) => ({ value: o.value, label: o.label, count: counts[o.value] ?? 0 }))
113
+ };
114
+ }));
93
115
  function dimCounts(d) {
94
- const m = {};
95
- for (const row of props.data) {
96
- const k = String(d.getter(row) ?? "");
97
- m[k] = (m[k] ?? 0) + 1;
98
- }
99
- return m;
116
+ return countByValue(props.data, d.getter);
100
117
  }
101
118
  const filtered = computed(() => {
102
119
  if (props.serverMode || !filterDims.value.length) return props.data;
103
- const active = filterDims.value.filter((d) => activeFilters.value[d.key]);
120
+ const active = filterDims.value.filter((d) => activeFilters.value[d.key]?.length);
104
121
  if (!active.length) return props.data;
105
- return props.data.filter((row) => active.every((d) => String(d.getter(row) ?? "") === activeFilters.value[d.key]));
122
+ return props.data.filter((row) => active.every((d) => matchesFilter(d.getter, row, activeFilters.value[d.key])));
106
123
  });
107
124
  const searched = computed(() => {
108
125
  if (props.serverMode) return props.data;
@@ -207,6 +224,11 @@ function toggleSort(column) {
207
224
  }
208
225
  sort.value = { column: null, direction: null };
209
226
  }
227
+ function ariaSortFor(col) {
228
+ if (!col.sortable) return void 0;
229
+ if (sort.value.column !== col.key) return "none";
230
+ return sort.value.direction === "desc" ? "descending" : "ascending";
231
+ }
210
232
  function rowKeyFor(row) {
211
233
  if (typeof props.rowKey === "function") return props.rowKey(row);
212
234
  return row[props.rowKey];
@@ -218,6 +240,10 @@ const gridTemplate = computed(() => {
218
240
  const placeholder = computed(
219
241
  () => typeof props.search === "object" ? props.search.placeholder ?? t("shared.datatable.toolbar.search.placeholder.default") : t("shared.datatable.toolbar.search.placeholder.default")
220
242
  );
243
+ const searchLabel = computed(() => {
244
+ const configured = typeof props.search === "object" ? props.search.label ?? props.search.placeholder : void 0;
245
+ return configured ?? t("shared.datatable.toolbar.search.aria.label");
246
+ });
221
247
  const slots = useSlots();
222
248
  const showToolbar = computed(
223
249
  () => props.search || props.columnsMenu || props.filter || !!props.filterTabs || !!props.savedViews || !!props.viewModes?.length || !!props.rail || !!slots.actions || !!slots.scope || !!slots["toolbar-left"] || !!slots["toolbar-right"]
@@ -231,6 +257,11 @@ const showSortMenu = computed(() => !showTable.value && sortableColumns.value.le
231
257
  function cellIconFor(col) {
232
258
  return col.cellIcon ?? null;
233
259
  }
260
+ const scrollStyle = computed(() => {
261
+ if (!props.scrollBody) return void 0;
262
+ const configured = typeof props.scrollBody === "object" ? props.scrollBody.maxHeight : void 0;
263
+ return { maxHeight: configured ?? "calc(100vh - 22rem)" };
264
+ });
234
265
  const showFooter = computed(() => props.pagination !== false && !props.groupBy);
235
266
  const lastPage = computed(
236
267
  () => pageSize.value > 0 ? Math.max(0, Math.ceil(totalCount.value / pageSize.value) - 1) : 0
@@ -255,13 +286,42 @@ function defaultCell(col, row) {
255
286
  <template>
256
287
  <div class="rounded-lg border border-black/[0.06] overflow-hidden flex flex-col">
257
288
  <!-- Toolbar ------------------------------------------------------ -->
258
- <!-- The single control row of a list surface:
259
- LEFT = rail · search · filter · columns · #toolbar-left
260
- RIGHT = #toolbar-right · saved views · #scope · view mode · #actions
261
- (Pagination always lives in the footer.) -->
289
+ <!-- The single control row of a list surface.
290
+
291
+ The bar carries the dashboard's tint rather than flat white, so a
292
+ list's chrome and the home screen's are recognisably one surface. On
293
+ white the controls sat on nothing and the row read as a gap above the
294
+ table; with a ground under them they read as a row.
295
+
296
+ The stops are REVERSED from the dashboard's, and measuring is what
297
+ said so. The dashboard panel runs white → tint → white, which is right
298
+ for a panel whose content sits in the middle. A toolbar's content sits
299
+ at the two ENDS: the search field lands at 12% along the bar, where a
300
+ gradient starting pure white at 0% and reaching its tint at 50% is
301
+ still white. So a white field sat on a white ground with a 10% hairline
302
+ and a white rim doing nothing, while the identical control on the
303
+ dashboard read cleanly against 0.972 lightness.
304
+
305
+ Tinted at the ends, clear in the middle where nothing stands.
306
+
307
+ Not the dashboard's shimmer or its blurred corner blob: those are for a
308
+ panel somebody looks AT, and this is one they look through on the way
309
+ to the rows.
310
+ LEFT is LOOKING: rail · search · filter · sort · columns · scope ·
311
+ view mode · saved views · #toolbar-left
312
+ RIGHT is DOING: #toolbar-right · #actions
313
+ (Pagination always lives in the footer.)
314
+
315
+ The line is not "filters versus the rest". View mode and column
316
+ visibility narrow nothing and still belong left, because reaching for
317
+ them means you are still looking for something; once your hand crosses
318
+ to the right you have stopped looking and started acting. Scope, view
319
+ mode and saved views used to sit on the right beside the primary
320
+ action, which made a row of eight controls with no readable boundary
321
+ in it. -->
262
322
  <div
263
323
  v-if="showToolbar"
264
- class="flex flex-wrap items-center justify-between gap-2 px-4 py-3 bg-white/55 backdrop-blur-md"
324
+ class="flex flex-wrap items-center justify-between gap-2 px-4 py-3 bg-gradient-to-r from-violet-50/80 via-white to-violet-50/60"
265
325
  >
266
326
  <div class="flex items-center gap-2 min-w-0">
267
327
  <Button
@@ -278,77 +338,111 @@ function defaultCell(col, row) {
278
338
  {{ railLabel }}
279
339
  </Button>
280
340
  <!-- Shrinks (never wraps the row) once the toolbar carries scope /
281
- view-mode / saved-view controls next to it. -->
282
- <div v-if="search" class="relative w-[21rem] min-w-40 max-w-full shrink">
283
- <Search :size="13" class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
284
- <Input
285
- v-model="query"
286
- :placeholder="placeholder"
287
- class="h-7 pl-8 text-sm bg-white"
288
- />
289
- </div>
290
- <!-- Generic "Filter" dropdown — each dimension is a labelled sub-menu. -->
291
- <DropdownMenu v-if="filterDims.length">
292
- <DropdownMenuTrigger as-child>
293
- <Button
294
- variant="outline"
295
- size="sm"
296
- class="h-7 gap-1.5 text-sm"
297
- :class="anyFilterActive ? 'border-primary/30 text-primary' : ''"
298
- >
299
- <Filter :size="14" />
300
- {{ t("shared.datatable.toolbar.filter.action") }}
301
- <AltArrowDown :size="12" class="opacity-60" />
302
- </Button>
303
- </DropdownMenuTrigger>
304
- <DropdownMenuContent align="start" class="w-48">
305
- <DropdownMenuSub v-for="d in filterDims" :key="d.key">
306
- <DropdownMenuSubTrigger class="gap-2">
307
- <span class="flex-1">{{ d.label }}</span>
308
- <span v-if="activeFilters[d.key]" class="text-xs text-primary capitalize">{{ activeFilters[d.key] }}</span>
309
- </DropdownMenuSubTrigger>
310
- <DropdownMenuSubContent class="w-48">
311
- <DropdownMenuItem class="gap-2" @select="setFilter(d.key, null)">
312
- <span class="flex-1">{{ d.allLabel ?? t("shared.datatable.toolbar.filter.all.default") }}</span>
313
- <span class="tabular-nums text-muted-foreground">{{ data.length }}</span>
314
- </DropdownMenuItem>
315
- <DropdownMenuSeparator />
316
- <DropdownMenuItem
317
- v-for="o in d.options"
318
- :key="o.value"
319
- class="gap-2"
320
- @select="setFilter(d.key, o.value)"
321
- >
322
- <span class="flex-1">{{ o.label }}</span>
323
- <span class="tabular-nums text-muted-foreground">{{ dimCounts(d)[o.value] ?? 0 }}</span>
324
- </DropdownMenuItem>
325
- </DropdownMenuSubContent>
326
- </DropdownMenuSub>
327
- </DropdownMenuContent>
328
- </DropdownMenu>
341
+ view-mode / saved-view controls next to it.
342
+
343
+ `SearchField` rather than an Input with an icon positioned over it
344
+ and a counted `pl-8`: three decisions that had to agree, and any
345
+ change to the icon size or the field height broke one of them. -->
346
+ <SearchField
347
+ v-if="search"
348
+ v-model="query"
349
+ :placeholder="placeholder"
350
+ :label="searchLabel"
351
+ class="w-[21rem] min-w-40 max-w-full shrink"
352
+ >
353
+ <template #icon>
354
+ <Search :size="14" weight="Linear" />
355
+ </template>
356
+ </SearchField>
357
+ <!--
358
+ Every facet at once, committed with Apply.
359
+
360
+ It was a menu of submenus: one facet visible at a time, one value
361
+ each, and the answer to "what is filtering right now?" only available
362
+ by opening all of them in turn. The panel lets a facet hold more than
363
+ one value and does not move the list until the reader is done
364
+ deciding.
365
+
366
+ `panes` rather than the automatic choice. Auto reads the numbers - it
367
+ switches past four facets or eight options - and today's surfaces sit
368
+ just under both thresholds, so they would draw as columns while the
369
+ same control on the pattern page draws as panes. A table that gains a
370
+ fifth facet would then change shape on its own. One layout, whatever
371
+ the surface happens to declare this week.
372
+ -->
373
+ <FilterPanel
374
+ v-if="filterFacets.length"
375
+ :model-value="panelSelection"
376
+ :facets="filterFacets"
377
+ layout="panes"
378
+ :label="t('shared.datatable.toolbar.filter.action')"
379
+ :apply-label="t('shared.datatable.toolbar.filter.apply')"
380
+ :reset-label="t('shared.datatable.toolbar.filter.reset')"
381
+ @update:model-value="applyPanelSelection"
382
+ >
383
+ <template #icon>
384
+ <Filter :size="14" weight="BoldDuotone" />
385
+ </template>
386
+ </FilterPanel>
329
387
  <FilterMenu
330
388
  v-if="filter"
331
389
  :width="typeof filter === 'object' ? filter.width : void 0"
332
390
  :active="typeof filter === 'object' ? filter.active : void 0"
391
+ :count="typeof filter === 'object' ? filter.count : void 0"
333
392
  >
334
393
  <slot name="filter-menu" />
335
394
  </FilterMenu>
336
395
  <SortMenu v-if="showSortMenu" v-model="sort" :columns="sortableColumns" />
337
- <ColumnsMenu
396
+ <!--
397
+ The design system's picker, drag and drop included. It was a local
398
+ copy that did its own dragging with the browser's own drag events,
399
+ which cannot say where a row may GO: a row could be pulled out over
400
+ the table behind the popover and dropped there. Only the rows of this
401
+ list register as drop targets now, and the edge the pointer is nearest
402
+ decides the index rather than arithmetic on a row height that a
403
+ wrapped label invalidates.
404
+ -->
405
+ <ColumnPicker
338
406
  v-if="columnsMenu && showTable"
339
- :columns="menuColumns"
340
- :visible="visibleKeys"
341
- @toggle="toggleColumn"
342
- />
407
+ :model-value="pickerColumns"
408
+ :label="t('shared.datatable.columnsmenu.tooltip')"
409
+ :heading="t('shared.datatable.columnsmenu.heading')"
410
+ @update:model-value="onPickerUpdate"
411
+ >
412
+ <template #icon>
413
+ <Columns :size="15" weight="BoldDuotone" />
414
+ </template>
415
+ <template #handle>
416
+ <ListArrowDownMinimalistic :size="15" weight="Linear" />
417
+ </template>
418
+ <template #visible>
419
+ <Eye :size="16" weight="Linear" />
420
+ </template>
421
+ <template #hidden>
422
+ <EyeClosed :size="16" weight="Linear" />
423
+ </template>
424
+ </ColumnPicker>
425
+ <!-- Market scope — the surface passes the shared MarketScopeToggle in;
426
+ the row position is fixed here. -->
427
+ <slot name="scope" />
428
+ <!-- The editor's device switch, which is the right shape wherever the
429
+ choice changes what you are LOOKING AT rather than which rows. -->
430
+ <SegmentedToggle v-if="viewModes?.length" v-model="viewMode" variant="inset">
431
+ <SegmentedToggleItem
432
+ v-for="m in viewModes"
433
+ :key="m.value"
434
+ :value="m.value"
435
+ :aria-label="m.label"
436
+ >
437
+ <component :is="m.icon" v-if="m.icon" :size="14" weight="Bold" />
438
+ <template v-if="!m.icon">{{ m.label }}</template>
439
+ </SegmentedToggleItem>
440
+ </SegmentedToggle>
441
+ <SavedViewsMenu v-if="savedViews" :config="savedViews" />
343
442
  <slot name="toolbar-left" />
344
443
  </div>
345
- <div class="flex items-center gap-1.5 ml-auto shrink-0">
444
+ <div class="flex items-center gap-2 ml-auto shrink-0">
346
445
  <slot name="toolbar-right" />
347
- <SavedViewsMenu v-if="savedViews" :config="savedViews" />
348
- <!-- Market scope ("Showing: … / All markets") — the surface passes the
349
- shared MarketScopeToggle in; the row position is fixed here. -->
350
- <slot name="scope" />
351
- <ViewModeSwitch v-if="viewModes?.length" v-model="viewMode" :modes="viewModes" />
352
446
  <slot name="actions" />
353
447
  </div>
354
448
  </div>
@@ -358,15 +452,48 @@ function defaultCell(col, row) {
358
452
  <slot name="view" :rows="displayedRows" :loading="loading" />
359
453
  </div>
360
454
 
455
+ <!-- The header and the rows share one box so their columns cannot drift.
456
+ `display: contents` when scrolling is off makes this element vanish
457
+ from layout entirely, so every table that has not asked for it renders
458
+ exactly as it did.
459
+
460
+ IT IS ALSO THE TABLE. The box that holds the header and the rows and
461
+ nothing else is the only element in this component that can carry the
462
+ role: the root has the toolbar and the footer in it, and a `table` may
463
+ own rows and rowgroups only. Which is why everything below is a `row`
464
+ or is hidden — an empty state or a skeleton left as a bare `div` here
465
+ would be a child the role does not allow, and the whole table's
466
+ semantics are then whatever the browser decides to salvage.
467
+
468
+ `table` and not `grid`. `grid` is the interactive one: it promises
469
+ arrow keys move a focus between cells, and nothing here implements
470
+ that. A static table read with a screen reader's own table commands is
471
+ what these lists actually are, and both roles announce the column
472
+ heading with the cell, which is the thing that was missing. -->
473
+ <div
474
+ :role="showTable ? 'table' : void 0"
475
+ :aria-busy="showTable && loading ? 'true' : void 0"
476
+ :class="scrollBody ? 'min-h-0 overflow-y-auto' : 'contents'"
477
+ :style="scrollStyle"
478
+ >
361
479
  <!-- Header row -------------------------------------------------- -->
362
480
  <div
363
481
  v-if="showTable"
482
+ role="row"
364
483
  class="grid items-center gap-2 border-t rounded-t-lg border-gray-200 bg-gray-100 px-4 py-2.5 text-sm font-medium uppercase tracking-normal text-muted-foreground"
484
+ :class="scrollBody ? 'sticky top-0 z-10' : ''"
365
485
  :style="gridTemplate"
366
486
  >
487
+ <!-- `columnheader` is what names every cell underneath it, so this is
488
+ the half of the fix that does the work. `aria-sort` goes with it:
489
+ a heading that carries a sort button and says nothing about the
490
+ current direction is a control whose state is visible only to
491
+ people who can see the arrow. -->
367
492
  <div
368
493
  v-for="col in visibleColumns"
369
494
  :key="col.key"
495
+ role="columnheader"
496
+ :aria-sort="ariaSortFor(col)"
370
497
  :class="col.alignRight ? 'text-right' : ''"
371
498
  >
372
499
  <!-- `#header-<key>` lets a page take over a header cell (e.g. a
@@ -383,23 +510,33 @@ function defaultCell(col, row) {
383
510
  >
384
511
  {{ col.label }}
385
512
  </SortHeader>
386
- <span v-else>{{ col.label }}</span>
513
+ <!-- `normal-case` to match SortHeader, whose button does not inherit
514
+ the row's uppercase. Without it a column reads "LAST CHECK"
515
+ beside "Channel" and "Provider" for no reason a reader can see —
516
+ the header shouts wherever a column happens not to be sortable. -->
517
+ <span v-else class="normal-case">{{ col.label }}</span>
387
518
  </slot>
388
519
  </div>
389
520
  </div>
390
521
 
391
522
  <!-- Body --------------------------------------------------------- -->
392
523
  <template v-for="entry in showTable ? renderRows : []" :key="entry.key">
393
- <!-- Group header (groupBy mode) -->
524
+ <!-- Group header (groupBy mode) — a row of one cell that spans the lot.
525
+ The flex moves one element in, onto the cell: a `row` may hold
526
+ cells and the label and the count are neither. -->
394
527
  <div
395
528
  v-if="entry.kind === 'header'"
396
- class="flex items-center gap-2 border-t border-black/[0.04] bg-gray-100/70 px-4 py-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground"
529
+ role="row"
530
+ class="border-t border-black/[0.04] bg-gray-100/70 px-4 py-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground"
397
531
  >
398
- {{ entry.label }}
399
- <span class="tabular-nums normal-case text-muted-foreground/70">{{ entry.count }}</span>
532
+ <div role="cell" :aria-colspan="visibleColumns.length" class="flex items-center gap-2">
533
+ {{ entry.label }}
534
+ <span class="tabular-nums normal-case text-muted-foreground/70">{{ entry.count }}</span>
535
+ </div>
400
536
  </div>
401
537
  <div
402
538
  v-else
539
+ role="row"
403
540
  class="group grid items-center gap-2 border-t border-black/[0.04] px-4 py-3 cursor-pointer bg-gray-50 transition-colors"
404
541
  :style="gridTemplate"
405
542
  @click="emit('rowClick', entry.row)"
@@ -407,6 +544,7 @@ function defaultCell(col, row) {
407
544
  <div
408
545
  v-for="col in visibleColumns"
409
546
  :key="col.key"
547
+ role="cell"
410
548
  class="min-w-0"
411
549
  :class="col.alignRight ? 'text-right' : ''"
412
550
  >
@@ -430,11 +568,16 @@ function defaultCell(col, row) {
430
568
  </div>
431
569
  </template>
432
570
 
433
- <!-- Loading skeleton (only when there are no rows yet) ----------- -->
571
+ <!-- Loading skeleton (only when there are no rows yet) -----------
572
+ Hidden rather than given roles. Six rows of pulsing grey carry no
573
+ information to read out, and announcing them as six empty rows would
574
+ be worse than silence; the table says it is `aria-busy` instead,
575
+ which is the same news in one word. -->
434
576
  <template v-if="showTable && loading && !displayedRows.length">
435
577
  <div
436
578
  v-for="n in 6"
437
579
  :key="`sk-${n}`"
580
+ aria-hidden="true"
438
581
  class="grid items-center gap-2 border-t border-black/[0.04] bg-gray-50 px-4 py-3"
439
582
  :style="gridTemplate"
440
583
  >
@@ -454,11 +597,16 @@ function defaultCell(col, row) {
454
597
  <!-- Empty -------------------------------------------------------- -->
455
598
  <div
456
599
  v-if="showTable && !displayedRows.length && !loading"
600
+ role="row"
457
601
  class="px-3 py-8 text-center text-sm text-muted-foreground"
458
602
  >
459
- <slot name="empty">
460
- {{ emptyText ?? t("shared.datatable.empty.default") }}
461
- </slot>
603
+ <div role="cell" :aria-colspan="visibleColumns.length">
604
+ <slot name="empty">
605
+ {{ emptyText ?? t("shared.datatable.empty.default") }}
606
+ </slot>
607
+ </div>
608
+ </div>
609
+
462
610
  </div>
463
611
 
464
612
  <!-- Footer ------------------------------------------------------- -->
@@ -11,8 +11,11 @@ declare const _default: <T>(__VLS_props: NonNullable<Awaited<typeof __VLS_setup>
11
11
  data: T[];
12
12
  columns: DataTableColumn<T>[];
13
13
  rowKey: RowKey<T>;
14
+ /** `label` is the field's accessible name; it defaults to the placeholder
15
+ * where the surface wrote one (see `searchLabel`). */
14
16
  search?: boolean | {
15
17
  placeholder?: string;
18
+ label?: string;
16
19
  };
17
20
  columnsMenu?: boolean;
18
21
  /** Mount the surface's own filter panel in the toolbar's Filter popover
@@ -20,6 +23,7 @@ declare const _default: <T>(__VLS_props: NonNullable<Awaited<typeof __VLS_setup>
20
23
  filter?: boolean | {
21
24
  width?: string;
22
25
  active?: boolean;
26
+ count?: number;
23
27
  };
24
28
  /** Built-in status/dimension filter pills (static mode). One dimension, or
25
29
  * several (each renders as its own sub-menu). */
@@ -32,6 +36,26 @@ declare const _default: <T>(__VLS_props: NonNullable<Awaited<typeof __VLS_setup>
32
36
  viewModes?: DataTableViewMode[] | null;
33
37
  /** Collapse handle for the surface's left rail (see `ListSurface`). */
34
38
  rail?: boolean | DataTableRail;
39
+ /**
40
+ * Scroll the ROWS inside the table instead of the page.
41
+ *
42
+ * The toolbar, the header and the footer stay put and only the data moves,
43
+ * so paging through a long list keeps the search box, the filters and the
44
+ * "showing 20 of 400" in view — you stay in the table's context instead of
45
+ * scrolling the whole surface away and losing the controls that scope it.
46
+ *
47
+ * OPT-IN. A short list scrolling inside a box shorter than the list is worse
48
+ * than a page that scrolls, and there is no way for this component to know
49
+ * which it is looking at.
50
+ *
51
+ * The header row moves INSIDE the scroll container and sticks, rather than
52
+ * sitting above it. That is not a detail: a scrollbar takes width from the
53
+ * scrolling box, so a header outside it stays wider than the rows and every
54
+ * column drifts by however wide the scrollbar is.
55
+ */
56
+ scrollBody?: boolean | {
57
+ maxHeight?: string;
58
+ };
35
59
  pagination?: boolean | {
36
60
  pageSize?: number;
37
61
  };
@@ -61,12 +85,12 @@ declare const _default: <T>(__VLS_props: NonNullable<Awaited<typeof __VLS_setup>
61
85
  }) => any) | undefined;
62
86
  } & {
63
87
  'filter-menu'?: (props: {}) => any;
88
+ } & {
89
+ scope?: (props: {}) => any;
64
90
  } & {
65
91
  'toolbar-left'?: (props: {}) => any;
66
92
  } & {
67
93
  'toolbar-right'?: (props: {}) => any;
68
- } & {
69
- scope?: (props: {}) => any;
70
94
  } & {
71
95
  actions?: (props: {}) => any;
72
96
  } & {
@@ -1,12 +1,14 @@
1
1
  type __VLS_Props = {
2
2
  /** Popover width class (a wide panel of inputs needs more than the default). */
3
3
  width?: string;
4
- /** Tint the trigger while at least one filter is set. */
4
+ /** The trigger goes primary while at least one filter is set. */
5
5
  active?: boolean;
6
+ /** How many are set, for the badge. */
7
+ count?: number;
6
8
  };
7
- declare var __VLS_34: {};
9
+ declare var __VLS_22: {};
8
10
  type __VLS_Slots = {} & {
9
- default?: (props: typeof __VLS_34) => any;
11
+ default?: (props: typeof __VLS_22) => any;
10
12
  };
11
13
  declare const __VLS_component: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {
12
14
  active: boolean;