@antelopejs/dms 0.7.11 → 0.7.12

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 (67) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js +7 -0
  3. package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js.map +1 -1
  4. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +10 -0
  5. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
  6. package/frontend-vue/layers/dms-core/app/build/composables/page/usePageRecordLabel.ts +7 -29
  7. package/frontend-vue/layers/dms-core/app/composables/page/usePageRecordLabel.ts +53 -0
  8. package/frontend-vue/layers/dms-core/app/composables/user/useRegionalFormat.ts +73 -20
  9. package/frontend-vue/layers/dms-core/app/utils/formatter.ts +37 -1
  10. package/frontend-vue/layers/dms-core/app/utils/regional.ts +20 -0
  11. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardHeader.vue +1 -1
  12. package/frontend-vue/layers/dms-layout/app/middleware/module-routing.global.ts +34 -10
  13. package/frontend-vue/layers/dms-ui/app/build/components/table-view/LiveHoldBanner.vue +69 -17
  14. package/frontend-vue/layers/dms-ui/app/build/composables/chart/apexTheme.ts +1 -1
  15. package/frontend-vue/layers/dms-ui/app/build/composables/form/enclosingForm.ts +38 -0
  16. package/frontend-vue/layers/dms-ui/app/build/composables/global/searchShortcuts.ts +7 -16
  17. package/frontend-vue/layers/dms-ui/app/build/composables/live/liveNotes.ts +54 -0
  18. package/frontend-vue/layers/dms-ui/app/build/composables/page/blockAnchors.ts +50 -0
  19. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableLiveHold.ts +78 -35
  20. package/frontend-vue/layers/dms-ui/app/build/utils/numberAdjustment.ts +60 -0
  21. package/frontend-vue/layers/dms-ui/app/components/RecursiveComponent.vue +11 -0
  22. package/frontend-vue/layers/dms-ui/app/components/activity/ActivityItem.vue +17 -9
  23. package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +58 -7
  24. package/frontend-vue/layers/dms-ui/app/components/blocks/CodeBlock.vue +5 -1
  25. package/frontend-vue/layers/dms-ui/app/components/blocks/EmptyStateBlock.vue +2 -1
  26. package/frontend-vue/layers/dms-ui/app/components/blocks/LiveToggleBlock.vue +7 -20
  27. package/frontend-vue/layers/dms-ui/app/components/chart/Chart.vue +3 -0
  28. package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +5 -1
  29. package/frontend-vue/layers/dms-ui/app/components/code-snippet/CodeSnippet.vue +51 -13
  30. package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +38 -1
  31. package/frontend-vue/layers/dms-ui/app/components/form/components/InputNumber.vue +58 -3
  32. package/frontend-vue/layers/dms-ui/app/components/tab/Tab.vue +19 -1
  33. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +9 -1
  34. package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +3 -0
  35. package/frontend-vue/layers/dms-ui/app/composables/chart/useApexChart.types.ts +2 -0
  36. package/frontend-vue/layers/dms-ui/app/composables/form/types/validation.ts +10 -0
  37. package/frontend-vue/layers/dms-ui/app/composables/form/useEnclosingForm.ts +61 -0
  38. package/frontend-vue/layers/dms-ui/app/composables/global/searchShortcuts.ts +24 -0
  39. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +3 -2
  40. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLiveRefresh.ts +103 -0
  41. package/frontend-vue/layers/dms-ui/app/composables/tab/types/composable.ts +1 -0
  42. package/frontend-vue/layers/dms-ui/app/composables/tab/types/item.ts +8 -1
  43. package/frontend-vue/layers/dms-ui/app/composables/tab/useTab.ts +35 -23
  44. package/frontend-vue/layers/dms-ui/app/types/input-number.ts +18 -0
  45. package/frontend-vue/layers/dms-ui/app/types/live.ts +6 -0
  46. package/frontend-vue/layers/dms-ui/app/utils/scrollToBlock.ts +23 -0
  47. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +9 -2
  48. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +9 -2
  49. package/frontend-vue/tests/activity-feed-fold.test.ts +119 -0
  50. package/frontend-vue/tests/chart-legend.test.ts +133 -0
  51. package/frontend-vue/tests/code-block.test.ts +28 -0
  52. package/frontend-vue/tests/field-suffix-count.test.ts +1 -0
  53. package/frontend-vue/tests/form-enclosing-form.test.ts +141 -0
  54. package/frontend-vue/tests/header-actions.test.ts +1 -1
  55. package/frontend-vue/tests/input-number-adjust.test.ts +176 -0
  56. package/frontend-vue/tests/live-hold-banner.test.ts +66 -8
  57. package/frontend-vue/tests/module-routing.test.ts +139 -0
  58. package/frontend-vue/tests/page-live-refresh.test.ts +169 -0
  59. package/frontend-vue/tests/page-record-label.test.ts +53 -0
  60. package/frontend-vue/tests/regional-format.test.ts +69 -2
  61. package/frontend-vue/tests/scroll-to-block.test.ts +142 -0
  62. package/frontend-vue/tests/settings-search-shortcuts.test.ts +17 -0
  63. package/frontend-vue/tests/tab-url-slot.test.ts +150 -0
  64. package/frontend-vue/tests/table-live-hold.test.ts +90 -0
  65. package/package.json +2 -2
  66. package/skills/dms/SKILL.md +28 -4
  67. package/skills/dms-module/SKILL.md +2 -1
