@revenexx/studio-shared 0.3.0 → 0.3.1

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 +23 -2
  8. package/dist/runtime/components/DataTable/DataTable.vue +199 -100
  9. package/dist/runtime/components/DataTable/DataTable.vue.d.ts +23 -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
package/dist/module.d.mts CHANGED
@@ -7,6 +7,7 @@ export { PlatformTenant } from '../dist/runtime/composables/usePlatformTenant.js
7
7
  export { PlatformTalkback } from '../dist/runtime/composables/usePlatformTalkback.js';
8
8
  export { TalkbackChannel, TalkbackChannelOptions, TalkbackMaybeRef } from '../dist/runtime/composables/useTalkbackChannels.js';
9
9
  export { MediaLibraryClient } from '../dist/runtime/composables/useMediaLibrary.js';
10
+ export { FloatingPanelDock, FloatingPanelMode, FloatingPanelOptions } from '../dist/runtime/composables/useFloatingPanel.js';
10
11
  export { IsoOption } from '../dist/runtime/lib/isoLists.js';
11
12
  export { Translate, TranslateNamed, Translator } from '../dist/runtime/lib/i18nBridge.js';
12
13
 
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "studio-shared",
3
3
  "configKey": "studioShared",
4
- "version": "0.3.0",
4
+ "version": "0.3.1",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
package/dist/module.mjs CHANGED
@@ -36,6 +36,11 @@ const module$1 = defineNuxtModule({
36
36
  // Collapsible list-rail state (persisted per surface) behind the
37
37
  // DataTable toolbar's rail handle.
38
38
  { name: "useListRail", from: join(runtime, "composables/useListRail"), priority: -1 },
39
+ // A panel that docks to either side of a canvas, floats anywhere on it,
40
+ // or collapses to a pill — and is dragged between those by its header.
41
+ // Lifted out of the integration studio's workflow editor so a second
42
+ // editor can have the same panels without a second implementation.
43
+ { name: "useFloatingPanel", from: join(runtime, "composables/useFloatingPanel"), priority: -1 },
39
44
  // Realtime provider seam (Talkback). Composes usePlatformAuth +
40
45
  // usePlatformTenant, so a host that overrides those gets a correct client
41
46
  // without overriding this one too.
@@ -6,7 +6,9 @@ type __VLS_Props = {
6
6
  };
7
7
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
8
8
  toggle: (key: string) => any;
9
+ reorder: (from: number, to: number) => any;
9
10
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
10
11
  onToggle?: ((key: string) => any) | undefined;
12
+ onReorder?: ((from: number, to: number) => any) | undefined;
11
13
  }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
12
14
  export default _default;
@@ -1,51 +1,107 @@
1
1
  <script setup>
2
- import { Widget as Columns, CheckCircle as Check } from "#solar-icons";
3
- import {
4
- DropdownMenu,
5
- DropdownMenuContent,
6
- DropdownMenuItem,
7
- DropdownMenuLabel,
8
- DropdownMenuSeparator,
9
- DropdownMenuTrigger
10
- } from "@revenexx/studio";
2
+ import { Eye, EyeClosed, ListArrowDownMinimalistic, Widget as Columns } from "#solar-icons";
3
+ import { Popover, PopoverContent, PopoverTrigger } from "@revenexx/studio";
11
4
  const props = defineProps({
12
5
  columns: { type: Array, required: true },
13
6
  visible: { type: Set, required: true }
14
7
  });
15
- const emit = defineEmits(["toggle"]);
16
- const toggleable = computed(() => props.columns.filter((c) => c.toggleable !== false));
8
+ const emit = defineEmits(["toggle", "reorder"]);
9
+ const listed = computed(() => props.columns);
10
+ const visibleCount = computed(() => props.columns.filter((c) => props.visible.has(c.key)).length);
11
+ function canHide(col) {
12
+ if (col.toggleable === false) return false;
13
+ return !props.visible.has(col.key) || visibleCount.value > 1;
14
+ }
15
+ const dragging = ref(null);
16
+ const over = ref(null);
17
+ const announcement = ref("");
18
+ function move(from, to) {
19
+ if (to < 0 || to >= listed.value.length || to === from) return;
20
+ emit("reorder", from, to);
21
+ const column = listed.value[from];
22
+ if (column) announcement.value = `${column.label}, ${to + 1} / ${listed.value.length}`;
23
+ }
24
+ function onDrop(index) {
25
+ if (dragging.value !== null) move(dragging.value, index);
26
+ dragging.value = null;
27
+ over.value = null;
28
+ }
29
+ function onKey(event, index) {
30
+ if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
31
+ event.preventDefault();
32
+ const to = event.key === "ArrowUp" ? index - 1 : index + 1;
33
+ move(index, to);
34
+ const list = event.currentTarget.closest("[data-columns-list]");
35
+ requestAnimationFrame(() => {
36
+ list?.querySelectorAll("[data-column-handle]")[to]?.focus();
37
+ });
38
+ }
17
39
  const t = useT();
18
40
  </script>
19
41
 
20
42
  <template>
21
- <DropdownMenu>
22
- <DropdownMenuTrigger as-child>
43
+ <Popover>
44
+ <PopoverTrigger as-child>
23
45
  <button
24
46
  type="button"
25
47
  class="flex size-7 items-center justify-center rounded-md border border-black/[0.06] bg-white text-muted-foreground transition hover:text-foreground"
26
48
  :title="t('shared.datatable.columnsmenu.tooltip')"
27
49
  >
28
- <Columns :size="15" />
50
+ <Columns :size="15" weight="Bold" />
29
51
  </button>
30
- </DropdownMenuTrigger>
31
- <DropdownMenuContent align="end" class="w-48">
32
- <DropdownMenuLabel class="text-sm text-muted-foreground">
52
+ </PopoverTrigger>
53
+
54
+ <PopoverContent align="end" class="w-60 p-1.5">
55
+ <p class="px-2 py-1.5 text-sm text-muted-foreground">
33
56
  {{ t("shared.datatable.columnsmenu.heading") }}
34
- </DropdownMenuLabel>
35
- <DropdownMenuSeparator />
36
- <DropdownMenuItem
37
- v-for="col in toggleable"
38
- :key="col.key"
39
- class="justify-between gap-2"
40
- @select.prevent="emit('toggle', col.key)"
41
- >
42
- <span class="truncate">{{ col.label }}</span>
43
- <Check
44
- v-if="visible.has(col.key)"
45
- :size="14"
46
- class="text-muted-foreground shrink-0"
47
- />
48
- </DropdownMenuItem>
49
- </DropdownMenuContent>
50
- </DropdownMenu>
57
+ </p>
58
+
59
+ <div data-columns-list class="space-y-0.5">
60
+ <div
61
+ v-for="(col, index) in listed"
62
+ :key="col.key"
63
+ class="flex items-center gap-1 rounded-md pr-1 transition-colors"
64
+ :class="[
65
+ over === index && dragging !== null && dragging !== index ? 'bg-primary/10' : 'hover:bg-muted',
66
+ dragging === index && 'opacity-40'
67
+ ]"
68
+ @dragover.prevent="over = index"
69
+ @drop.prevent="onDrop(index)"
70
+ >
71
+ <button
72
+ type="button"
73
+ data-column-handle
74
+ draggable="true"
75
+ class="flex size-7 shrink-0 cursor-grab items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring active:cursor-grabbing"
76
+ :aria-label="t('shared.datatable.columnsmenu.reorder', { column: col.label })"
77
+ @dragstart="dragging = index"
78
+ @dragend="dragging = null;
79
+ over = null"
80
+ @keydown="onKey($event, index)"
81
+ >
82
+ <ListArrowDownMinimalistic :size="15" weight="Linear" />
83
+ </button>
84
+
85
+ <span
86
+ class="min-w-0 flex-1 truncate py-1.5 text-sm transition-colors"
87
+ :class="visible.has(col.key) ? 'text-foreground' : 'text-muted-foreground'"
88
+ >{{ col.label }}</span>
89
+
90
+ <button
91
+ type="button"
92
+ class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-35"
93
+ :disabled="!canHide(col)"
94
+ :aria-pressed="visible.has(col.key)"
95
+ :aria-label="col.label"
96
+ @click="emit('toggle', col.key)"
97
+ >
98
+ <Eye v-if="visible.has(col.key)" :size="16" weight="Linear" />
99
+ <EyeClosed v-else :size="16" weight="Linear" />
100
+ </button>
101
+ </div>
102
+ </div>
103
+
104
+ <p class="sr-only" role="status" aria-live="polite">{{ announcement }}</p>
105
+ </PopoverContent>
106
+ </Popover>
51
107
  </template>
