@antelopejs/dms 0.7.6 → 0.7.8

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 (75) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/test/unit/interfaces/dms-base/column-display.test.js +15 -0
  3. package/dist/test/unit/interfaces/dms-base/column-display.test.js.map +1 -1
  4. package/dist/test/unit/interfaces/dms-base/page-live.test.d.ts +1 -0
  5. package/dist/test/unit/interfaces/dms-base/page-live.test.js +66 -0
  6. package/dist/test/unit/interfaces/dms-base/page-live.test.js.map +1 -0
  7. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +16 -0
  8. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
  9. package/frontend-vue/layers/dms-core/app/composables/period/usePeriod.ts +23 -3
  10. package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +2 -1
  11. package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +2 -1
  12. package/frontend-vue/layers/dms-layout/app/build/components/layout/RowSidePanel.vue +134 -0
  13. package/frontend-vue/layers/dms-layout/app/build/composables/layout/rowSidePanel.ts +129 -0
  14. package/frontend-vue/layers/dms-layout/app/pages/[...slug].vue +6 -0
  15. package/frontend-vue/layers/dms-layout/app/types/page.ts +3 -0
  16. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +5 -1
  17. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +5 -1
  18. package/frontend-vue/layers/dms-ui/app/build/components/form/FormSectionNav.vue +30 -15
  19. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +44 -8
  20. package/frontend-vue/layers/dms-ui/app/build/components/table-view/LiveHoldBanner.vue +35 -0
  21. package/frontend-vue/layers/dms-ui/app/build/composables/actions/rowNavigation.ts +17 -0
  22. package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +107 -12
  23. package/frontend-vue/layers/dms-ui/app/build/composables/blocks/useRefreshTriggers.ts +26 -11
  24. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/cellHelpers.ts +24 -0
  25. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/metricCells.ts +58 -16
  26. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +30 -3
  27. package/frontend-vue/layers/dms-ui/app/build/composables/live/currentPageLive.ts +107 -0
  28. package/frontend-vue/layers/dms-ui/app/build/composables/live/formatElapsed.ts +25 -0
  29. package/frontend-vue/layers/dms-ui/app/build/composables/live/pageLiveController.ts +229 -0
  30. package/frontend-vue/layers/dms-ui/app/build/composables/live/shortcuts.ts +8 -0
  31. package/frontend-vue/layers/dms-ui/app/build/composables/live/useLiveToggleShortcut.ts +25 -0
  32. package/frontend-vue/layers/dms-ui/app/build/composables/live/usePageLiveHost.ts +46 -0
  33. package/frontend-vue/layers/dms-ui/app/build/composables/period/periodQuery.ts +330 -0
  34. package/frontend-vue/layers/dms-ui/app/build/composables/period/usePeriodLocation.ts +189 -0
  35. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableListData.ts +15 -3
  36. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableLiveHold.ts +77 -0
  37. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +53 -18
  38. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +1 -0
  39. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +61 -2
  40. package/frontend-vue/layers/dms-ui/app/build/utils/rowClickTrigger.ts +73 -0
  41. package/frontend-vue/layers/dms-ui/app/components/blocks/LiveToggleBlock.vue +92 -0
  42. package/frontend-vue/layers/dms-ui/app/components/field-row/FieldRow.vue +10 -4
  43. package/frontend-vue/layers/dms-ui/app/components/period/PeriodSelector.vue +19 -0
  44. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +90 -12
  45. package/frontend-vue/layers/dms-ui/app/composables/form/types/field.ts +1 -1
  46. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +52 -0
  47. package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/index.ts +8 -0
  48. package/frontend-vue/layers/dms-ui/app/composables/table-view/shortcuts/tableViewRowPanel.ts +30 -0
  49. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +15 -0
  50. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +4 -0
  51. package/frontend-vue/layers/dms-ui/app/config/shortcuts-registry.ts +5 -0
  52. package/frontend-vue/layers/dms-ui/app/types/live.ts +8 -0
  53. package/frontend-vue/layers/dms-ui/app/types/row-action.ts +5 -2
  54. package/frontend-vue/layers/dms-ui/app/utils/blockRefresh.ts +44 -6
  55. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +28 -0
  56. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +28 -0
  57. package/frontend-vue/tests/chart-live-pause.test.ts +74 -0
  58. package/frontend-vue/tests/field-row.test.ts +14 -0
  59. package/frontend-vue/tests/form-section-nav-groups.test.ts +75 -14
  60. package/frontend-vue/tests/live-hold-banner.test.ts +37 -0
  61. package/frontend-vue/tests/live-toggle.test.ts +162 -0
  62. package/frontend-vue/tests/page-live-gate.test.ts +241 -0
  63. package/frontend-vue/tests/period-live-pause.test.ts +120 -0
  64. package/frontend-vue/tests/period-selector-url.test.ts +268 -0
  65. package/frontend-vue/tests/period-url-query.test.ts +351 -0
  66. package/frontend-vue/tests/row-side-panel.test.ts +653 -0
  67. package/frontend-vue/tests/table-cell-tones.test.ts +176 -0
  68. package/frontend-vue/tests/table-live-hold.test.ts +204 -0
  69. package/frontend-vue/tests/table-source-period-scope.test.ts +144 -0
  70. package/frontend-vue/tests/table-view-refresh.test.ts +2 -2
  71. package/frontend-vue/tests/table-view-sources.test.ts +40 -0
  72. package/frontend-vue/tests/vue-inventory.test.ts +1 -1
  73. package/package.json +2 -2
  74. package/skills/dms/SKILL.md +11 -3
  75. package/skills/dms-pages/REFERENCE.md +5 -0
