@revenexx/studio-shared 0.3.1 → 0.3.3

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 (85) hide show
  1. package/dist/module.d.mts +4 -0
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +9 -0
  4. package/dist/runtime/components/Dashboard/DashboardConfirmDialog.d.vue.ts +15 -0
  5. package/dist/runtime/components/Dashboard/DashboardConfirmDialog.vue +39 -0
  6. package/dist/runtime/components/Dashboard/DashboardConfirmDialog.vue.d.ts +15 -0
  7. package/dist/runtime/components/Dashboard/DashboardControls.d.vue.ts +57 -0
  8. package/dist/runtime/components/Dashboard/DashboardControls.vue +209 -0
  9. package/dist/runtime/components/Dashboard/DashboardControls.vue.d.ts +57 -0
  10. package/dist/runtime/components/Dashboard/DashboardGrid.d.vue.ts +16 -0
  11. package/dist/runtime/components/Dashboard/DashboardGrid.vue +87 -0
  12. package/dist/runtime/components/Dashboard/DashboardGrid.vue.d.ts +16 -0
  13. package/dist/runtime/components/Dashboard/DashboardNameDialog.d.vue.ts +18 -0
  14. package/dist/runtime/components/Dashboard/DashboardNameDialog.vue +61 -0
  15. package/dist/runtime/components/Dashboard/DashboardNameDialog.vue.d.ts +18 -0
  16. package/dist/runtime/components/Dashboard/DashboardPickerDialog.d.vue.ts +14 -0
  17. package/dist/runtime/components/Dashboard/DashboardPickerDialog.vue +100 -0
  18. package/dist/runtime/components/Dashboard/DashboardPickerDialog.vue.d.ts +14 -0
  19. package/dist/runtime/components/Dashboard/DashboardShareDialog.d.vue.ts +27 -0
  20. package/dist/runtime/components/Dashboard/DashboardShareDialog.vue +155 -0
  21. package/dist/runtime/components/Dashboard/DashboardShareDialog.vue.d.ts +27 -0
  22. package/dist/runtime/components/Dashboard/DashboardTopBar.d.vue.ts +59 -0
  23. package/dist/runtime/components/Dashboard/DashboardTopBar.vue +79 -0
  24. package/dist/runtime/components/Dashboard/DashboardTopBar.vue.d.ts +59 -0
  25. package/dist/runtime/components/Dashboard/DashboardWidgetConfigDialog.d.vue.ts +42 -0
  26. package/dist/runtime/components/Dashboard/DashboardWidgetConfigDialog.vue +153 -0
  27. package/dist/runtime/components/Dashboard/DashboardWidgetConfigDialog.vue.d.ts +42 -0
  28. package/dist/runtime/components/Dashboard/DashboardWidgetFrame.d.vue.ts +27 -0
  29. package/dist/runtime/components/Dashboard/DashboardWidgetFrame.vue +80 -0
  30. package/dist/runtime/components/Dashboard/DashboardWidgetFrame.vue.d.ts +27 -0
  31. package/dist/runtime/components/Dashboard/DashboardWidgetHost.d.vue.ts +6 -0
  32. package/dist/runtime/components/Dashboard/DashboardWidgetHost.vue +30 -0
  33. package/dist/runtime/components/Dashboard/DashboardWidgetHost.vue.d.ts +6 -0
  34. package/dist/runtime/components/Dashboard/DashboardWidgetIcon.d.vue.ts +8 -0
  35. package/dist/runtime/components/Dashboard/DashboardWidgetIcon.vue +13 -0
  36. package/dist/runtime/components/Dashboard/DashboardWidgetIcon.vue.d.ts +8 -0
  37. package/dist/runtime/components/Dashboard/DashboardWidgetLibrary.d.vue.ts +23 -0
  38. package/dist/runtime/components/Dashboard/DashboardWidgetLibrary.vue +140 -0
  39. package/dist/runtime/components/Dashboard/DashboardWidgetLibrary.vue.d.ts +23 -0
  40. package/dist/runtime/components/Dashboard/DashboardWidgetPreview.d.vue.ts +22 -0
  41. package/dist/runtime/components/Dashboard/DashboardWidgetPreview.vue +124 -0
  42. package/dist/runtime/components/Dashboard/DashboardWidgetPreview.vue.d.ts +22 -0
  43. package/dist/runtime/components/DataTable/DataTable.d.vue.ts +39 -3
  44. package/dist/runtime/components/DataTable/DataTable.vue +306 -32
  45. package/dist/runtime/components/DataTable/DataTable.vue.d.ts +39 -3
  46. package/dist/runtime/components/DataTable/FacetChips.d.vue.ts +16 -0
  47. package/dist/runtime/components/DataTable/FacetChips.vue +72 -0
  48. package/dist/runtime/components/DataTable/FacetChips.vue.d.ts +16 -0
  49. package/dist/runtime/components/DataTable/FacetPanel.d.vue.ts +20 -0
  50. package/dist/runtime/components/DataTable/FacetPanel.vue +359 -0
  51. package/dist/runtime/components/DataTable/FacetPanel.vue.d.ts +20 -0
  52. package/dist/runtime/components/DataTable/SavedViewsMenu.d.vue.ts +5 -1
  53. package/dist/runtime/components/DataTable/SavedViewsMenu.vue +83 -63
  54. package/dist/runtime/components/DataTable/SavedViewsMenu.vue.d.ts +5 -1
  55. package/dist/runtime/components/DataTable/SortMenu.d.vue.ts +5 -1
  56. package/dist/runtime/components/DataTable/SortMenu.vue +40 -12
  57. package/dist/runtime/components/DataTable/SortMenu.vue.d.ts +5 -1
  58. package/dist/runtime/components/Media/MediaPickerDialog.d.vue.ts +2 -2
  59. package/dist/runtime/components/Media/MediaPickerDialog.vue.d.ts +2 -2
  60. package/dist/runtime/components/Media/MediaUploader.d.vue.ts +2 -2
  61. package/dist/runtime/components/Media/MediaUploader.vue.d.ts +2 -2
  62. package/dist/runtime/components/Shared/SortHeader.vue +6 -2
  63. package/dist/runtime/components/Shared/TabBar.d.vue.ts +23 -7
  64. package/dist/runtime/components/Shared/TabBar.vue +3 -2
  65. package/dist/runtime/components/Shared/TabBar.vue.d.ts +23 -7
  66. package/dist/runtime/composables/useDashboards.d.ts +27 -0
  67. package/dist/runtime/composables/useDashboards.js +143 -0
  68. package/dist/runtime/composables/useSavedViews.d.ts +44 -0
  69. package/dist/runtime/composables/useSavedViews.js +44 -0
  70. package/dist/runtime/composables/useStickyListView.d.ts +60 -0
  71. package/dist/runtime/composables/useStickyListView.js +32 -0
  72. package/dist/runtime/lib/canonicalJson.d.ts +43 -0
  73. package/dist/runtime/lib/canonicalJson.js +38 -0
  74. package/dist/runtime/lib/dashboardIcons.d.ts +4 -0
  75. package/dist/runtime/lib/dashboardIcons.js +50 -0
  76. package/dist/runtime/lib/facets.d.ts +200 -0
  77. package/dist/runtime/lib/facets.js +171 -0
  78. package/dist/runtime/lib/gridTracks.d.ts +55 -0
  79. package/dist/runtime/lib/gridTracks.js +29 -0
  80. package/dist/runtime/lib/widgetRegistry.d.ts +124 -0
  81. package/dist/runtime/lib/widgetRegistry.js +66 -0
  82. package/dist/runtime/types/dashboard.d.ts +95 -0
  83. package/dist/runtime/types/dashboard.js +2 -0
  84. package/dist/types.d.mts +8 -0
  85. package/package.json +4 -3