@@ -6,7 +6,9 @@ type __VLS_Props = {
6
6
  };
7
7
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
8
8
  toggle: (key: string) => any;
9
+ reorder: (from: number, to: number) => any;
9
10
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
10
11
  onToggle?: ((key: string) => any) | undefined;
12
+ onReorder?: ((from: number, to: number) => any) | undefined;
11
13
  }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
12
14
  export default _default;
@@ -20,6 +20,7 @@ declare const _default: <T>(__VLS_props: NonNullable<Awaited<typeof __VLS_setup>
20
20
  filter?: boolean | {
21
21
  width?: string;
22
22
  active?: boolean;
23
+ count?: number;
23
24
  };
24
25
  /** Built-in status/dimension filter pills (static mode). One dimension, or
25
26
  * several (each renders as its own sub-menu). */
@@ -32,6 +33,26 @@ declare const _default: <T>(__VLS_props: NonNullable<Awaited<typeof __VLS_setup>
32
33
  viewModes?: DataTableViewMode[] | null;
33
34
  /** Collapse handle for the surface's left rail (see `ListSurface`). */
34
35
  rail?: boolean | DataTableRail;
36
+ /**
37
+ * Scroll the ROWS inside the table instead of the page.
38
+ *
39
+ * The toolbar, the header and the footer stay put and only the data moves,
40
+ * so paging through a long list keeps the search box, the filters and the
41
+ * "showing 20 of 400" in view — you stay in the table's context instead of
42
+ * scrolling the whole surface away and losing the controls that scope it.
43
+ *
44
+ * OPT-IN. A short list scrolling inside a box shorter than the list is worse
45
+ * than a page that scrolls, and there is no way for this component to know
46
+ * which it is looking at.
47
+ *
48
+ * The header row moves INSIDE the scroll container and sticks, rather than
49
+ * sitting above it. That is not a detail: a scrollbar takes width from the
50
+ * scrolling box, so a header outside it stays wider than the rows and every
51
+ * column drifts by however wide the scrollbar is.
52
+ */
53
+ scrollBody?: boolean | {
54
+ maxHeight?: string;
55
+ };
35
56
  pagination?: boolean | {
36
57
  pageSize?: number;
37
58
  };
@@ -61,12 +82,12 @@ declare const _default: <T>(__VLS_props: NonNullable<Awaited<typeof __VLS_setup>
61
82
  }) => any) | undefined;