@@ -9,6 +9,10 @@ import {
9
9
  type ReplaceUrlVariablesContext,
10
10
  resolveUrlVariables,
11
11
  } from "../../utils/urlVariables";
12
+ import {
13
+ appendPeriodToUrl,
14
+ usePeriodScope,
15
+ } from "#dms-core/app/composables/period/usePeriodScope";
12
16
 
13
17
  type Query = Record<string, unknown>;
14
18
 
@@ -37,7 +41,13 @@ const SEARCH_KEY = "search";
37
41
  const SORT_KEYS = ["sortKey", "sortDirection"];
38
42
  const PAGE_KEYS = ["offset", "limit"];
39
43
  const DESCENDING = "desc";
44
+ /**
45
+ * Sent while a `liveHold` table is paused: the moment its rows were frozen,
46
+ * for a source route to list the rows as they stood then.
47
+ */
48
+ export const LIVE_HELD_AT_QUERY_KEY = "liveHeldAt";
40
49
  const NO_ROUTE_VALUES: ReplaceUrlVariablesContext = { routeQuery: {} };
50
+ const ROW_ID_TOKEN = "{id}";
41
51
 
42
52
  type SourceCapabilities = TableViewSourceConfig["capabilities"];
43
53
 
@@ -54,6 +64,7 @@ const QUERY_KEY_CAPABILITIES: Array<
54
64
  /**
55
65
  * The part of the list query a source route handles, by its capabilities: a
56
66
  * key no capability names (a controller's `showArchived`…) never reaches it.
67
+ * The live hold's bound always does: a route not reading it lists as usual.
57
68
  */