@@ -19,7 +19,10 @@ import {
19
19
  import { orderColumnsBy } from "../../lib/columnOrder";
20
20
  import { initiallyHidden, visibleColumnsOf } from "../../lib/columnVisibility";
21
21
  import { countByValue, matchesFilter } from "../../lib/dataTableFilters";
22
+ import { gridTemplateColumns } from "../../lib/gridTracks";
22
23
  import SortHeader from "../Shared/SortHeader.vue";
24
+ import FacetChips from "./FacetChips.vue";
25
+ import FacetPanel from "./FacetPanel.vue";
23
26
  import FilterMenu from "./FilterMenu.vue";
24
27
  import SavedViewsMenu from "./SavedViewsMenu.vue";
25
28
  import SortMenu from "./SortMenu.vue";
@@ -31,10 +34,12 @@ const props = defineProps({
31
34
  columnsMenu: { type: Boolean, required: false, default: true },
32
35
  filter: { type: [Boolean, Object], required: false, default: false },
33
36
  filterTabs: { type: [Object, Array], required: false },
37
+ facets: { type: Array, required: false, default: () => [] },
38
+ chipFacetLabels: { type: Boolean, required: false, default: false },
34
39
  savedViews: { type: [Object, null], required: false, default: null },
35
40
  viewModes: { type: [Array, null], required: false, default: null },
36
41
  rail: { type: [Boolean, Object], required: false, default: false },
37
- scrollBody: { type: [Boolean, Object], required: false },
42
+ scrollBody: { type: [Boolean, String, Object], required: false },
38
43
  pagination: { type: [Boolean, Object], required: false, default: true },
39
44
  defaultSort: { type: Object, required: false },
40
45
  serverMode: { type: Boolean, required: false, default: false },
@@ -56,6 +61,8 @@ const sort = defineModel("sort", { type: Object, ...{
56
61
  const page = defineModel("page", { type: Number, ...{ default: 0 } });
57
62
  const viewMode = defineModel("viewMode", { type: String, ...{ default: "list" } });
58
63
  const railOpen = defineModel("railOpen", { type: Boolean, ...{ default: true } });
64
+ const facetSelection = defineModel("facetSelection", { type: Object, ...{ default: () => ({}) } });
65
+ const panelFacets = computed(() => props.facets);
59
66
  if (props.defaultSort && sort.value.column == null) {
60
67
  sort.value = { column: props.defaultSort.column, direction: props.defaultSort.direction };
61
68
  }
@@ -224,23 +231,30 @@ function toggleSort(column) {
224
231
  }
225
232
  sort.value = { column: null, direction: null };
226
233
  }
234
+ function ariaSortFor(col) {
235
+ if (!col.sortable) return void 0;
236
+ if (sort.value.column !== col.key) return "none";
237
+ return sort.value.direction === "desc" ? "descending" : "ascending";
238
+ }
227
239
  function rowKeyFor(row) {
228
240
  if (typeof props.rowKey === "function") return props.rowKey(row);
229
241
  return row[props.rowKey];
230
242
  }
231
- const gridTemplate = computed(() => {
232
- const tracks = visibleColumns.value.map((c) => c.width ?? "minmax(0,1fr)");
233
- return `grid-template-columns: ${tracks.join(" ")};`;
234
- });
243
+ const gridTemplate = computed(() => `grid-template-columns: ${gridTemplateColumns(visibleColumns.value.map((c) => c.width))};`);
244
+ const SUBGRID_ROW = "grid-column: 1 / -1; grid-template-columns: subgrid;";
235
245
  const placeholder = computed(
236
246
  () => typeof props.search === "object" ? props.search.placeholder ?? t("shared.datatable.toolbar.search.placeholder.default") : t("shared.datatable.toolbar.search.placeholder.default")
237
247
  );
248
+ const searchLabel = computed(() => {
249
+ const configured = typeof props.search === "object" ? props.search.label ?? props.search.placeholder : void 0;
250
+ return configured ?? t("shared.datatable.toolbar.search.aria.label");
251
+ });
238
252
  const slots = useSlots();
239
253
  const showToolbar = computed(
240
254
  () => 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"]
241
255
  );
242
256
  const railLabel = computed(
243
- () => (typeof props.rail === "object" ? props.rail.label : void 0) ?? t("shared.datatable.toolbar.rail.label.default")
257
+ () => (typeof props.rail === "object" ? props.rail.label : void 0) ?? null
244
258
  );
245
259
  const showTable = computed(() => !props.viewModes?.length || viewMode.value === "list");
246
260
  const sortableColumns = computed(() => props.columns.filter((c) => c.sortable));
@@ -248,8 +262,121 @@ const showSortMenu = computed(() => !showTable.value && sortableColumns.value.le
248
262
  function cellIconFor(col) {
249
263
  return col.cellIcon ?? null;
250
264
  }
265
+ const scrollEl = ref(null);
266
+ const footerEl = ref(null);
267
+ const fitHeight = ref(null);
268
+ const MIN_FIT_PX = 220;
269
+ const BOTTOM_GAP_PX = 16;
270
+ function scrollParent(el) {
271
+ let node = el.parentElement;
272
+ while (node && node !== document.body) {
273
+ const overflow = getComputedStyle(node).overflowY;
274
+ if (overflow === "auto" || overflow === "scroll") return node;
275
+ node = node.parentElement;
276
+ }
277
+ return document.documentElement;
278
+ }
279
+ function measureFit() {
280
+ if (props.scrollBody !== "fit" || !import.meta.client) return;
281
+ const el = scrollEl.value;
282
+ if (!el) return;
283
+ const container = scrollParent(el);
284
+ const offset = el.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop;
285
+ const reserve = (footerEl.value?.offsetHeight ?? 0) + BOTTOM_GAP_PX;
286
+ const overflow = Math.max(container.scrollHeight - container.clientHeight, 0);
287
+ const available = Math.max(
288
+ Math.floor(container.clientHeight - offset - reserve - overflow) - 1,
289
+ MIN_FIT_PX
290
+ );
291
+ const current = fitHeight.value ? Number.parseInt(fitHeight.value, 10) : null;
292
+ if (current !== null && Math.abs(current - available) < 8) return;
293
+ fitHeight.value = `${available}px`;
294
+ }
295
+ if (import.meta.client) {
296
+ let frame = 0;
297
+ let timers = [];
298
+ const schedule = () => {
299
+ if (frame) return;
300
+ frame = requestAnimationFrame(() => {
301
+ frame = 0;
302
+ measureFit();
303
+ });
304
+ };
305
+ onMounted(() => {
306
+ measureFit();
307
+ window.addEventListener("resize", schedule);
308
+ timers = [setTimeout(schedule, 250), setTimeout(schedule, 1200)];
309
+ });
310
+ onBeforeUnmount(() => {
311
+ window.removeEventListener("resize", schedule);
312
+ for (const timer of timers) clearTimeout(timer);
313
+ timers = [];
314
+ if (frame) cancelAnimationFrame(frame);
315
+ });
316
+ watch(() => [props.data.length, props.loading], () => nextTick(() => {
317
+ schedule();
318
+ syncThumb();
319
+ }));
320
+ onMounted(() => {
321
+ nextTick(syncThumb);
322
+ window.addEventListener("resize", syncThumb);
323
+ });
324
+ onBeforeUnmount(() => window.removeEventListener("resize", syncThumb));
325
+ watch(fitHeight, () => nextTick(syncThumb));
326
+ }
327
+ const headerRowEl = ref(null);
328
+ const headerHeight = ref(0);
329
+ const rowsOverflow = ref(false);
330
+ const thumbHeight = ref(0);
331
+ const thumbOffset = ref(0);
332
+ const MIN_THUMB_PX = 32;
333
+ const drawnBarSupported = import.meta.client && typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("selector(::-webkit-scrollbar)");
334
+ function syncThumb() {
335
+ const el = scrollEl.value;
336
+ if (!props.scrollBody || !el) return;
337
+ headerHeight.value = headerRowEl.value?.offsetHeight ?? 0;
338
+ const trackHeight = el.clientHeight - headerHeight.value;
339
+ const scrollable = el.scrollHeight - el.clientHeight;
340
+ rowsOverflow.value = drawnBarSupported && scrollable > 1 && trackHeight > MIN_THUMB_PX;
341
+ if (!rowsOverflow.value) return;
342
+ const ratio = el.clientHeight / el.scrollHeight;
343
+ const height = Math.max(Math.round(trackHeight * ratio), MIN_THUMB_PX);
344
+ thumbHeight.value = height;
345
+ thumbOffset.value = Math.round(el.scrollTop / scrollable * (trackHeight - height));
346
+ }
347
+ function onRowsScroll() {
348
+ syncThumb();
349
+ }
350
+ function startThumbDrag(event) {
351
+ const el = scrollEl.value;
352
+ if (!el) return;
353
+ event.preventDefault();
354
+ const startY = event.clientY;
355
+ const startScroll = el.scrollTop;
356
+ const trackHeight = el.clientHeight - headerHeight.value;
357
+ const scrollable = el.scrollHeight - el.clientHeight;
358
+ const travel = trackHeight - thumbHeight.value;
359
+ if (travel <= 0) return;
360
+ const target = event.target;
361
+ target.setPointerCapture(event.pointerId);
362
+ const move = (e) => {
363
+ el.scrollTop = startScroll + (e.clientY - startY) / travel * scrollable;
364
+ };
365
+ const up = (e) => {
366
+ target.releasePointerCapture(e.pointerId);
367
+ target.removeEventListener("pointermove", move);
368
+ target.removeEventListener("pointerup", up);
369
+ target.removeEventListener("pointercancel", up);
370
+ };
371
+ target.addEventListener("pointermove", move);
372
+ target.addEventListener("pointerup", up);
373
+ target.addEventListener("pointercancel", up);
374
+ }
251
375
  const scrollStyle = computed(() => {
252
376
  if (!props.scrollBody) return void 0;
377
+ if (props.scrollBody === "fit") {
378
+ return fitHeight.value ? { maxHeight: fitHeight.value } : void 0;
379
+ }
253
380
  const configured = typeof props.scrollBody === "object" ? props.scrollBody.maxHeight : void 0;
254
381
  return { maxHeight: configured ?? "calc(100vh - 22rem)" };
255
382
  });
@@ -320,13 +447,16 @@ function defaultCell(col, row) {
320
447
  variant="outline"
321
448
  size="sm"
322
449
  class="h-7 shrink-0 gap-1.5 text-sm"
323
- :class="railOpen ? 'border-primary/30 text-primary' : ''"
450
+ :class="[
451
+ railOpen ? 'border-primary/30 text-primary' : '',
452
+ railLabel ? '' : 'w-7 px-0 justify-center'
453
+ ]"
324
454
  :aria-pressed="railOpen"
325
- :aria-label="railOpen ? t('shared.datatable.toolbar.rail.aria.hide', { label: railLabel }) : t('shared.datatable.toolbar.rail.aria.show', { label: railLabel })"
455
+ :aria-label="railLabel ? railOpen ? t('shared.datatable.toolbar.rail.aria.hide', { label: railLabel }) : t('shared.datatable.toolbar.rail.aria.show', { label: railLabel }) : railOpen ? t('shared.datatable.toolbar.rail.aria.hideUnnamed') : t('shared.datatable.toolbar.rail.aria.showUnnamed')"
326
456
  @click="railOpen = !railOpen"
327
457
  >
328
458
  <SidebarMinimalistic :size="14" />
329
- {{ railLabel }}
459
+ <template v-if="railLabel">{{ railLabel }}</template>
330
460
  </Button>
331
461
  <!-- Shrinks (never wraps the row) once the toolbar carries scope /
332
462
  view-mode / saved-view controls next to it.
@@ -338,6 +468,7 @@ function defaultCell(col, row) {
338
468
  v-if="search"
339
469
  v-model="query"
340
470
  :placeholder="placeholder"
471
+ :label="searchLabel"
341
472
  class="w-[21rem] min-w-40 max-w-full shrink"
342
473
  >
343
474
  <template #icon>
@@ -374,8 +505,20 @@ function defaultCell(col, row) {
374
505
  <Filter :size="14" weight="BoldDuotone" />
375
506
  </template>
376
507
  </FilterPanel>
508
+ <!--
509
+ The one filter control, for a surface that owns its own query.
510
+
511
+ Drawn in the same slot as the other two so a surface never shows two
512
+ filter triggers: `facets` and `filter` are alternatives, and
513
+ `filterTabs` is the in-memory path this replaces.
514
+ -->
515
+ <FacetPanel
516
+ v-if="panelFacets.length"
517
+ v-model="facetSelection"
518
+ :facets="facets"
519
+ />
377
520
  <FilterMenu
378
- v-if="filter"
521
+ v-if="filter && !panelFacets.length"
379
522
  :width="typeof filter === 'object' ? filter.width : void 0"
380
523
  :active="typeof filter === 'object' ? filter.active : void 0"
381
524
  :count="typeof filter === 'object' ? filter.count : void 0"
@@ -412,23 +555,51 @@ function defaultCell(col, row) {
412
555
  <EyeClosed :size="16" weight="Linear" />
413
556
  </template>
414
557
  </ColumnPicker>
558
+ <!--
559
+ Saved views, beside the columns picker rather than out past the market
560
+ scope and the view-mode switch.
561
+
562
+ It belongs with what it saves. Filters, sort and columns are the three
563
+ things a view is made of, so the control that names one sits at the end
564
+ of that group — not after the market scope, which is not part of a
565
+ view, or the view-mode switch, which is about what you are looking at.
566
+ -->
567
+ <SavedViewsMenu v-if="savedViews" :config="savedViews" />
415
568
  <!-- Market scope — the surface passes the shared MarketScopeToggle in;
416
569
  the row position is fixed here. -->
417
570
  <slot name="scope" />
418
571
  <!-- The editor's device switch, which is the right shape wherever the
419
572
  choice changes what you are LOOKING AT rather than which rows. -->
420
573
  <SegmentedToggle v-if="viewModes?.length" v-model="viewMode" variant="inset">
574
+ <!--
575
+ Violet on hover, and violet while it is the one in effect — the
576
+ same language the filter trigger, the column picker and the saved
577
+ views speak two controls to the left. The track's own variant only
578
+ lifts the active segment onto a white pill, which says WHICH is
579
+ picked but not that the row is a set of live controls.
580
+
581
+ The hover is written against `data-[state=off]` and marked
582
+ important because the design system's own toggle already ships
583
+ `data-[state=off]:hover:text-foreground`. A plain `hover:` loses to
584
+ it on specificity, and matching the specificity alone would leave
585
+ the winner to whatever order Tailwind happened to emit the two
586
+ utilities in — a coin toss that reads as a bug the day it lands the
587
+ other way. Delete the `!` if the toggle's own hover ever goes violet.
588
+
589
+ `aria-label` because these are icon-only: the mode still has to
590
+ announce itself.
591
+ -->
421
592
  <SegmentedToggleItem
422
593
  v-for="m in viewModes"
423
594
  :key="m.value"
424
595
  :value="m.value"
425
596
  :aria-label="m.label"
597
+ class="transition-colors data-[state=on]:text-primary data-[state=off]:hover:text-primary!"
426
598
  >
427
599
  <component :is="m.icon" v-if="m.icon" :size="14" weight="Bold" />
428
600
  <template v-if="!m.icon">{{ m.label }}</template>
429
601
  </SegmentedToggleItem>
430
602
  </SegmentedToggle>
431
- <SavedViewsMenu v-if="savedViews" :config="savedViews" />
432
603
  <slot name="toolbar-left" />
433
604
  </div>
434
605
  <div class="flex items-center gap-2 ml-auto shrink-0">
@@ -437,29 +608,88 @@ function defaultCell(col, row) {
437
608
  </div>
438
609
  </div>
439
610
 
611
+ <!--
612
+ What is filtering right now. Not optional beside a paned panel: with it
613
+ closed, a count on an icon says that something narrows the list and never
614
+ what.
615
+ -->
616
+ <FacetChips
617
+ v-if="facets.length"
618
+ v-model="facetSelection"
619
+ :facets="facets"
620
+ :show-facet="chipFacetLabels"
621
+ />
622
+
440
623
  <!-- Alternate view mode (gallery, board, …) — same toolbar, same footer. -->
441
624
  <div v-if="!showTable" class="border-t border-black/[0.04] bg-white/55">
442
625
  <slot name="view" :rows="displayedRows" :loading="loading" />
443
626
  </div>
444
627
 
445
- <!-- The header and the rows share one box so their columns cannot drift.
446
- `display: contents` when scrolling is off makes this element vanish
447
- from layout entirely, so every table that has not asked for it renders
448
- exactly as it did. -->
628
+ <!-- Two boxes: one that scrolls, and the table itself.
629
+
630
+ The table is ONE GRID and the rows are subgrids of it (`SUBGRID_ROW`),
631
+ which is what keeps a heading over its own column. It used to be a grid
632
+ per row, and a content-sized track then measured itself separately in
633
+ each one — see `SUBGRID_ROW` for what that did to every list with row
634
+ actions.
635
+
636
+ The scrolling is the outer box's job, and it has to be, because the
637
+ grid carries `min-w-min`: a box that grows to its own content cannot
638
+ also be the thing that scrolls it. The panel around them clips
639
+ (`overflow-hidden`, for the rounded corners), so without this a table
640
+ whose declared widths outgrow the panel simply lost the excess.
641
+
642
+ The inner element used to be `display: contents` when scrolling was
643
+ off, which made it vanish from layout — and a box that is not in the
644
+ layout can be neither a grid nor a scroller.
645
+
646
+ IT IS ALSO THE TABLE. The box that holds the header and the rows and
647
+ nothing else is the only element in this component that can carry the
648
+ role: the root has the toolbar and the footer in it, and a `table` may
649
+ own rows and rowgroups only. Which is why everything below is a `row`
650
+ or is hidden — an empty state or a skeleton left as a bare `div` here
651
+ would be a child the role does not allow, and the whole table's
652
+ semantics are then whatever the browser decides to salvage.
653
+
654
+ `table` and not `grid`. `grid` is the interactive one: it promises
655
+ arrow keys move a focus between cells, and nothing here implements
656
+ that. A static table read with a screen reader's own table commands is
657
+ what these lists actually are, and both roles announce the column
658
+ heading with the cell, which is the thing that was missing. -->
659
+ <div :class="scrollBody ? 'relative flex min-h-0 flex-col' : ''">
449
660
  <div
450
- :class="scrollBody ? 'min-h-0 overflow-y-auto' : 'contents'"
661
+ ref="scrollEl"
662
+ :class="scrollBody ? 'rx-rows min-h-0 overflow-auto' : 'overflow-x-auto'"
451
663
  :style="scrollStyle"
664
+ @scroll="onRowsScroll"
452
665
  >
666
+ <div
667
+ :role="showTable ? 'table' : void 0"
668
+ :aria-busy="showTable && loading ? 'true' : void 0"
669
+ :class="showTable ? 'grid min-w-min gap-x-2' : ''"
670
+ :style="showTable ? gridTemplate : void 0"
671
+ >
453
672
  <!-- Header row -------------------------------------------------- -->
454
673
  <div
455
674
  v-if="showTable"
456
- 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"
675
+ ref="headerRowEl"
676
+ role="row"
677
+ class="grid items-center gap-x-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"
457
678
  :class="scrollBody ? 'sticky top-0 z-10' : ''"
458
- :style="gridTemplate"
679
+ :style="SUBGRID_ROW"
459
680
  >
681
+ <!-- `columnheader` is what names every cell underneath it, so this is
682
+ the half of the fix that does the work. `aria-sort` goes with it:
683
+ a heading that carries a sort button and says nothing about the
684
+ current direction is a control whose state is visible only to
685
+ people who can see the arrow. -->
460
686
  <div
461
687
  v-for="col in visibleColumns"
462
688
  :key="col.key"
689
+ role="columnheader"
690
+ :aria-sort="ariaSortFor(col)"
691
+ :title="col.label || void 0"
692
+ class="min-w-0"
463
693
  :class="col.alignRight ? 'text-right' : ''"
464
694
  >
465
695
  <!-- `#header-<key>` lets a page take over a header cell (e.g. a
@@ -480,30 +710,40 @@ function defaultCell(col, row) {
480
710
  the row's uppercase. Without it a column reads "LAST CHECK"
481
711
  beside "Channel" and "Provider" for no reason a reader can see —
482
712
  the header shouts wherever a column happens not to be sortable. -->
483
- <span v-else class="normal-case">{{ col.label }}</span>
713
+ <span v-else class="block truncate normal-case">{{ col.label }}</span>
484
714
  </slot>
485
715
  </div>
486
716
  </div>
487
717
 
488
718
  <!-- Body --------------------------------------------------------- -->
489
719
  <template v-for="entry in showTable ? renderRows : []" :key="entry.key">
490
- <!-- Group header (groupBy mode) -->
720
+ <!-- Group header (groupBy mode) — a row of one cell that spans the lot.
721
+ The flex moves one element in, onto the cell: a `row` may hold
722
+ cells and the label and the count are neither. -->
491
723
  <div
492
724
  v-if="entry.kind === 'header'"
493
- 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"
725
+ role="row"
726
+ class="col-[1/-1] 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"
494
727
  >
495
- {{ entry.label }}
496
- <span class="tabular-nums normal-case text-muted-foreground/70">{{ entry.count }}</span>
728
+ <!-- Spans every column like the rows do, so a table wide enough to
729
+ scroll does not show this band stopping short of the rows it
730
+ groups. No subgrid: it holds one cell, not a set of columns. -->
731
+ <div role="cell" :aria-colspan="visibleColumns.length" class="flex items-center gap-2">
732
+ {{ entry.label }}
733
+ <span class="tabular-nums normal-case text-muted-foreground/70">{{ entry.count }}</span>
734
+ </div>
497
735
  </div>
498
736
  <div
499
737
  v-else
500
- class="group grid items-center gap-2 border-t border-black/[0.04] px-4 py-3 cursor-pointer bg-gray-50 transition-colors"
501
- :style="gridTemplate"
738
+ role="row"
739
+ class="group grid items-center gap-x-2 border-t border-black/[0.04] px-4 py-3 cursor-pointer bg-gray-50 transition-colors"
740
+ :style="SUBGRID_ROW"
502
741
  @click="emit('rowClick', entry.row)"
503
742
  >
504
743
  <div
505
744
  v-for="col in visibleColumns"
506
745
  :key="col.key"
746
+ role="cell"
507
747
  class="min-w-0"
508
748
  :class="col.alignRight ? 'text-right' : ''"
509
749
  >
@@ -527,13 +767,18 @@ function defaultCell(col, row) {
527
767
  </div>
528
768
  </template>
529
769
 
530
- <!-- Loading skeleton (only when there are no rows yet) ----------- -->
770
+ <!-- Loading skeleton (only when there are no rows yet) -----------
771
+ Hidden rather than given roles. Six rows of pulsing grey carry no
772
+ information to read out, and announcing them as six empty rows would
773
+ be worse than silence; the table says it is `aria-busy` instead,
774
+ which is the same news in one word. -->
531
775
  <template v-if="showTable && loading && !displayedRows.length">
532
776
  <div
533
777
  v-for="n in 6"
534
778
  :key="`sk-${n}`"
535
- class="grid items-center gap-2 border-t border-black/[0.04] bg-gray-50 px-4 py-3"
536
- :style="gridTemplate"
779
+ aria-hidden="true"
780
+ class="grid items-center gap-x-2 border-t border-black/[0.04] bg-gray-50 px-4 py-3"
781
+ :style="SUBGRID_ROW"
537
782
  >
538
783
  <div
539
784
  v-for="col in visibleColumns"
@@ -551,18 +796,43 @@ function defaultCell(col, row) {
551
796
  <!-- Empty -------------------------------------------------------- -->
552
797
  <div
553
798
  v-if="showTable && !displayedRows.length && !loading"
554
- class="px-3 py-8 text-center text-sm text-muted-foreground"
799
+ role="row"
800
+ class="col-[1/-1] px-3 py-8 text-center text-sm text-muted-foreground"
555
801
  >
556
- <slot name="empty">
557
- {{ emptyText ?? t("shared.datatable.empty.default") }}
558
- </slot>
802
+ <div role="cell" :aria-colspan="visibleColumns.length">
803
+ <slot name="empty">
804
+ {{ emptyText ?? t("shared.datatable.empty.default") }}
805
+ </slot>
806
+ </div>
559
807
  </div>
560
808
 
809
+ </div>
810
+ </div>
811
+
812
+ <!-- The rows' scrollbar ---------------------------------------- -->
813
+ <!-- Drawn, not borrowed. A browser's own bar belongs to the scrolling
814
+ box, and the column head is inside that box — it has to be, because
815
+ the head and the rows are one grid and a `max-content` track resolves
816
+ per grid (PE-610). So the native bar runs beside the head, which is
817
+ not where a bar over the ROWS belongs. This one starts under the
818
+ head, ends at the footer, and drives the same native scrolling. -->
819
+ <div
820
+ v-if="scrollBody && rowsOverflow"
821
+ class="pointer-events-none absolute right-0 z-20 w-3.5"
822
+ :style="{ top: `${headerHeight}px`, bottom: '0px' }"
823
+ >
824
+ <div
825
+ class="rx-thumb pointer-events-auto absolute right-[3px] w-2 cursor-default rounded-full bg-[rgb(11_5_34_/_0.18)] transition-colors hover:bg-[rgb(11_5_34_/_0.32)]"
826
+ :style="{ height: `${thumbHeight}px`, transform: `translateY(${thumbOffset}px)` }"
827
+ @pointerdown="startThumbDrag"
828
+ />
829
+ </div>
561
830
  </div>
562
831
 
563
832
  <!-- Footer ------------------------------------------------------- -->
564
833
  <div
565
834
  v-if="showFooter"
835
+ ref="footerEl"
566
836
  class="flex items-center justify-between gap-2 border-t border-gray-200 bg-white/55 backdrop-blur-md px-4 py-3 text-sm text-muted-foreground/60"
567
837
  >
568
838
  <div class="flex items-center gap-2.5">
@@ -607,3 +877,7 @@ function defaultCell(col, row) {
607
877
  </div>
608
878
  </div>
609
879
  </template>
880
+
881
+ <style scoped>
882
+ .rx-rows::-webkit-scrollbar{height:10px;width:0}.rx-rows::-webkit-scrollbar-track{background:transparent}.rx-rows::-webkit-scrollbar-thumb{background:rgba(11,5,34,.18);background-clip:padding-box;border:3px solid transparent;border-radius:9999px}.rx-thumb{min-height:32px}
883
+ </style>
@@ -1,3 +1,4 @@
1
+ import type { Facet, FacetSelection } from '../../lib/facets.js';
1
2
  import type { DataTableColumn, DataTableFilter, DataTableRail, DataTableSavedViews, DataTableState, DataTableViewMode, RowKey, SortState } from './types.js';
2
3
  declare const _default: <T>(__VLS_props: NonNullable<Awaited<typeof __VLS_setup>>["props"], __VLS_ctx?: __VLS_PrettifyLocal<Pick<NonNullable<Awaited<typeof __VLS_setup>>, "attrs" | "emit" | "slots">>, __VLS_expose?: NonNullable<Awaited<typeof __VLS_setup>>["expose"], __VLS_setup?: Promise<{
3
4
  props: __VLS_PrettifyLocal<Pick<Partial<{}> & Omit<{
@@ -5,14 +6,18 @@ declare const _default: <T>(__VLS_props: NonNullable<Awaited<typeof __VLS_setup>
5
6
  readonly "onUpdate:page"?: ((value: number) => any) | undefined;
6
7
  readonly "onUpdate:viewMode"?: ((value: string) => any) | undefined;
7
8
  readonly "onUpdate:railOpen"?: ((value: boolean) => any) | undefined;
9
+ readonly "onUpdate:facetSelection"?: ((value: FacetSelection) => any) | undefined;
8
10
  readonly onRowClick?: ((row: T) => any) | undefined;
9
11
  readonly onStateChange?: ((state: DataTableState) => any) | undefined;
10
- } & import("vue").VNodeProps & import("vue").AllowedComponentProps & import("vue").ComponentCustomProps, never>, "onUpdate:sort" | "onUpdate:page" | "onUpdate:viewMode" | "onUpdate:railOpen" | "onRowClick" | "onStateChange"> & ({
12
+ } & import("vue").VNodeProps & import("vue").AllowedComponentProps & import("vue").ComponentCustomProps, never>, "onUpdate:sort" | "onUpdate:page" | "onUpdate:viewMode" | "onUpdate:railOpen" | "onUpdate:facetSelection" | "onRowClick" | "onStateChange"> & ({
11
13
  data: T[];
12
14
  columns: DataTableColumn<T>[];
13
15
  rowKey: RowKey<T>;
16
+ /** `label` is the field's accessible name; it defaults to the placeholder
17
+ * where the surface wrote one (see `searchLabel`). */
14
18
  search?: boolean | {
15
19
  placeholder?: string;
20
+ label?: string;
16
21
  };
17
22
  columnsMenu?: boolean;
18
23
  /** Mount the surface's own filter panel in the toolbar's Filter popover
@@ -25,6 +30,25 @@ declare const _default: <T>(__VLS_props: NonNullable<Awaited<typeof __VLS_setup>
25
30
  /** Built-in status/dimension filter pills (static mode). One dimension, or
26
31
  * several (each renders as its own sub-menu). */
27
32
  filterTabs?: DataTableFilter<T> | DataTableFilter<T>[];
33
+ /**
34
+ * The surface's declared dimensions, as one facet model (PE-6xx).
35
+ *
36
+ * The THIRD filter path, and the one the others are being folded into. It
37
+ * differs from `filterTabs` in who does the work: those are filtered in
38
+ * memory here, which a server-paged list cannot use — a facet count taken
39
+ * over one page of fifty rows out of twenty-three thousand is a number
40
+ * nobody can reconcile with the table under it. Here the surface owns the
41
+ * query and the counts, and this component only draws the control and hands
42
+ * back a committed selection (`v-model:facet-selection`).
43
+ *
44
+ * EVERY declared dimension lives in this one panel, committed together, and
45
+ * reads back in one chip strip under the toolbar. There is deliberately no
46
+ * second placement: a tree pinned in a left column beside a panel is two
47
+ * places to look and two commit rules for one question.
48
+ */
49
+ facets?: Facet[];
50
+ /** Say which facet each chip belongs to (see `FacetChips.showFacet`). */
51
+ chipFacetLabels?: boolean;
28
52
  /** Saved views control — the surface supplies snapshot/apply, the table
29
53
  * owns the control, the storage and the dialogs. */
30
54
  savedViews?: DataTableSavedViews | null;
@@ -49,8 +73,13 @@ declare const _default: <T>(__VLS_props: NonNullable<Awaited<typeof __VLS_setup>
49
73
  * sitting above it. That is not a detail: a scrollbar takes width from the
50
74
  * scrolling box, so a header outside it stays wider than the rows and every
51
75
  * column drifts by however wide the scrollbar is.
76
+ *
77
+ * `'fit'` measures instead of guessing: the rows get whatever height is left
78
+ * in the surrounding scroll container, so the table ends where the page ends
79
+ * and nothing below it is pushed out of sight. A short list still ends after
80
+ * its last row — the measurement is a MAXIMUM, not a height.
52
81
  */
53
- scrollBody?: boolean | {
82
+ scrollBody?: boolean | "fit" | {
54
83
  maxHeight?: string;
55
84
  };
56
85
  pagination?: boolean | {
@@ -71,6 +100,13 @@ declare const _default: <T>(__VLS_props: NonNullable<Awaited<typeof __VLS_setup>
71
100
  page?: number;
72
101
  viewMode?: string;
73
102
  railOpen?: boolean;
103
+ /**
104
+ * The committed facet selection. Two-way so a chip removed under the toolbar,
105
+ * a facet picked in the surface's pinned rail and the panel's own Apply all
106
+ * write to one place — and so the surface can seed it from a saved view or a
107
+ * URL without this component holding a second copy to drift from.
108
+ */
109
+ facetSelection?: FacetSelection;
74
110
  }) & Partial<{}>> & import("vue").PublicProps;
75
111
  expose(exposed: import("vue").ShallowUnwrapRef<{}>): void;
76
112
  attrs: any;
@@ -98,7 +134,7 @@ declare const _default: <T>(__VLS_props: NonNullable<Awaited<typeof __VLS_setup>
98
134
  } & {
99
135
  empty?: (props: {}) => any;
100
136
  };
101
- emit: (((evt: "rowClick", row: T) => void) & ((evt: "stateChange", state: DataTableState) => void)) & (((evt: "update:sort", value: SortState) => void) & ((evt: "update:page", value: number) => void) & ((evt: "update:viewMode", value: string) => void) & ((evt: "update:railOpen", value: boolean) => void));
137
+ emit: (((evt: "rowClick", row: T) => void) & ((evt: "stateChange", state: DataTableState) => void)) & (((evt: "update:sort", value: SortState) => void) & ((evt: "update:page", value: number) => void) & ((evt: "update:viewMode", value: string) => void) & ((evt: "update:railOpen", value: boolean) => void) & ((evt: "update:facetSelection", value: FacetSelection) => void));
102
138
  }>) => import("vue").VNode & {
103
139
  __ctx?: Awaited<typeof __VLS_setup>;
104
140
  };
@@ -0,0 +1,16 @@
1
+ import type { Facet, FacetSelection } from '../../lib/facets.js';
2
+ type __VLS_Props = {
3
+ facets: Facet[];
4
+ modelValue: FacetSelection;
5
+ /**
6
+ * Say which facet each chip belongs to. On where "Draft" could be a status or
7
+ * a lifecycle; off where the values speak for themselves.
8
+ */
9
+ showFacet?: boolean;
10
+ };
11
+ declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
12
+ "update:modelValue": (value: FacetSelection) => any;
13
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
14
+ "onUpdate:modelValue"?: ((value: FacetSelection) => any) | undefined;
15
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
16
+ export default _default;