@@ -5,11 +5,11 @@ import { usePageLive } from "../../composables/live/usePageLive";
5
5
  import { formatElapsed } from "../../build/composables/live/formatElapsed";
6
6
  import { useLiveToggleShortcut } from "../../build/composables/live/useLiveToggleShortcut";
7
7
  import { HIDDEN_TAB_NOTE } from "../../build/composables/live/pageLiveController";
8
+ import type { ConfirmTranslate } from "../../build/composables/confirm/confirmDialogTexts";
8
9
  import {
9
- resolveConfirmText,
10
- type ConfirmTranslate,
11
- } from "../../build/composables/confirm/confirmDialogTexts";
12
- import type { PageLiveNote, PageLiveNoteTone } from "../../types/live";
10
+ missedRowsText,
11
+ showLiveNote,
12
+ } from "../../build/composables/live/liveNotes";
13
13
  import type { DefaultComponentProps } from "../../../../dms-core/app/types/component";
14
14
 
15
15
  interface LiveToggleBlockProps extends DefaultComponentProps {
@@ -40,11 +40,6 @@ const ALIGN_CLASSES: Record<
40
40
  center: "justify-center",
41
41
  right: "justify-end",
42
42
  };
43
- const TONE_CLASSES: Record<PageLiveNoteTone, string> = {
44
- neutral: "",
45
- warning: "text-warning",
46
- error: "text-error",
47
- };
48
43
  const PILL_BUTTON_CLASS =
49
44
  "focus-visible:outline-primary flex items-center gap-1.5 rounded-[5px] px-2 py-1 text-[12px] font-medium transition-colors focus-visible:outline-2";
50
45
  const PILL_IDLE_CLASS = "text-muted hover:text-default hover:bg-elevated";
@@ -90,15 +85,10 @@ const updatedLabel = computed(() => {
90
85
  : updated;
91
86
  });
92
87
 
93
- const noteText = (note: PageLiveNote) =>
94
- resolveConfirmText(note.text, note.params ?? {}, translate);
95
-
96
88
  const missedText = computed(() => {
97
89
  const count = live.missedCount.value;
98
90
  if (count === 0) return "";
99
- return props.missedLabel
100
- ? resolveConfirmText(props.missedLabel, { count }, translate)
101
- : t("dms.live.new_rows", { count }, count);
91
+ return missedRowsText(count, props.missedLabel, translate);
102
92
  });
103
93
 
104
94
  // "Paused · typing in the Tester · 40 new requests", the detail apart: it