62
83
  } & {
63
84
  'filter-menu'?: (props: {}) => any;
85
+ } & {
86
+ scope?: (props: {}) => any;
64
87
  } & {
65
88
  'toolbar-left'?: (props: {}) => any;
66
89
  } & {
67
90
  'toolbar-right'?: (props: {}) => any;
68
- } & {
69
- scope?: (props: {}) => any;
70
91
  } & {
71
92
  actions?: (props: {}) => any;
72
93
  } & {
@@ -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;
@@ -231,6 +248,11 @@ const showSortMenu = computed(() => !showTable.value && sortableColumns.value.le
231
248
  function cellIconFor(col) {
232
249
  return col.cellIcon ?? null;
233
250
  }
251
+ const scrollStyle = computed(() => {
252
+ if (!props.scrollBody) return void 0;
253
+ const configured = typeof props.scrollBody === "object" ? props.scrollBody.maxHeight : void 0;
254
+ return { maxHeight: configured ?? "calc(100vh - 22rem)" };
255
+ });
234
256
  const showFooter = computed(() => props.pagination !== false && !props.groupBy);
235
257
  const lastPage = computed(
236
258
  () => pageSize.value > 0 ? Math.max(0, Math.ceil(totalCount.value / pageSize.value) - 1) : 0
@@ -255,13 +277,42 @@ function defaultCell(col, row) {
255
277
  <template>
256
278
  <div class="rounded-lg border border-black/[0.06] overflow-hidden flex flex-col">
257
279
  <!-- 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.) -->
280
+ <!-- The single control row of a list surface.
281
+
282
+ The bar carries the dashboard's tint rather than flat white, so a
283
+ list's chrome and the home screen's are recognisably one surface. On
284
+ white the controls sat on nothing and the row read as a gap above the
285
+ table; with a ground under them they read as a row.
286
+
287
+ The stops are REVERSED from the dashboard's, and measuring is what
288
+ said so. The dashboard panel runs white → tint → white, which is right
289
+ for a panel whose content sits in the middle. A toolbar's content sits
290
+ at the two ENDS: the search field lands at 12% along the bar, where a
291
+ gradient starting pure white at 0% and reaching its tint at 50% is
292
+ still white. So a white field sat on a white ground with a 10% hairline
293
+ and a white rim doing nothing, while the identical control on the
294
+ dashboard read cleanly against 0.972 lightness.
295
+
296
+ Tinted at the ends, clear in the middle where nothing stands.
297
+
298
+ Not the dashboard's shimmer or its blurred corner blob: those are for a
299
+ panel somebody looks AT, and this is one they look through on the way
300
+ to the rows.
301
+ LEFT is LOOKING: rail · search · filter · sort · columns · scope ·
302
+ view mode · saved views · #toolbar-left
303
+ RIGHT is DOING: #toolbar-right · #actions
304
+ (Pagination always lives in the footer.)
305
+
306
+ The line is not "filters versus the rest". View mode and column
307
+ visibility narrow nothing and still belong left, because reaching for
308
+ them means you are still looking for something; once your hand crosses
309
+ to the right you have stopped looking and started acting. Scope, view
310
+ mode and saved views used to sit on the right beside the primary
311
+ action, which made a row of eight controls with no readable boundary
312
+ in it. -->
262
313
  <div
263
314
  v-if="showToolbar"
264
- class="flex flex-wrap items-center justify-between gap-2 px-4 py-3 bg-white/55 backdrop-blur-md"
315
+ 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
316
  >
266
317
  <div class="flex items-center gap-2 min-w-0">
267
318
  <Button
@@ -278,77 +329,110 @@ function defaultCell(col, row) {
278
329
  {{ railLabel }}
279
330
  </Button>
280
331
  <!-- 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>
332
+ view-mode / saved-view controls next to it.
333
+
334
+ `SearchField` rather than an Input with an icon positioned over it
335
+ and a counted `pl-8`: three decisions that had to agree, and any
336
+ change to the icon size or the field height broke one of them. -->
337
+ <SearchField
338
+ v-if="search"
339
+ v-model="query"
340
+ :placeholder="placeholder"
341
+ class="w-[21rem] min-w-40 max-w-full shrink"
342
+ >
343
+ <template #icon>
344
+ <Search :size="14" weight="Linear" />
345
+ </template>
346
+ </SearchField>
347
+ <!--
348
+ Every facet at once, committed with Apply.
349
+
350
+ It was a menu of submenus: one facet visible at a time, one value
351
+ each, and the answer to "what is filtering right now?" only available
352
+ by opening all of them in turn. The panel lets a facet hold more than
353
+ one value and does not move the list until the reader is done
354
+ deciding.
355
+
356
+ `panes` rather than the automatic choice. Auto reads the numbers - it
357
+ switches past four facets or eight options - and today's surfaces sit
358
+ just under both thresholds, so they would draw as columns while the
359
+ same control on the pattern page draws as panes. A table that gains a
360
+ fifth facet would then change shape on its own. One layout, whatever
361
+ the surface happens to declare this week.
362
+ -->
363
+ <FilterPanel
364
+ v-if="filterFacets.length"
365
+ :model-value="panelSelection"
366
+ :facets="filterFacets"
367
+ layout="panes"
368
+ :label="t('shared.datatable.toolbar.filter.action')"
369
+ :apply-label="t('shared.datatable.toolbar.filter.apply')"
370
+ :reset-label="t('shared.datatable.toolbar.filter.reset')"
371
+ @update:model-value="applyPanelSelection"
372
+ >
373
+ <template #icon>
374
+ <Filter :size="14" weight="BoldDuotone" />
375
+ </template>
376
+ </FilterPanel>
329
377
  <FilterMenu
330
378
  v-if="filter"
331
379
  :width="typeof filter === 'object' ? filter.width : void 0"
332
380
  :active="typeof filter === 'object' ? filter.active : void 0"
381
+ :count="typeof filter === 'object' ? filter.count : void 0"
333
382
  >
334
383
  <slot name="filter-menu" />
335
384
  </FilterMenu>
336
385
  <SortMenu v-if="showSortMenu" v-model="sort" :columns="sortableColumns" />
337
- <ColumnsMenu
386
+ <!--
387
+ The design system's picker, drag and drop included. It was a local
388
+ copy that did its own dragging with the browser's own drag events,
389
+ which cannot say where a row may GO: a row could be pulled out over
390
+ the table behind the popover and dropped there. Only the rows of this
391
+ list register as drop targets now, and the edge the pointer is nearest
392
+ decides the index rather than arithmetic on a row height that a
393
+ wrapped label invalidates.
394
+ -->
395
+ <ColumnPicker
338
396
  v-if="columnsMenu && showTable"
339
- :columns="menuColumns"
340
- :visible="visibleKeys"
341
- @toggle="toggleColumn"
342
- />
397
+ :model-value="pickerColumns"
398
+ :label="t('shared.datatable.columnsmenu.tooltip')"
399
+ :heading="t('shared.datatable.columnsmenu.heading')"
400
+ @update:model-value="onPickerUpdate"
401
+ >
402
+ <template #icon>
403
+ <Columns :size="15" weight="BoldDuotone" />
404
+ </template>
405
+ <template #handle>
406
+ <ListArrowDownMinimalistic :size="15" weight="Linear" />
407
+ </template>
408
+ <template #visible>
409
+ <Eye :size="16" weight="Linear" />
410
+ </template>
411
+ <template #hidden>
412
+ <EyeClosed :size="16" weight="Linear" />
413
+ </template>
414
+ </ColumnPicker>
415
+ <!-- Market scope — the surface passes the shared MarketScopeToggle in;
416
+ the row position is fixed here. -->
417
+ <slot name="scope" />
418
+ <!-- The editor's device switch, which is the right shape wherever the
419
+ choice changes what you are LOOKING AT rather than which rows. -->
420
+ <SegmentedToggle v-if="viewModes?.length" v-model="viewMode" variant="inset">
421
+ <SegmentedToggleItem
422
+ v-for="m in viewModes"
423
+ :key="m.value"
424
+ :value="m.value"
425
+ :aria-label="m.label"
426
+ >
427
+ <component :is="m.icon" v-if="m.icon" :size="14" weight="Bold" />
428
+ <template v-if="!m.icon">{{ m.label }}</template>
429
+ </SegmentedToggleItem>
430
+ </SegmentedToggle>
431
+ <SavedViewsMenu v-if="savedViews" :config="savedViews" />
343
432
  <slot name="toolbar-left" />
344
433
  </div>
345
- <div class="flex items-center gap-1.5 ml-auto shrink-0">
434
+ <div class="flex items-center gap-2 ml-auto shrink-0">
346
435
  <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
436
  <slot name="actions" />
353
437
  </div>
354
438
  </div>
@@ -358,10 +442,19 @@ function defaultCell(col, row) {
358
442
  <slot name="view" :rows="displayedRows" :loading="loading" />
359
443
  </div>
360
444
 
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. -->
449
+ <div
450
+ :class="scrollBody ? 'min-h-0 overflow-y-auto' : 'contents'"
451
+ :style="scrollStyle"
452
+ >
361
453
  <!-- Header row -------------------------------------------------- -->
362
454
  <div
363
455
  v-if="showTable"
364
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"
457
+ :class="scrollBody ? 'sticky top-0 z-10' : ''"
365
458
  :style="gridTemplate"
366
459
  >
367
460
  <div
@@ -383,7 +476,11 @@ function defaultCell(col, row) {
383
476
  >
384
477
  {{ col.label }}
385
478
  </SortHeader>
386
- <span v-else>{{ col.label }}</span>
479
+ <!-- `normal-case` to match SortHeader, whose button does not inherit
480
+ the row's uppercase. Without it a column reads "LAST CHECK"
481
+ beside "Channel" and "Provider" for no reason a reader can see —
482
+ the header shouts wherever a column happens not to be sortable. -->
483
+ <span v-else class="normal-case">{{ col.label }}</span>
387
484
  </slot>
388
485
  </div>
389
486
  </div>
@@ -461,6 +558,8 @@ function defaultCell(col, row) {
461
558
  </slot>
462
559
  </div>
463
560
 
561
+ </div>
562
+
464
563
  <!-- Footer ------------------------------------------------------- -->
465
564
  <div
466
565
  v-if="showFooter"