58
69
  export function sourceRouteQuery(
59
70
  query: Query,
@@ -61,6 +72,7 @@ export function sourceRouteQuery(
61
72
  ): Query {
62
73
  return Object.fromEntries(
63
74
  Object.entries(query).filter(([key]) => {
75
+ if (key === LIVE_HELD_AT_QUERY_KEY) return true;
64
76
  const entry = QUERY_KEY_CAPABILITIES.find(([matches]) => matches(key));
65
77
  return !!entry && !!capabilities[entry[1]];
66
78
  }),
@@ -135,28 +147,35 @@ function emptySourcePage<T>(query: Query): TableViewListResponse<T> {
135
147
  * the rest to the browser; a route answering one page is taken as it is.
136
148
  *
137
149
  * A source's `{{params.X}}` and `{{query.X}}` tokens are filled from `route`;
138
- * while one has no value nothing is requested and the list is empty.
139
- * `sourceUrl` is the URL requested, for the caller to list again when it
140
- * changes.
150
+ * while one has no value nothing is requested and the list is empty. A
151
+ * source's `periodScope` adds the period of its selector to the URL, like a
152
+ * chart's; until that selector registers, nothing is requested and the list
153
+ * answers `null`, which the table draws as its skeleton. `sourceUrl` is the
154
+ * URL requested, for the caller to list again when it changes.
141
155
  */
142
156
  export function useTableRows<T extends Record<string, unknown>>(
143
157
  options: TableRowsOptions,
144
158
  ) {
145
159
  const { api, location, source, columns } = options;
146
-
147
- const sourceUrl = computed(() =>
148
- source
149
- ? resolveUrlVariables(
150
- source.fetchUrl,
151
- options.route?.() ?? NO_ROUTE_VALUES,
152
- )
153
- : undefined,
160
+ const periodScope = usePeriodScope(source?.periodScope);
161
+ const isPeriodPending = computed(
162
+ () => !!source?.periodScope && !periodScope.value,
154
163
  );
155
164
 
165
+ const sourceUrl = computed(() => {
166
+ if (!source) return undefined;
167
+ const url = resolveUrlVariables(
168
+ source.fetchUrl,
169
+ options.route?.() ?? NO_ROUTE_VALUES,
170
+ );
171
+ return source.periodScope ? appendPeriodToUrl(url, periodScope.value) : url;
172
+ });
173
+
156
174
  const listFromSource = async (
157
175
  sourceConfig: TableViewSourceConfig,
158
176
  query: Query,
159
- ): Promise<TableViewListResponse<T>> => {
177
+ ): Promise<TableViewListResponse<T> | null> => {
178
+ if (isPeriodPending.value) return null;
160
179
  const url = sourceUrl.value;
161
180
  if (!url) return emptySourcePage(query);
162
181
  const { capabilities } = sourceConfig;
@@ -173,7 +192,7 @@ export function useTableRows<T extends Record<string, unknown>>(
173
192
  return processSourceRows(response.results, query, capabilities, columns);
174
193
  };
175
194
 
176
- const list = (query: Query): Promise<TableViewListResponse<T>> =>
195
+ const list = (query: Query): Promise<TableViewListResponse<T> | null> =>
177
196
  source
178
197
  ? listFromSource(source, query)
179
198
  : api<TableViewListResponse<T>>(`${location}/list`, { query });
@@ -190,21 +209,37 @@ export function useTableRows<T extends Record<string, unknown>>(
190
209
  }
191
210
  const totals = await Promise.all(
192
211
  queries.map(async ({ id, query }) => {
193
- const { total } = await listFromSource(source, {
212
+ const page = await listFromSource(source, {
194
213
  ...query,
195
214
  offset: 0,
196
215
  limit: 1,
197
216
  });
198
- return [id, total] as const;
217
+ return page ? [[id, page.total] as const] : [];
199
218
  }),
200
219
  );
201
- return Object.fromEntries(totals);
220
+ return Object.fromEntries(totals.flat());
221
+ };
222
+
223
+ // A source reads one row through its `fetchRowUrl`, if it declares one. It
224
+ // sends no list query, the period included: one row by id depends on none.
225
+ const getSourceRow = (
226
+ sourceConfig: TableViewSourceConfig,
227
+ id: string,
228
+ ): Promise<T | undefined> => {
229
+ const url = resolveUrlVariables(
230
+ sourceConfig.fetchRowUrl?.replaceAll(
231
+ ROW_ID_TOKEN,
232
+ encodeURIComponent(id),
233
+ ),
234
+ options.route?.() ?? NO_ROUTE_VALUES,
235
+ );
236
+ return url ? api<T>(url) : Promise.resolve(undefined);
202
237
  };
203
238
 
204
- /** One row by id: a source has no route for it. */
239
+ /** One row by id: a source's `fetchRowUrl`, a controller's `get` route. */
205
240
  const getRow = (id: string): Promise<T | undefined> =>
206
241
  source
207
- ? Promise.resolve(undefined)
242
+ ? getSourceRow(source, id)
208
243
  : api<T>(`${location}/get`, { query: { id } });
209
244
 
210
245
  return { isSource: !!source, sourceUrl, list, countBatch, getRow };
@@ -123,6 +123,7 @@ export const useTableViewConfig = <T extends Data>(
123
123
  maxHeight: config.maxHeight,
124
124
  rowIdKey: config.rowIdKey,
125
125
  rowActions: config.rowActions,
126
+ rowClick: config.rowClick,
126
127
  customNavItems: config.customNavItems,
127
128
  componentId: config.componentId,
128
129
  formContainer: config.formContainer,
@@ -1,3 +1,4 @@
1
+ import type { CustomRowAction } from "../../../../types/row-action";
1
2
  import { readTableUrlKey, type TableUrlScope } from "./views";
2
3
 
3
4
  /** Query key of the row a `deepLink` action has open. */
@@ -21,12 +22,70 @@ export function readRecordId(
21
22
  /**
22
23
  * Writes (or, without an id, removes) the open row in the browser's URL in
23
24
  * place: no visit, no history entry, the link and a reload open it again.
25
+ * Returns what it wrote, for {@link clearWrittenRecord}.
24
26
  */
25
- export function writeRecordId(scope: TableUrlScope, id?: string): void {
26
- if (typeof window === "undefined") return;
27
+ export function writeRecordId(
28
+ scope: TableUrlScope,
29
+ id?: string,
30
+ ): WrittenRecord | undefined {
31
+ if (typeof window === "undefined") return undefined;
27
32
  const url = new URL(window.location.href);
28
33
  const key = recordUrlKey(scope);
29
34
  if (id) url.searchParams.set(key, id);
30
35
  else url.searchParams.delete(key);
31
36
  window.history.replaceState(window.history.state, "", url);
37
+ return id ? { pathname: url.pathname, key, id } : undefined;
38
+ }
39
+
40
+ /**
41
+ * Whether an action keeps the row it opens in the URL: a side panel does
42
+ * unless its target or the action sets `deepLink: false`, a drawer or a modal
43
+ * only with the action's `deepLink: true`.
44
+ */
45
+ export function isDeepLinkAction(action: CustomRowAction): boolean {
46
+ if (action.target.type !== "sidePanel") return !!action.deepLink;
47
+ return (action.deepLink ?? action.target.deepLink) !== false;
48
+ }
49
+
50
+ /** The row a table wrote in the URL, and the page it wrote it on. */
51
+ export interface WrittenRecord {
52
+ pathname: string;
53
+ key: string;
54
+ id: string;
55
+ }
56
+
57
+ /**
58
+ * Removes the row a table wrote in the URL, only while the URL still holds
59
+ * it: a container closing as the user leaves the page must not erase the
60
+ * `?record=` of the page reached.
61
+ */
62
+ export function clearWrittenRecord(written: WrittenRecord | undefined): void {
63
+ if (typeof window === "undefined" || !written) return;
64
+ const url = new URL(window.location.href);
65
+ if (url.pathname !== written.pathname) return;
66
+ if (url.searchParams.get(written.key) !== written.id) return;
67
+ url.searchParams.delete(written.key);
68
+ window.history.replaceState(window.history.state, "", url);
69
+ }
70
+
71
+ /** Where a deep link finds its row: among the rows listed, or by id. */
72
+ export interface DeepLinkedRowSource<T> {
73
+ listed: T[] | undefined;
74
+ idOf: (row: T) => string | undefined;
75
+ /** Reads one row: a controller's `get` route, a source's `fetchRowUrl`. */
76
+ getRow: (id: string) => Promise<T | undefined>;
77
+ }
78
+
79
+ /**
80
+ * The row a deep link names: the listed one, else the one read by id — a row
81
+ * beyond the first page. A row the route cannot answer (gone, a 404) opens
82
+ * nothing.
83
+ */
84
+ export async function resolveDeepLinkedRow<T>(
85
+ id: string,
86
+ source: DeepLinkedRowSource<T>,
87
+ ): Promise<T | undefined> {
88
+ const listed = source.listed?.find((row) => source.idOf(row) === id);
89
+ if (listed) return listed;
90
+ return source.getRow(id).catch(() => undefined);
32
91
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * What runs a row's click action (backend `TableViewRowClick`): a double-click
3
+ * by default, or a single click.
4
+ */
5
+ export type TableViewRowClick = "single" | "double";
6
+
7
+ /** A reason a pointer event on a row must not run its click action. */
8
+ type RowClickGuard = (event: MouseEvent) => boolean;
9
+
10
+ interface RowClickTrigger {
11
+ eventType: string;
12
+ guards: RowClickGuard[];
13
+ }
14
+
15
+ const DEFAULT_ROW_CLICK: TableViewRowClick = "double";
16
+
17
+ // The controls a row may hold: a click on one is that control's, not the
18
+ // row's.
19
+ const ROW_CONTROL_SELECTOR = [
20
+ "a",
21
+ "button",
22
+ "input",
23
+ "select",
24
+ "textarea",
25
+ "label",
26
+ "summary",
27
+ "[role='button']",
28
+ "[role='checkbox']",
29
+ "[role='menuitem']",
30
+ "[role='switch']",
31
+ "[contenteditable='true']",
32
+ ].join(", ");
33
+
34
+ // The second click of a double-click: the first one already ran the action.
35
+ const isRepeatClick: RowClickGuard = (event) => event.detail > 1;
36
+
37
+ const isOnRowControl: RowClickGuard = (event) => {
38
+ const target = event.target as Element | null;
39
+ const control = target?.closest?.(ROW_CONTROL_SELECTOR);
40
+ if (!control) return false;
41
+ const row = event.currentTarget as Element | null;
42
+ return !row || row.contains(control);
43
+ };
44
+
45
+ // A click ending a drag over the cells selects their text: the user copies a
46
+ // value, not opens the row.
47
+ const endsTextSelection: RowClickGuard = () => {
48
+ if (typeof window === "undefined") return false;
49
+ const selection = window.getSelection();
50
+ return !!selection && !selection.isCollapsed && selection.toString() !== "";
51
+ };
52
+
53
+ const ROW_CLICK_TRIGGERS: Record<TableViewRowClick, RowClickTrigger> = {
54
+ // Unchanged from before `rowClick` existed: any double-click on the row.
55
+ double: { eventType: "dblclick", guards: [] },
56
+ single: {
57
+ eventType: "click",
58
+ guards: [isRepeatClick, isOnRowControl, endsTextSelection],
59
+ },
60
+ };
61
+
62
+ /**
63
+ * Whether `event`, a click or a double-click on a row, runs the row's click
64
+ * action under the table's `rowClick` mode.
65
+ */
66
+ export function isRowClickTrigger(
67
+ mode: TableViewRowClick | undefined,
68
+ event: MouseEvent,
69
+ ): boolean {
70
+ const trigger = ROW_CLICK_TRIGGERS[mode ?? DEFAULT_ROW_CLICK];
71
+ if (!trigger || event.type !== trigger.eventType) return false;
72
+ return !trigger.guards.some((guard) => guard(event));
73
+ }
@@ -0,0 +1,92 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, onMounted, ref } from "vue";
3
+ import DmsSegmented from "../segmented/Segmented.vue";
4
+ import { usePageLive } from "../../composables/live/usePageLive";
5
+ import { formatElapsed } from "../../build/composables/live/formatElapsed";
6
+ import { useLiveToggleShortcut } from "../../build/composables/live/useLiveToggleShortcut";
7
+ import type { DefaultComponentProps } from "../../../../dms-core/app/types/component";
8
+
9
+ interface LiveToggleBlockProps extends DefaultComponentProps {
10
+ align?: "left" | "center" | "right";
11
+ /** Shows "updated N s ago". */
12
+ showUpdatedAt?: boolean;
13
+ }
14
+
15
+ const props = withDefaults(defineProps<LiveToggleBlockProps>(), {
16
+ align: "right",
17
+ showUpdatedAt: true,
18
+ });
19
+
20
+ const DIRECT = "direct";
21
+ const PAUSE = "pause";
22
+ const TICK_MS = 1000;
23
+ const ALIGN_CLASSES: Record<
24
+ NonNullable<LiveToggleBlockProps["align"]>,
25
+ string
26
+ > = {
27
+ left: "justify-start",
28
+ center: "justify-center",
29
+ right: "justify-end",
30
+ };
31
+
32
+ const { t } = useI18n();
33
+ const live = usePageLive();
34
+
35
+ // The state lives in the browser (its storage, its clock): the server and the
36
+ // hydrating render draw nothing, the mounted block draws the real state.
37
+ const isMounted = ref(false);
38
+ const now = ref(Date.now());
39
+ let ticker: ReturnType<typeof setInterval> | undefined;
40
+
41
+ onMounted(() => {
42
+ isMounted.value = true;
43
+ if (props.showUpdatedAt) {
44
+ ticker = setInterval(() => (now.value = Date.now()), TICK_MS);
45
+ }
46
+ });
47
+ onBeforeUnmount(() => clearInterval(ticker));
48
+
49
+ const mode = computed({
50
+ get: () => (live.isPaused.value ? PAUSE : DIRECT),
51
+ set: (next: string | number) => {
52
+ if (next === PAUSE) live.pause();
53
+ else live.resume();
54
+ },
55
+ });
56
+
57
+ const items = computed(() => [
58
+ { value: DIRECT, label: t("dms.live.direct"), icon: "i-ph-broadcast" },
59
+ { value: PAUSE, label: t("dms.live.pause"), icon: "i-ph-pause" },
60
+ ]);
61
+
62
+ const updatedLabel = computed(() => {
63
+ const at = live.lastUpdatedAt.value;
64
+ if (at === null) return "";
65
+ const { unit, count } = formatElapsed(now.value - at);
66
+ return t(`dms.live.updated_${unit}`, { count }, count);
67
+ });
68
+
69
+ useLiveToggleShortcut(() => live.toggle());
70
+ </script>
71
+
72
+ <template>
73
+ <div
74
+ v-if="isMounted && live.isEnabled.value"
75
+ class="dms-live-toggle flex flex-wrap items-center gap-2.5"
76
+ :class="ALIGN_CLASSES[align]"
77
+ >
78
+ <span
79
+ v-if="showUpdatedAt"
80
+ class="text-muted text-[12.5px] tabular-nums"
81
+ aria-live="off"
82
+ >
83
+ {{ updatedLabel }}
84
+ </span>
85
+ <DmsSegmented
86
+ v-model="mode"
87
+ :items="items"
88
+ :aria-label="t('dms.live.aria_label')"
89
+ size="sm"
90
+ />
91
+ </div>
92
+ </template>
@@ -107,15 +107,24 @@ const theme = tv({
107
107
  disabled: {
108
108
  true: { root: "opacity-55" },
109
109
  },
110
+ // Without a label cell, the control is the grid's only child: it spans
111
+ // both columns instead of falling into the form's 240px label column.
112
+ labeled: {
113
+ false: { control: "col-span-full" },
114
+ },
110
115
  },
111
116
  });
112
117
 
118
+ const hasText = computed(
119
+ () => !!(props.label || props.description || slots.label || slots.details),
120
+ );
113
121
  const ui = computed(() =>
114
122
  theme({
115
123
  layout: props.layout,
116
124
  inset: props.inset,
117
125
  spacing: props.spacing,
118
126
  disabled: props.disabled,
127
+ labeled: hasText.value,
119
128
  }),
120
129
  );
121
130
  const labelTag = computed(() => (props.labelFor ? "label" : "div"));
@@ -127,10 +136,7 @@ const { processI18n } = useTranslation();
127
136
  <template>
128
137
  <div :class="ui.root()">
129
138
  <div :class="ui.body()">
130
- <div
131
- v-if="props.label || props.description || slots.label || slots.details"
132
- :class="ui.text()"
133
- >
139
+ <div v-if="hasText" :class="ui.text()">
134
140
  <component
135
141
  :is="labelTag"
136
142
  v-if="props.label || slots.label"
@@ -12,6 +12,7 @@ import type {
12
12
  } from "#dms-core/app/composables/period/types";
13
13
  import type { DefaultComponentProps } from "../../../../dms-core/app/types/component";
14
14
  import { usePeriodDraft } from "../../build/composables/period/usePeriodDraft";
15
+ import { usePeriodLocation } from "../../build/composables/period/usePeriodLocation";
15
16
  import {
16
17
  type CalendarRange,
17
18
  calendarToRange,
@@ -21,6 +22,7 @@ import {
21
22
  } from "../../build/composables/period/periodDisplay";
22
23
  import PeriodRangeCalendar from "../../build/components/period/PeriodRangeCalendar.vue";
23
24
  import { useUserRegionalPreferences } from "#dms-core/app/composables/user/useUserRegionalPreferences";
25
+ import { usePageLive } from "../../composables/live/usePageLive";
24
26
 
25
27
  interface Props extends DefaultComponentProps {
26
28
  id: string;
@@ -93,11 +95,28 @@ useWatch(props.watchActions || [], props.componentId);
93
95
 
94
96
  const { t, locale } = useI18n();
95
97
 
98
+ // A paused live page keeps its period where it was: a relative preset
99
+ // sliding every minute would refetch every block reading it.
100
+ const { isPaused: isPagePaused } = usePageLive();
96
101
  const period = usePeriod({
97
102
  defaultPreset: props.defaultPreset,
98
103
  defaultComparison: props.defaultComparison,
99
104
  presets: props.presets,
100
105
  comparisons: props.comparisons,
106
+ holdClock: () => isPagePaused.value,
107
+ });
108
+
109
+ // Before the scope registers, so the cards' first request already carries
110
+ // the period of the URL (or of the user's last choice), never the default.
111
+ usePeriodLocation(period, {
112
+ id: props.id,
113
+ pageId: props.pageId,
114
+ fallback: {
115
+ preset: period.preset.value,
116
+ comparison: period.comparison.value,
117
+ customRange: null,
118
+ customCompareRange: null,
119
+ },
101
120
  });
102
121
 
103
122
  const cleanup = registerPeriodScope(props.id, period.state);