@@ -107,7 +97,7 @@ const pausedText = computed(() => {
107
97
  const reason = live.pauseReason.value;
108
98
  return [
109
99
  t("dms.live.paused"),
110
- reason ? noteText(reason) : "",
100
+ reason ? showLiveNote(reason, translate).text : "",
111
101
  missedText.value,
112
102
  ]
113
103
  .filter((part) => part !== "")
@@ -117,10 +107,7 @@ const pausedText = computed(() => {
117
107
  const detail = computed(() => {
118
108
  const note = live.missedDetail.value;
119
109
  if (!note || live.missedCount.value === 0) return null;
120
- return {
121
- text: noteText(note),
122
- toneClass: TONE_CLASSES[note.tone ?? "neutral"],
123
- };
110
+ return showLiveNote(note, translate);
124
111
  });
125
112
 
126
113
  // A hidden tab resumes by itself when it shows again: no button for it.
@@ -21,6 +21,7 @@ import type {
21
21
  ChartAnnotation,
22
22
  ChartAutoYRange,
23
23
  ChartCurve,
24
+ ChartLegendPosition,
24
25
  ChartResponse,
25
26
  ChartSeries,
26
27
  ChartType,
@@ -48,6 +49,7 @@ interface Props extends DefaultComponentProps {
48
49
  showGrid?: boolean;
49
50
  showTooltip?: boolean;
50
51
  showLegend?: boolean;
52
+ legendPosition?: ChartLegendPosition;
51
53
  smooth?: boolean;
52
54
  curve?: ChartCurve;
53
55
  stacked?: boolean;
@@ -238,6 +240,7 @@ const apex = useApexChart(() => ({
238
240
  showGrid: props.showGrid,
239
241
  showTooltip: props.showTooltip,
240
242
  showLegend: effectiveShowLegend.value,
243
+ legendPosition: props.legendPosition,
241
244
  smooth: props.smooth,
242
245
  curve: props.curve,
243
246
  stacked: props.stacked,
@@ -34,6 +34,8 @@ interface Props extends DefaultComponentProps {
34
34
  valuePrecision?: ValuePrecision;
35
35
  showDelta?: boolean;
36
36
  showLegend?: boolean;
37
+ /** The wrapped chart draws its own legend, naming each series. */
38
+ showSeriesLegend?: boolean;
37
39
  primaryLabel?: string;
38
40
  comparisonLabel?: string;
39
41
  }
@@ -53,6 +55,7 @@ const DEFAULT_COMPARISON_LABEL_KEY = "dms.chart.legend_comparison";
53
55
  const props = withDefaults(defineProps<Props>(), {
54
56
  showDelta: true,
55
57
  showLegend: true,
58
+ showSeriesLegend: false,
56
59
  valueFormat: "compact",
57
60
  currencyCode: "EUR",
58
61
  });
@@ -126,7 +129,7 @@ const nestedContext = reactive<NestedChartContext>({
126
129
  cardSeries: [],
127
130
  cardComparisonSeries: [],
128
131
  isLoading: false,
129
- hideLegend: true,
132
+ hideLegend: !props.showSeriesLegend,
130
133
  valueFormat: props.valueFormat,
131
134
  currencyCode: props.currencyCode,
132
135
  valuePrecision: props.valuePrecision,
@@ -138,6 +141,7 @@ watchEffect(() => {
138
141
  nestedContext.cardSeries = cardSeries.value;
139
142
  nestedContext.cardComparisonSeries = cardComparisonSeries.value;
140
143
  nestedContext.isLoading = isLoading.value;
144
+ nestedContext.hideLegend = !props.showSeriesLegend;
141
145
  nestedContext.valueFormat = props.valueFormat;
142
146
  nestedContext.currencyCode = props.currencyCode;
143
147
  nestedContext.valuePrecision = props.valuePrecision;
@@ -10,7 +10,17 @@ import type { CodeToken, highlightCode } from "../../build/utils/codeHighlight";
10
10
 
11
11
  // Read-only code in the editor's look: a head with the title, the language
12
12
  // and a copy button, then the code highlighted by the editor's languages.
13
- // Until the highlighter loads (and on the server) the code shows as is.
13
+ // Without a title or a language there is no head: the copy button floats in
14
+ // the code's corner. Until the highlighter loads (and on the server) the
15
+ // code shows as is.
16
+
17
+ /**
18
+ * How long lines break: `false` scrolls them sideways, `true` wraps them
19
+ * between words, `"anywhere"` also breaks a word too long for the line (a
20
+ * URL, a token, a minified payload).
21
+ */
22
+ type CodeSnippetWrap = boolean | "anywhere";
23
+
14
24
  interface CodeSnippetProps {
15
25
  /** The code, verbatim. */
16
26
  code: string;
@@ -25,8 +35,13 @@ interface CodeSnippetProps {
25
35
  title?: string;
26
36
  /** Lines shown before the code scrolls; all of them when unset. */
27
37
  maxLines?: number;
38
+ /**
39
+ * A CSS height the code scrolls past (`"20rem"`), when `maxLines` is not
40
+ * set: for a payload whose lines differ in length once wrapped.
41
+ */
42
+ maxHeight?: string;
28
43
  /** Wraps long lines instead of scrolling sideways. */
29
- wrap?: boolean;
44
+ wrap?: CodeSnippetWrap;
30
45
  }
31
46
 
32
47
  const props = withDefaults(defineProps<CodeSnippetProps>(), {
@@ -34,6 +49,7 @@ const props = withDefaults(defineProps<CodeSnippetProps>(), {
34
49
  copy: true,
35
50
  title: undefined,
36
51
  maxLines: undefined,
52
+ maxHeight: undefined,
37
53
  wrap: false,
38
54
  });
39
55
 
@@ -41,24 +57,39 @@ const LINE_HEIGHT_PX = 19;
41
57
  // The code's top and bottom padding (`py-2`), around its lines.
42
58
  const CODE_PADDING_PX = 16;
43
59
 
60
+ const WRAP_CLASSES: Record<"false" | "true" | "anywhere", string> = {
61
+ false: "whitespace-pre",
62
+ true: "break-words whitespace-pre-wrap",
63
+ anywhere: "whitespace-pre-wrap [overflow-wrap:anywhere]",
64
+ };
65
+
44
66
  const highlight = shallowRef<typeof highlightCode | null>(null);
45
67
 
46
68
  const language = computed(() => resolveCodeLanguage(props.language));
69
+ // A language given, even `text`, is named in a head; without a title either,
70
+ // a head would only hold the copy button.
71
+ const isHeadless = computed(() => !props.title && props.language === undefined);
47
72
  const hasHead = computed(
48
- () => !!props.title || props.copy || language.value !== PLAIN_CODE_LANGUAGE,
73
+ () =>
74
+ !isHeadless.value &&
75
+ (!!props.title || props.copy || language.value !== PLAIN_CODE_LANGUAGE),
76
+ );
77
+ const hasFloatingCopy = computed(() => isHeadless.value && props.copy);
78
+ const wrapClass = computed(
79
+ () => WRAP_CLASSES[String(props.wrap) as keyof typeof WRAP_CLASSES],
49
80
  );
50
81
  const tokens = computed<CodeToken[]>(() =>
51
82
  highlight.value
52
83
  ? highlight.value(props.code, language.value)
53
84
  : [{ text: props.code }],
54
85
  );
55
- const sizeStyle = computed(() =>
56
- props.maxLines
57
- ? {
58
- maxHeight: `${props.maxLines * LINE_HEIGHT_PX + CODE_PADDING_PX}px`,
59
- }
60
- : undefined,
61
- );
86
+ const sizeStyle = computed(() => {
87
+ if (props.maxLines)
88
+ return {
89
+ maxHeight: `${props.maxLines * LINE_HEIGHT_PX + CODE_PADDING_PX}px`,
90
+ };
91
+ return props.maxHeight ? { maxHeight: props.maxHeight } : undefined;
92
+ });
62
93
 
63
94
  onMounted(async () => {
64
95
  const module = await import("../../build/utils/codeHighlight");
@@ -68,7 +99,7 @@ onMounted(async () => {
68
99
 
69
100
  <template>
70
101
  <div
71
- class="border-default min-w-0 overflow-hidden rounded-[10px] border bg-(--ui-bg)"
102
+ class="border-default relative min-w-0 overflow-hidden rounded-[10px] border bg-(--ui-bg)"
72
103
  >
73
104
  <div
74
105
  v-if="hasHead"
@@ -85,10 +116,17 @@ onMounted(async () => {
85
116
  </span>
86
117
  <DmsCopyButton v-if="props.copy" :value="props.code" />
87
118
  </div>
119
+ <!-- Kept clear of the code: the code's end padding leaves it room. -->
120
+ <DmsCopyButton
121
+ v-if="hasFloatingCopy"
122
+ :value="props.code"
123
+ class="absolute end-1 top-1"
124
+ data-floating-copy
125
+ />
88
126
  <!-- Focusable: a keyboard scrolls the code when it overflows. -->
89
127
  <pre
90
- class="text-highlighted m-0 overflow-auto px-3.5 py-2 font-mono text-[12.5px] leading-[19px]"
91
- :class="props.wrap ? 'break-words whitespace-pre-wrap' : 'whitespace-pre'"
128
+ class="text-highlighted m-0 overflow-auto py-2 ps-3.5 font-mono text-[12.5px] leading-[19px]"
129
+ :class="[wrapClass, hasFloatingCopy ? 'pe-10' : 'pe-3.5']"
92
130
  :style="sizeStyle"
93
131
  :data-language="language"
94
132
  tabindex="0"
@@ -17,6 +17,10 @@ import {
17
17
  type FormValidator,
18
18
  } from "../../composables/form/types/validation";
19
19
  import { FORM_FIELD_LOADING_KEY } from "../../composables/form/types/field-loading";
20
+ import {
21
+ FORM_HANDLE_KEY,
22
+ linkedFieldId,
23
+ } from "../../build/composables/form/enclosingForm";
20
24
  import { FORM_CONTENT_LANGUAGE_KEY } from "../../composables/form/types/content-language";
21
25
  import { DMS_SECTION_SURFACE_KEY } from "../../build/components/section/context";
22
26
  import DmsSaveBar from "../save-bar/SaveBar.vue";
@@ -30,7 +34,7 @@ import { DMS_CONTAINER_KEY } from "../../build/composables/containers/context";
30
34
  import { useFormDirty } from "../../composables/unsaved-changes/useFormDirty";
31
35
  import { useUnsavedChanges } from "../../composables/unsaved-changes/useUnsavedChanges";
32
36
  import { formSaveMode } from "../../build/composables/form/formFooter";
33
- import { usePageRecordLabel } from "#dms-core/app/build/composables/page/usePageRecordLabel";
37
+ import { usePageRecordLabel } from "#dms-core/app/composables/page/usePageRecordLabel";
34
38
  import { formatRecordLabel } from "../../build/composables/table-view/utils/formTexts";
35
39
  import {
36
40
  FORM_ENTRY_CONTEXT_KEY,
@@ -168,6 +172,13 @@ async function focusField(name: string | undefined): Promise<void> {
168
172
  row?.scrollIntoView({ block: "center" });
169
173
  }
170
174
 
175
+ /** Brings a field into view and focuses it: a link's anchor, a field's request. */
176
+ async function revealField(fieldId: string): Promise<void> {
177
+ await formSettled();
178
+ fieldRow(fieldId)?.scrollIntoView({ block: "center" });
179
+ await focusField(fieldId);
180
+ }
181
+
171
182
  /**
172
183
  * A refused submit: its field errors go under the fields, the first focused.
173
184
  *
@@ -516,6 +527,13 @@ function onSubmit(event: Parameters<typeof handleSubmit>[0]) {
516
527
  }
517
528
 
518
529
  provide(FORM_VALIDATOR_KEY, registerValidator);
530
+ provide(FORM_HANDLE_KEY, {
531
+ submit: async () => {
532
+ await form.value?.submit();
533
+ },
534
+ registerValidator,
535
+ revealField,
536
+ });
519
537
  provide(FORM_FIELD_LOADING_KEY, setFieldLoading);
520
538
  provide(FORM_CONTENT_LANGUAGE_KEY, "*");
521
539
 
@@ -580,6 +598,25 @@ if (props.labelKey && !inFormContainer) {
580
598
  });
581
599
  }
582
600
 
601
+ // A page opened on a field's anchor (`#retentionDays`, the id of its
602
+ // control) brings it into view and focuses it once drawn: a link followed
603
+ // inside the app gets no jump from the browser, and the fields of a record
604
+ // exist only once it loaded. A form in a drawer or a modal follows none: the
605
+ // anchor is the page's.
606
+ if (!inFormContainer && !container) {
607
+ const followFieldAnchor = () => {
608
+ const fieldId = linkedFieldId(window.location.hash, allFieldIds.value);
609
+ if (fieldId) void revealField(fieldId);
610
+ };
611
+ onMounted(() => {
612
+ followFieldAnchor();
613
+ window.addEventListener("hashchange", followFieldAnchor);
614
+ });
615
+ onBeforeUnmount(() =>
616
+ window.removeEventListener("hashchange", followFieldAnchor),
617
+ );
618
+ }
619
+
583
620
  const isHorizontal = props.fieldsOrientation !== "vertical";
584
621
  const surface: FormSurface = inFormContainer
585
622
  ? "container"
@@ -4,9 +4,14 @@ import type {
4
4
  InputNumberEmits,
5
5
  InputNumberSlots,
6
6
  } from "@nuxt/ui/components/InputNumber.vue";
7
- import { computed } from "vue";
7
+ import { computed, nextTick } from "vue";
8
8
  import { useForwardPropsEmits } from "reka-ui";
9
9
  import { useControlError } from "../../../build/composables/form/useControlError";
10
+ import {
11
+ numberAdjustment,
12
+ parseTypedNumber,
13
+ } from "../../../build/utils/numberAdjustment";
14
+ import type { InputNumberAdjustment } from "../../../types/input-number";
10
15
 
11
16
  defineOptions({ inheritAttrs: false });
12
17
 
@@ -15,16 +20,27 @@ interface InputNumberFieldProps extends InputNumberProps {
15
20
  suffix?: string;
16
21
  }
17
22
 
23
+ interface InputNumberAdjustEmits {
24
+ /**
25
+ * The field kept another value than the one typed, on leaving it or on
26
+ * Enter: clamped to `min` / `max`, or put on its `step`.
27
+ */
28
+ adjust: [adjustment: InputNumberAdjustment];
29
+ }
30
+
31
+ type InputNumberFieldEmits = InputNumberEmits & InputNumberAdjustEmits;
32
+
18
33
  const props = defineProps<InputNumberFieldProps>();
19
- const emits = defineEmits<InputNumberEmits>();
34
+ const emits = defineEmits<InputNumberFieldEmits>();
20
35
  defineSlots<InputNumberSlots>();
21
36
 
22
37
  const forwarded = useForwardPropsEmits(props, emits);
23
38
  const inputProps = computed(() => {
24
- const { suffix: _suffix, ...rest } = forwarded.value;
39
+ const { suffix: _suffix, onAdjust: _onAdjust, ...rest } = forwarded.value;
25
40
  return rest;
26
41
  });
27
42
  const { processI18n } = useTranslation();
43
+ const { locale } = useI18n();
28
44
 
29
45
  // v2 number fields keep the value left-aligned with a compact caret stepper.
30
46
  const DEFAULT_ORIENTATION = "vertical";
@@ -41,6 +57,41 @@ function onIncomingText(event: InputEvent): void {
41
57
  const refusesNegative = props.min !== undefined && props.min >= 0;
42
58
  report(refusesNegative && text.includes("-") ? BELOW_MIN : undefined);
43
59
  }
60
+
61
+ // What was typed since the field last settled, and the value it settled on.
62
+ let typedText: string | null = null;
63
+ let settledValue: number | null | undefined;
64
+
65
+ function onTyped(event: Event): void {
66
+ typedText = (event.target as HTMLInputElement).value;
67
+ settledValue = undefined;
68
+ }
69
+
70
+ function onSettledValue(value: number | null | undefined): void {
71
+ settledValue = value;
72
+ }
73
+
74
+ // The number field keeps its own copy of the text typed, which it does not
75
+ // always redraw when it settles on the value it already held (1020 typed
76
+ // over 1,000): its `change` handler copies the kept value's text back.
77
+ function redraw(input: HTMLInputElement): void {
78
+ input.dispatchEvent(new Event("change"));
79
+ }
80
+
81
+ /** Once the field applied the typed text: on leaving it, or on Enter. */
82
+ async function settle(event: Event): Promise<void> {
83
+ if (typedText === null) return;
84
+ const text = typedText;
85
+ typedText = null;
86
+ const input = event.target as HTMLInputElement;
87
+ await nextTick();
88
+ const typed = parseTypedNumber(text, props.locale ?? locale.value);
89
+ const kept = settledValue === undefined ? props.modelValue : settledValue;
90
+ const adjustment = numberAdjustment(typed, kept, props);
91
+ if (!adjustment) return;
92
+ emits("adjust", adjustment);
93
+ redraw(input);
94
+ }
44
95
  </script>
45
96
 
46
97
  <template>
@@ -49,9 +100,13 @@ function onIncomingText(event: InputEvent): void {
49
100
  <div
50
101
  :class="props.suffix ? 'flex items-center gap-2' : 'contents'"
51
102
  @beforeinput.capture="onIncomingText"
103
+ @input="onTyped"
104
+ @keydown.enter="settle"
52
105
  >
53
106
  <UInputNumber
54
107
  v-bind="{ ...inputProps, ...$attrs }"
108
+ @blur="settle"
109
+ @update:model-value="onSettledValue"
55
110
  :class="props.suffix && 'min-w-0 flex-1'"
56
111
  :orientation="props.orientation ?? DEFAULT_ORIENTATION"
57
112
  :increment="{ color: 'neutral' }"
@@ -3,8 +3,11 @@ import type { TabProps } from "../../composables/tab/types";
3
3
  import { type TabBadges, TabVariant } from "../../composables/tab/types/props";
4
4
  import { useChartFetch } from "../../composables/chart/useChartFetch";
5
5
  import { confirmLeave } from "../../build/composables/unsaved-changes/registry";
6
+ import { useComposedText } from "../../../../dms-core/app/composables/translation/useComposedText";
7
+ import type { BadgeProps } from "@nuxt/ui";
6
8
 
7
9
  const { processI18n } = useTranslation();
10
+ const { processText } = useComposedText();
8
11
 
9
12
  const props = withDefaults(defineProps<TabProps>(), {
10
13
  // A tab set with nothing in it yet is a legitimate state — a page being
@@ -45,9 +48,24 @@ const countFormat = computed(() => new Intl.NumberFormat(locale.value));
45
48
  const COUNT_BADGE_CLASS =
46
49
  "rounded-[4px] bg-elevated px-[5px] py-px font-mono text-[10.5px] font-semibold tabular-nums text-dimmed ring-0 in-data-[state=active]:bg-primary/10 in-data-[state=active]:text-primary";
47
50
 
51
+ type TabBadge = TabProps["items"][number]["badge"];
52
+
53
+ // A badge written as text is a BlockText: a `$` key or a ComposedText is
54
+ // written in the reader's language, a number as it is.
55
+ function writtenBadge(
56
+ badge: TabBadge,
57
+ ): string | number | BadgeProps | undefined {
58
+ if (badge === undefined || typeof badge === "number") return badge;
59
+ if (typeof badge === "string" || "key" in badge) return processText(badge);
60
+ const { label } = badge;
61
+ return typeof label === "string"
62
+ ? { ...badge, label: processText(label) }
63
+ : badge;
64
+ }
65
+
48
66
  function badgeOf(item: TabProps["items"][number]) {
49
67
  const fetched = fetchedBadges.value?.[item.slot];
50
- if (fetched === undefined) return item.badge;
68
+ if (fetched === undefined) return writtenBadge(item.badge);
51
69
  return typeof fetched === "number"
52
70
  ? countFormat.value.format(fetched)
53
71
  : fetched;
@@ -827,6 +827,8 @@ const liveHold = useTableLiveHold({
827
827
  gate: () => liveGate,
828
828
  signals: {
829
829
  isRowOpen: () => openRowId.value !== undefined,
830
+ isSearching: () => !!effectiveGlobalFilter.value,
831
+ isSorted: () => !isSameSorting(effectiveSorting.value, defaultSortState),
830
832
  tableElement: () => tableRoot.value?.$el ?? null,
831
833
  },
832
834
  });
@@ -2102,9 +2104,15 @@ onMounted(() => {
2102
2104
  @duplicate="handleRowDuplicate"
2103
2105
  @edit="handleRowEdit"
2104
2106
  >
2105
- <template v-if="liveHold.newCount.value > 0" #notice>
2107
+ <template
2108
+ v-if="liveHold.newCount.value > 0 || liveHold.reason.value"
2109
+ #notice
2110
+ >
2106
2111
  <LiveHoldBanner
2107
2112
  :count="liveHold.newCount.value"
2113
+ :reason="liveHold.reason.value"
2114
+ :missed-label="liveHold.missedLabel"
2115
+ :detail="liveHold.missedDetail.value"
2108
2116
  @show="liveHold.showNew"
2109
2117
  />
2110
2118
  </template>
@@ -64,6 +64,9 @@ export interface ChartSeries {
64
64
 
65
65
  export type ChartCurve = "smooth" | "straight" | "stepline";
66
66
 
67
+ /** Mirror of `ChartLegendPosition`: where a chart draws its legend. */
68
+ export type ChartLegendPosition = "top" | "bottom";
69
+
67
70
  /** Opt-in expansion of native Y bounds to include annotation lines and bands. */
68
71
  export interface ChartAutoYRange {
69
72
  /** Outward fraction of each bound's magnitude (or 1 at zero). Defaults to 0.08. */
@@ -3,6 +3,7 @@ import type {
3
3
  ChartAnnotation,
4
4
  ChartAutoYRange,
5
5
  ChartCurve,
6
+ ChartLegendPosition,
6
7
  ChartSeries,
7
8
  ChartType,
8
9
  ChartXAxis,
@@ -35,6 +36,7 @@ export interface UseApexChartInput {
35
36
  showGrid?: boolean;
36
37
  showTooltip?: boolean;
37
38
  showLegend?: boolean;
39
+ legendPosition?: ChartLegendPosition;
38
40
  smooth?: boolean;
39
41
  curve?: ChartCurve;
40
42
  stacked?: boolean;
@@ -1,7 +1,17 @@
1
1
  import type { InjectionKey } from "vue";
2
2
 
3
+ /**
4
+ * A check a `Form` runs before each submit: `false` stops the submit before
5
+ * anything is sent. See `useEnclosingForm().addValidator`.
6
+ */
3
7
  export type FormValidator = () => boolean;
8
+
9
+ /** Adds a {@link FormValidator} to a `Form`; returns its removal. */
4
10
  export type RegisterValidator = (validator: FormValidator) => () => void;
5
11
 
12
+ /**
13
+ * What a `Form` provides to register a validator. Kept for the fields that
14
+ * inject it; a module's field calls `useEnclosingForm().addValidator`.
15
+ */
6
16
  export const FORM_VALIDATOR_KEY: InjectionKey<RegisterValidator> =
7
17
  Symbol("form-validator");
@@ -0,0 +1,61 @@
1
+ import { getCurrentScope, inject, onScopeDispose } from "vue";
2
+ import { FORM_HANDLE_KEY } from "../../build/composables/form/enclosingForm";
3
+ import type { FormValidator } from "./types/validation";
4
+
5
+ /** The DMS `Form` a custom field is drawn in (see {@link useEnclosingForm}). */
6
+ export interface EnclosingForm {
7
+ /**
8
+ * Submits the form as its save button does: the form validates its fields,
9
+ * runs the validators, then sends what changed. Resolves once the submit
10
+ * was handled; a refused one shows its errors as usual.
11
+ */
12
+ submit: () => Promise<void>;
13
+ /**
14
+ * Runs `validator` before each submit: answering `false` stops it, before
15
+ * anything is sent (and before the other validators). Removed when the
16
+ * calling component goes, or by calling the returned function.
17
+ */
18
+ addValidator: (validator: FormValidator) => () => void;
19
+ /**
20
+ * Brings a field of the form into view, by its id, and focuses its control:
21
+ * its first focusable part when the control is a group.
22
+ */
23
+ focusField: (fieldId: string) => Promise<void>;
24
+ }
25
+
26
+ /**
27
+ * The DMS `Form` the calling component is drawn in: what a custom field (an
28
+ * `inputComponent` of the module's own) needs of it without reading the DOM.
29
+ * `null` outside a `Form`.
30
+ *
31
+ * ```ts
32
+ * const form = useEnclosingForm();
33
+ * const { confirm } = useConfirm();
34
+ * // Ask before a save that deletes data; submit again once confirmed.
35
+ * form?.addValidator(() => {
36
+ * if (!isLowered.value || isConfirmed) return true;
37
+ * void confirm({ title: "Delete older logs?" }).then((accepted) => {
38
+ * isConfirmed = accepted;
39
+ * if (accepted) void form.submit();
40
+ * });
41
+ * return false;
42
+ * });
43
+ * ```
44
+ *
45
+ * A page opened on the anchor of a field (`/settings#retentionDays`, the
46
+ * field's id) scrolls to it and focuses it once the form is drawn, without
47
+ * any of this: `focusField` is for a link or a button inside the page.
48
+ */
49
+ export function useEnclosingForm(): EnclosingForm | null {
50
+ const handle = inject(FORM_HANDLE_KEY, null);
51
+ if (!handle) return null;
52
+ return {
53
+ submit: handle.submit,
54
+ addValidator: (validator) => {
55
+ const remove = handle.registerValidator(validator);
56
+ if (getCurrentScope()) onScopeDispose(remove);
57
+ return remove;
58
+ },
59
+ focusField: handle.revealField,
60
+ };
61
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Focuses a page's own search (⌘ / on macOS, Ctrl / elsewhere), so it never
3
+ * competes with "/" for the navigation search. `meta` is read as Ctrl off
4
+ * macOS by `defineShortcuts`. The shifted variant is the same physical keys
5
+ * on layouts where "/" needs Shift (AZERTY: Shift + :), which
6
+ * `defineShortcuts` would otherwise reject as an extra modifier.
7
+ */
8
+ export const PAGE_SEARCH_SHORTCUTS = ["meta_/", "meta_shift_/"] as const;
9
+
10
+ /**
11
+ * `defineShortcuts` map focusing a page's own search on ⌘ / or Ctrl /: the
12
+ * keys a custom page binds its search to, "/" being the navigation
13
+ * search's.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * defineShortcuts(buildPageSearchShortcuts(() => input.value?.focus()));
18
+ * ```
19
+ */
20
+ export function buildPageSearchShortcuts(
21
+ focus: () => void,
22
+ ): Record<string, () => void> {
23
+ return Object.fromEntries(PAGE_SEARCH_SHORTCUTS.map((key) => [key, focus]));
24
+ }
@@ -75,8 +75,9 @@ export interface PageLiveState {
75
75
  * const { isPaused, missedCount, resume, hold, release } = usePageLive();
76
76
  * ```
77
77
  *
78
- * A component that refetches on its own realtime topic need not use it:
79
- * built-in blocks with a `realtimeTopic` already follow the state.
78
+ * A component that refetches on its own realtime topic need not read it:
79
+ * `usePageLiveRefresh` gates its refresh as the built-in blocks with a
80
+ * `realtimeTopic` are.
80
81
  */
81
82
  export function usePageLive(): PageLiveState {
82
83
  const isPaused = computed(() => currentPageLive()?.isPaused.value ?? false);