@antelopejs/dms 0.7.4 → 0.7.6

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 (109) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/implementations/dms-base/export-jobs.d.ts +4 -1
  3. package/dist/implementations/dms-base/export-jobs.js +10 -6
  4. package/dist/implementations/dms-base/export-jobs.js.map +1 -1
  5. package/dist/implementations/dms-base/search-route.js +20 -11
  6. package/dist/implementations/dms-base/search-route.js.map +1 -1
  7. package/dist/test/attachment-host/capabilities.d.ts +2 -0
  8. package/dist/test/attachment-host/capabilities.js +11 -0
  9. package/dist/test/attachment-host/capabilities.js.map +1 -1
  10. package/dist/test/integration/table-view-capabilities.test.js +23 -5
  11. package/dist/test/integration/table-view-capabilities.test.js.map +1 -1
  12. package/dist/test/unit/interfaces/dms-base/data-types/field-types.test.js +29 -0
  13. package/dist/test/unit/interfaces/dms-base/data-types/field-types.test.js.map +1 -1
  14. package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js +14 -0
  15. package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js.map +1 -1
  16. package/dist/test/unit/interfaces/dms-base/export-jobs-binding.test.d.ts +1 -0
  17. package/dist/test/unit/interfaces/dms-base/export-jobs-binding.test.js +49 -0
  18. package/dist/test/unit/interfaces/dms-base/export-jobs-binding.test.js.map +1 -0
  19. package/dist/test/unit/interfaces/dms-base/table-view-hidden-columns.test.d.ts +1 -0
  20. package/dist/test/unit/interfaces/dms-base/table-view-hidden-columns.test.js +105 -0
  21. package/dist/test/unit/interfaces/dms-base/table-view-hidden-columns.test.js.map +1 -0
  22. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +16 -0
  23. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
  24. package/dist/test/unit/request-tenant-guards.test.js +38 -0
  25. package/dist/test/unit/request-tenant-guards.test.js.map +1 -1
  26. package/frontend-vue/layers/dms-auth/app/custom-pages/auth/accounts.vue +9 -3
  27. package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +6 -0
  28. package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +6 -0
  29. package/frontend-vue/layers/dms-layout/app/build/components/layout/CommandPaletteAssistantAnswer.vue +1 -0
  30. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardHeader.vue +2 -0
  31. package/frontend-vue/layers/dms-layout/app/build/components/layout/Footer.vue +1 -0
  32. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageSkeleton.vue +13 -4
  33. package/frontend-vue/layers/dms-layout/app/build/components/layout/RolePreviewBar.vue +9 -3
  34. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/members/MemberRolePicker.vue +5 -1
  35. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/profile/ProfilePersonalInfo.vue +1 -0
  36. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/profile/ProfileSummaryRow.vue +4 -0
  37. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/roles/RoleEditor.vue +1 -1
  38. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityEmail.vue +4 -1
  39. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityPassword.vue +4 -1
  40. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecuritySessions.vue +1 -0
  41. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityTwoFactor.vue +19 -4
  42. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/theme/AppearanceDeveloper.vue +1 -0
  43. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/theme/CssVariablesList.vue +6 -3
  44. package/frontend-vue/layers/dms-layout/app/custom-pages/modules.vue +20 -5
  45. package/frontend-vue/layers/dms-ui/app/build/components/chart/internal/ChartSkeleton.vue +4 -0
  46. package/frontend-vue/layers/dms-ui/app/build/components/containers/drawer/DynamicDrawer.vue +10 -2
  47. package/frontend-vue/layers/dms-ui/app/build/components/containers/modal/DynamicModal.vue +7 -2
  48. package/frontend-vue/layers/dms-ui/app/build/components/form/FormSectionNav.vue +87 -40
  49. package/frontend-vue/layers/dms-ui/app/build/components/skeleton/RowSkeleton.vue +8 -1
  50. package/frontend-vue/layers/dms-ui/app/build/components/stat/StatCell.vue +54 -13
  51. package/frontend-vue/layers/dms-ui/app/build/components/table/Pagination.vue +9 -2
  52. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +22 -40
  53. package/frontend-vue/layers/dms-ui/app/build/components/table/Tabs.vue +1 -0
  54. package/frontend-vue/layers/dms-ui/app/build/components/table/Views.vue +1 -0
  55. package/frontend-vue/layers/dms-ui/app/build/components/table-view/CardGridSkeleton.vue +22 -5
  56. package/frontend-vue/layers/dms-ui/app/build/components/table-view/CardsDisplay.vue +49 -9
  57. package/frontend-vue/layers/dms-ui/app/build/components/table-view/ExpandedRowDetail.vue +1 -0
  58. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +3 -1
  59. package/frontend-vue/layers/dms-ui/app/build/composables/form/formFooter.ts +3 -2
  60. package/frontend-vue/layers/dms-ui/app/build/composables/form/formSections.ts +25 -0
  61. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useReorderHandles.ts +93 -0
  62. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableReorder.ts +8 -7
  63. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +20 -2
  64. package/frontend-vue/layers/dms-ui/app/build/utils/characterCount.ts +8 -0
  65. package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +4 -1
  66. package/frontend-vue/layers/dms-ui/app/components/blocks/CodeBlock.vue +3 -0
  67. package/frontend-vue/layers/dms-ui/app/components/blocks/NavCardGridBlock.vue +13 -5
  68. package/frontend-vue/layers/dms-ui/app/components/card/NavCard.vue +7 -1
  69. package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +10 -3
  70. package/frontend-vue/layers/dms-ui/app/components/confirm/ConfirmModal.vue +8 -0
  71. package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +4 -3
  72. package/frontend-vue/layers/dms-ui/app/components/form/components/Cascader.vue +6 -1
  73. package/frontend-vue/layers/dms-ui/app/components/form/components/Display.vue +5 -1
  74. package/frontend-vue/layers/dms-ui/app/components/form/components/InputNumber.vue +27 -3
  75. package/frontend-vue/layers/dms-ui/app/components/form/components/InputText.vue +24 -2
  76. package/frontend-vue/layers/dms-ui/app/components/form/components/RichText.vue +13 -4
  77. package/frontend-vue/layers/dms-ui/app/components/form/components/Textarea.vue +27 -2
  78. package/frontend-vue/layers/dms-ui/app/components/key-value-list/KeyValueList.vue +8 -4
  79. package/frontend-vue/layers/dms-ui/app/components/kpi/KpiCard.vue +12 -4
  80. package/frontend-vue/layers/dms-ui/app/components/meter/MeterBlock.vue +15 -4
  81. package/frontend-vue/layers/dms-ui/app/components/save-bar/SaveBar.vue +69 -42
  82. package/frontend-vue/layers/dms-ui/app/components/tab/Tab.vue +15 -1
  83. package/frontend-vue/layers/dms-ui/app/components/table-view/KanbanBoard.vue +13 -3
  84. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +8 -2
  85. package/frontend-vue/layers/dms-ui/app/components/top-list/TopListCard.vue +45 -9
  86. package/frontend-vue/layers/dms-ui/app/composables/form/types/field.ts +2 -0
  87. package/frontend-vue/layers/dms-ui/app/composables/tab/types/props.ts +5 -0
  88. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/display.ts +51 -0
  89. package/frontend-vue/tests/confirm-dialog-fields.test.ts +12 -0
  90. package/frontend-vue/tests/drawer-target-direction.test.ts +1 -0
  91. package/frontend-vue/tests/field-suffix-count.test.ts +120 -0
  92. package/frontend-vue/tests/form-section-nav-groups.test.ts +116 -0
  93. package/frontend-vue/tests/nav-card-badge.test.ts +5 -1
  94. package/frontend-vue/tests/nuxt-ui-aria-labels.test.ts +58 -0
  95. package/frontend-vue/tests/page-fill-height.test.ts +2 -2
  96. package/frontend-vue/tests/save-bar.test.ts +49 -0
  97. package/frontend-vue/tests/stat-group-detail-lines.test.ts +63 -0
  98. package/frontend-vue/tests/tab-fit.test.ts +99 -0
  99. package/frontend-vue/tests/table-cell-sublines.test.ts +15 -0
  100. package/frontend-vue/tests/table-cell-wrap.test.ts +27 -3
  101. package/frontend-vue/tests/table-row-action-errors.test.ts +26 -0
  102. package/frontend-vue/tests/table-row-hover.test.ts +3 -1
  103. package/frontend-vue/tests/table-scroll-area.test.ts +3 -1
  104. package/frontend-vue/tests/table-view-card-props.test.ts +8 -2
  105. package/frontend-vue/tests/table-view-cards-reorder.test.ts +250 -0
  106. package/frontend-vue/tests/table-view-reorder.test.ts +2 -2
  107. package/frontend-vue/tests/top-list-card-actions.test.ts +91 -0
  108. package/package.json +2 -2
  109. package/skills/dms/SKILL.md +14 -1
@@ -314,10 +314,19 @@ const toolbarButtons = computed(() => [
314
314
  padding, so the swap to the editor moves nothing below it. -->
315
315
  <template #fallback>
316
316
  <div class="min-h-[140px] px-3 py-2.5" aria-busy="true">
317
- <USkeleton class="mb-4 h-8 w-3/4" />
318
- <USkeleton class="mb-3 h-4 w-full" />
319
- <USkeleton class="mb-3 h-4 w-5/6" />
320
- <USkeleton class="h-4 w-4/5" />
317
+ <USkeleton
318
+ :aria-label="t('dms.a11y.loading')"
319
+ class="mb-4 h-8 w-3/4"
320
+ />
321
+ <USkeleton
322
+ :aria-label="t('dms.a11y.loading')"
323
+ class="mb-3 h-4 w-full"
324
+ />
325
+ <USkeleton
326
+ :aria-label="t('dms.a11y.loading')"
327
+ class="mb-3 h-4 w-5/6"
328
+ />
329
+ <USkeleton :aria-label="t('dms.a11y.loading')" class="h-4 w-4/5" />
321
330
  </div>
322
331
  </template>
323
332
  </DmsClientOnly>
@@ -4,15 +4,40 @@ import type {
4
4
  TextareaEmits,
5
5
  TextareaSlots,
6
6
  } from "@nuxt/ui/components/Textarea.vue";
7
+ import { computed } from "vue";
7
8
  import { useForwardPropsEmits } from "reka-ui";
9
+ import { characterCountText } from "../../../build/utils/characterCount";
8
10
 
9
- const props = defineProps<TextareaProps>();
11
+ interface TextareaFieldProps extends TextareaProps {
12
+ /** Longest value accepted; the counter reads against it. */
13
+ maxLength?: number;
14
+ /** Shows the characters typed under the field ("4 / 60"). */
15
+ showCount?: boolean;
16
+ }
17
+
18
+ const props = defineProps<TextareaFieldProps>();
10
19
  const emits = defineEmits<TextareaEmits>();
11
20
  defineSlots<TextareaSlots>();
12
21
 
13
22
  const forwarded = useForwardPropsEmits(props, emits);
23
+ const textareaProps = computed(() => {
24
+ const { showCount: _showCount, ...rest } = forwarded.value;
25
+ return rest;
26
+ });
27
+ const countText = computed(() =>
28
+ characterCountText(props.modelValue, props.maxLength),
29
+ );
14
30
  </script>
15
31
 
16
32
  <template>
17
- <UTextarea v-bind="forwarded" />
33
+ <div v-if="props.showCount" class="grid gap-1">
34
+ <UTextarea v-bind="textareaProps" class="w-full" />
35
+ <span
36
+ class="text-dimmed justify-self-end text-xs tabular-nums"
37
+ data-character-count
38
+ >
39
+ {{ countText }}
40
+ </span>
41
+ </div>
42
+ <UTextarea v-else v-bind="textareaProps" />
18
43
  </template>
@@ -82,7 +82,7 @@ const COLUMN_CLASSES: Record<number, string> = {
82
82
  3: "gap-x-8 sm:grid-cols-2 lg:grid-cols-3 [&>div:first-child]:border-t-0 sm:[&>div:nth-child(2)]:border-t-0 lg:[&>div:nth-child(3)]:border-t-0",
83
83
  };
84
84
 
85
- const { locale } = useI18n();
85
+ const { locale, t } = useI18n();
86
86
 
87
87
  const columnClass = computed(
88
88
  () =>
@@ -185,10 +185,10 @@ function keyOf(item: KeyValueItem, index: number): string {
185
185
  <template v-if="showPlaceholders">
186
186
  <div v-for="index in props.skeletonCount" :key="index" :class="rowClass">
187
187
  <dt class="min-w-[120px] flex-none">
188
- <USkeleton class="h-3 w-20" />
188
+ <USkeleton :aria-label="t('dms.a11y.loading')" class="h-3 w-20" />
189
189
  </dt>
190
190
  <dd class="ms-auto">
191
- <USkeleton class="h-3 w-24" />
191
+ <USkeleton :aria-label="t('dms.a11y.loading')" class="h-3 w-24" />
192
192
  </dd>
193
193
  </div>
194
194
  </template>
@@ -202,7 +202,11 @@ function keyOf(item: KeyValueItem, index: number): string {
202
202
  <dd
203
203
  class="ms-auto flex min-w-0 flex-wrap items-center justify-end gap-x-1.5 gap-y-0.5 text-end"
204
204
  >
205
- <USkeleton v-if="props.loading || item.loading" class="h-3 w-24" />
205
+ <USkeleton
206
+ :aria-label="t('dms.a11y.loading')"
207
+ v-if="props.loading || item.loading"
208
+ class="h-3 w-24"
209
+ />
206
210
  <slot
207
211
  v-else-if="slots.value"
208
212
  name="value"
@@ -196,7 +196,11 @@ const isStat = computed(() => props.variant === "stat");
196
196
  />
197
197
  </div>
198
198
  <!-- The value's placeholder takes its 33px line. -->
199
- <USkeleton v-if="isFirstLoad" class="mt-1 h-[33px] w-32" />
199
+ <USkeleton
200
+ :aria-label="t('dms.a11y.loading')"
201
+ v-if="isFirstLoad"
202
+ class="mt-1 h-[33px] w-32"
203
+ />
200
204
  <div
201
205
  v-else-if="hasError"
202
206
  class="mt-auto flex items-center justify-between gap-2"
@@ -242,13 +246,17 @@ const isStat = computed(() => props.variant === "stat");
242
246
  >
243
247
  <div class="grid">
244
248
  <div v-if="showDelta" class="flex h-6 items-center">
245
- <USkeleton class="h-3 w-[58px]" />
249
+ <USkeleton :aria-label="t('dms.a11y.loading')" class="h-3 w-[58px]" />
246
250
  </div>
247
251
  <div v-if="footnote" class="flex h-4 items-center">
248
- <USkeleton class="h-2.5 w-28" />
252
+ <USkeleton :aria-label="t('dms.a11y.loading')" class="h-2.5 w-28" />
249
253
  </div>
250
254
  </div>
251
- <USkeleton v-if="showSparkline" class="ml-auto h-[34px] w-24" />
255
+ <USkeleton
256
+ :aria-label="t('dms.a11y.loading')"
257
+ v-if="showSparkline"
258
+ class="ml-auto h-[34px] w-24"
259
+ />
252
260
  </div>
253
261
  <!-- The trend's line holds its place, as in the skeleton, while there is
254
262
  no change to show (no comparison period): no "0%" stand-in, and
@@ -143,12 +143,23 @@ const Wrapper = props.card ? resolveComponent("DmsCard") : "div";
143
143
  <span v-if="props.label" class="text-toned">
144
144
  {{ translate(props.label) }}
145
145
  </span>
146
- <USkeleton v-else class="h-3 w-1/3" />
147
- <USkeleton class="ms-auto h-3 w-12" />
146
+ <USkeleton
147
+ :aria-label="t('dms.a11y.loading')"
148
+ v-else
149
+ class="h-3 w-1/3"
150
+ />
151
+ <USkeleton
152
+ :aria-label="t('dms.a11y.loading')"
153
+ class="ms-auto h-3 w-12"
154
+ />
148
155
  </span>
149
- <USkeleton class="w-full rounded-full" :class="skeletonTrackClass" />
156
+ <USkeleton
157
+ :aria-label="t('dms.a11y.loading')"
158
+ class="w-full rounded-full"
159
+ :class="skeletonTrackClass"
160
+ />
150
161
  <span v-if="skeletonHasLegend" class="flex h-4 items-center">
151
- <USkeleton class="h-2.5 w-40" />
162
+ <USkeleton :aria-label="t('dms.a11y.loading')" class="h-2.5 w-40" />
152
163
  </span>
153
164
  </div>
154
165
  <div v-else-if="hasError" class="grid min-w-0 gap-1.5" role="alert">
@@ -9,9 +9,10 @@ import { trackFloatingSaveBar } from "../../build/composables/form/floatingSaveB
9
9
  import DmsUnsavedStatus from "../../build/components/form/UnsavedStatus.vue";
10
10
 
11
11
  /**
12
- * `floating`: the v2 save bar, a card that sticks to the bottom of the panel
13
- * while the form runs past it. `band`: the footer band of a form card, a
14
- * drawer or a modal (its surface classes come from the form).
12
+ * `floating`: the v2 save bar, a card floating over the bottom of the panel
13
+ * while the form runs past it, taking no space while hidden. `band`: the
14
+ * footer band of a form card, a drawer or a modal (its surface classes come
15
+ * from the form).
15
16
  */
16
17
  export type SaveBarVariant = "floating" | "band";
17
18
 
@@ -66,26 +67,50 @@ const SECONDARY_LABELS: Record<FormKind, string> = {
66
67
  action: "dms.button.reset",
67
68
  };
68
69
 
69
- // Its states have the same height, and hidden it keeps its place (one line,
70
- // whatever the changes): switching never moves the content around it.
70
+ // Its states have the same height, so switching between them never moves the
71
+ // content around it. The floating bar overlays the form: its root is a sticky
72
+ // anchor at the form's end that takes no space while the bar hides, and the
73
+ // bar's height plus its gaps while it shows, so the last field scrolls clear
74
+ // of it and nothing above it moves. The band, a footer, keeps its place.
71
75
  const theme = tv({
72
76
  slots: {
73
- root: "flex items-center transition-[opacity,translate,visibility] duration-200 ease-out",
77
+ root: "",
78
+ bar: "flex items-center",
74
79
  actions: "ms-auto flex shrink-0 items-center gap-2",
75
80
  },
76
81
  variants: {
77
82
  variant: {
78
83
  floating: {
79
- root: "border-accented sticky bottom-4 z-5 mt-5 gap-3 rounded-[10px] border bg-(--ui-bg) py-2.5 ps-4 pe-3 text-[13px] shadow-lg",
84
+ root: "pointer-events-none sticky bottom-1 z-5 h-0 [--dms-save-bar-h:50px]",
85
+ bar: "border-accented pointer-events-auto absolute inset-x-0 bottom-3 h-(--dms-save-bar-h) gap-3 rounded-[10px] border bg-(--ui-bg) ps-4 pe-3 text-[13px] shadow-lg transition-[opacity,translate,visibility] duration-200 ease-out",
80
86
  },
81
87
  band: {
82
- root: "gap-2",
88
+ root: "transition-[opacity,translate,visibility] duration-200 ease-out",
89
+ bar: "gap-2",
83
90
  },
84
91
  },
85
92
  hidden: {
86
- true: { root: "invisible translate-y-2 opacity-0" },
93
+ true: "",
94
+ false: "",
87
95
  },
88
96
  },
97
+ compoundVariants: [
98
+ {
99
+ variant: "floating",
100
+ hidden: false,
101
+ class: { root: "h-[calc(var(--dms-save-bar-h)+2rem)]" },
102
+ },
103
+ {
104
+ variant: "floating",
105
+ hidden: true,
106
+ class: { bar: "invisible translate-y-2 opacity-0" },
107
+ },
108
+ {
109
+ variant: "band",
110
+ hidden: true,
111
+ class: { root: "invisible translate-y-2 opacity-0" },
112
+ },
113
+ ],
89
114
  });
90
115
 
91
116
  const BUTTONS = {
@@ -112,7 +137,7 @@ trackFloatingSaveBar(
112
137
  () => props.variant === "floating" && !state.value.isHidden,
113
138
  );
114
139
 
115
- // Empty while there is nothing to save: the hidden bar keeps its line.
140
+ // Empty while there is nothing to save: the hidden bar keeps its one line.
116
141
  const statusText = computed(() => {
117
142
  if (!props.dirty) return "";
118
143
  const changed = props.changes.map((label) => processI18n(label)).join(", ");
@@ -133,39 +158,41 @@ const saveText = computed(() => {
133
158
  :inert="state.isHidden || undefined"
134
159
  :aria-hidden="state.isHidden || undefined"
135
160
  >
136
- <DmsUnsavedStatus
137
- v-if="state.showsStatus"
138
- :text="statusText"
139
- :is-band="props.variant === 'band'"
140
- />
141
- <div :class="ui.actions()">
142
- <UButton
143
- v-if="state.secondary === 'discard'"
144
- :label="t(SECONDARY_LABELS[props.kind])"
145
- color="neutral"
146
- :variant="buttons.discardVariant"
147
- :size="buttons.size"
148
- :disabled="props.saving"
149
- @click="emit('discard')"
150
- />
151
- <UButton
152
- v-else-if="state.secondary === 'cancel'"
153
- :label="t('dms.button.cancel')"
154
- color="neutral"
155
- variant="outline"
156
- :size="buttons.size"
157
- @click="emit('cancel')"
158
- />
159
- <UButton
160
- v-if="state.showsSubmit"
161
- :label="saveText"
162
- :size="buttons.size"
163
- :loading="props.saving"
164
- :disabled="state.isSubmitHeld"
165
- :type="props.form ? 'submit' : 'button'"
166
- :form="props.form"
167
- @click="props.form ? undefined : emit('save')"
161
+ <div :class="ui.bar()">
162
+ <DmsUnsavedStatus
163
+ v-if="state.showsStatus"
164
+ :text="statusText"
165
+ :is-band="props.variant === 'band'"
168
166
  />
167
+ <div :class="ui.actions()">
168
+ <UButton
169
+ v-if="state.secondary === 'discard'"
170
+ :label="t(SECONDARY_LABELS[props.kind])"
171
+ color="neutral"
172
+ :variant="buttons.discardVariant"
173
+ :size="buttons.size"
174
+ :disabled="props.saving"
175
+ @click="emit('discard')"
176
+ />
177
+ <UButton
178
+ v-else-if="state.secondary === 'cancel'"
179
+ :label="t('dms.button.cancel')"
180
+ color="neutral"
181
+ variant="outline"
182
+ :size="buttons.size"
183
+ @click="emit('cancel')"
184
+ />
185
+ <UButton
186
+ v-if="state.showsSubmit"
187
+ :label="saveText"
188
+ :size="buttons.size"
189
+ :loading="props.saving"
190
+ :disabled="state.isSubmitHeld"
191
+ :type="props.form ? 'submit' : 'button'"
192
+ :form="props.form"
193
+ @click="props.form ? undefined : emit('save')"
194
+ />
195
+ </div>
169
196
  </div>
170
197
  </div>
171
198
  </template>
@@ -21,6 +21,7 @@ const props = withDefaults(defineProps<TabProps>(), {
21
21
  stateKey: "tab",
22
22
  badgesUrl: undefined,
23
23
  realtimeTopic: undefined,
24
+ fit: false,
24
25
  });
25
26
 
26
27
  // The badges a module counts for a record's tabs, in one request, again when
@@ -72,10 +73,23 @@ const ROOT_CLASSES: Record<AxeOrientation, string> = {
72
73
  [AxeOrientation.vertical]: "max-sm:flex-col max-sm:items-stretch",
73
74
  };
74
75
 
76
+ // A fitted pill list hugs its tabs: the triggers stop growing to share the
77
+ // full width. Link tabs never grow, and a vertical list has its own width.
78
+ const FIT_CLASSES: Record<TabVariant, { list: string; trigger: string }> = {
79
+ [TabVariant.pill]: { list: "w-fit max-w-full", trigger: "grow-0" },
80
+ [TabVariant.link]: { list: "", trigger: "" },
81
+ };
82
+
75
83
  const panelClass = computed(() => PANEL_CLASSES[props.orientation]);
84
+ const fitClasses = computed(() =>
85
+ props.fit && props.orientation === AxeOrientation.horizontal
86
+ ? FIT_CLASSES[props.variant]
87
+ : undefined,
88
+ );
76
89
  const tabsUi = computed(() => ({
77
90
  root: ROOT_CLASSES[props.orientation],
78
- list: LIST_CLASSES[props.orientation],
91
+ list: [LIST_CLASSES[props.orientation], fitClasses.value?.list],
92
+ trigger: fitClasses.value?.trigger,
79
93
  // The panel may shrink below its content's width (a wide table scrolls
80
94
  // inside it instead of pushing the page sideways).
81
95
  content: "min-w-0",
@@ -451,6 +451,7 @@ const FieldValue = (fieldProps: { column: TableViewColumn; item: T }) => {
451
451
  {{ col.label }}
452
452
  </span>
453
453
  <USkeleton
454
+ :aria-label="t('dms.a11y.loading')"
454
455
  v-if="isBoardLoading && !cells[col.value]"
455
456
  aria-hidden="true"
456
457
  class="ms-auto h-[16.5px] w-5 shrink-0 rounded-[5px]"
@@ -478,17 +479,26 @@ const FieldValue = (fieldProps: { column: TableViewColumn; item: T }) => {
478
479
  class="border-default grid gap-2 rounded-lg border bg-(--dms-surface-card) px-3 pt-2.5 pb-[11px] shadow-xs"
479
480
  >
480
481
  <div class="-mb-0.5 flex min-h-5 items-center">
481
- <USkeleton class="h-2.5 w-10" />
482
+ <USkeleton
483
+ :aria-label="t('dms.a11y.loading')"
484
+ class="h-2.5 w-10"
485
+ />
482
486
  </div>
483
487
  <div class="flex h-[17px] items-center">
484
- <USkeleton class="h-3 w-3/4" />
488
+ <USkeleton
489
+ :aria-label="t('dms.a11y.loading')"
490
+ class="h-3 w-3/4"
491
+ />
485
492
  </div>
486
493
  <div
487
494
  v-for="fieldColumn in cardColumns"
488
495
  :key="fieldColumn.id"
489
496
  class="-mt-0.5 flex h-[18px] items-center"
490
497
  >
491
- <USkeleton class="h-2.5 w-1/2" />
498
+ <USkeleton
499
+ :aria-label="t('dms.a11y.loading')"
500
+ class="h-2.5 w-1/2"
501
+ />
492
502
  </div>
493
503
  </div>
494
504
  </div>
@@ -37,6 +37,7 @@ import type {
37
37
  } from "../../composables/table-view/types";
38
38
  import {
39
39
  TABLE_DISPLAY_ID,
40
+ CARDS_DISPLAY_ID,
40
41
  KANBAN_DISPLAY_ID,
41
42
  GROUPED_DISPLAY_ID,
42
43
  TableViewEvents,
@@ -1019,7 +1020,9 @@ const accumulation = computed<TableAccumulation | undefined>(() =>
1019
1020
  : undefined,
1020
1021
  );
1021
1022
 
1022
- // Rows ordered by hand: moving is off while anything narrows the rows.
1023
+ // Rows ordered by hand: moving is off while anything narrows the rows, and
1024
+ // only the displays drawing the rows in their order move them.
1025
+ const ORDERED_DISPLAY_IDS = new Set([TABLE_DISPLAY_ID, CARDS_DISPLAY_ID]);
1023
1026
  const { reorderState } = useTableReorder<T>({
1024
1027
  reorder: reorderConfig,
1025
1028
  data,
@@ -1033,7 +1036,9 @@ const { reorderState } = useTableReorder<T>({
1033
1036
  !!effectiveGlobalFilter.value ||
1034
1037
  activeTabFilters.value.length > 0,
1035
1038
  ),
1036
- isGrid: computed(() => activeDisplayId.value === TABLE_DISPLAY_ID),
1039
+ isOrderedDisplay: computed(() =>
1040
+ ORDERED_DISPLAY_IDS.has(activeDisplayId.value),
1041
+ ),
1037
1042
  refresh: () => refresh(),
1038
1043
  onError: (failure) =>
1039
1044
  toast.add({
@@ -1742,6 +1747,7 @@ const displayContext = computed(
1742
1747
  pageId,
1743
1748
  options: optionsForDisplay(activeDisplayId.value),
1744
1749
  refresh: refreshAll,
1750
+ reorder: reorderState.value,
1745
1751
  table: displayTable.value,
1746
1752
  }),
1747
1753
  );
@@ -3,6 +3,9 @@ import { computed, nextTick, onMounted, ref, watch } from "vue";
3
3
  import { useMounted } from "@vueuse/core";
4
4
  import DmsEmptyState from "../empty-state/EmptyState.vue";
5
5
  import DmsSectionHeader from "../section-header/SectionHeader.vue";
6
+ import DmsBlockActions, {
7
+ type BlockAction,
8
+ } from "../../build/components/blocks/BlockActions.vue";
6
9
  // Imported rather than resolved from the registry: a registered component is
7
10
  // a lazy chunk of its own, and a list whose rows only arrive with its data
8
11
  // would draw an empty card for the moment that chunk takes.
@@ -42,6 +45,8 @@ interface Props extends DefaultComponentProps {
42
45
  maxHeight?: string;
43
46
  staticItems?: TopListItem[];
44
47
  emptyLabel?: string;
48
+ /** Link buttons in the card head, after the period badge. */
49
+ actions?: BlockAction[];
45
50
  /**
46
51
  * Placeholder rows while `fetchUrl` loads: the "top N" it answers.
47
52
  * Optional. Defaults to 10, enough to fill the default `maxHeight`.
@@ -90,6 +95,7 @@ const props = withDefaults(defineProps<Props>(), {
90
95
  currencyCode: "EUR",
91
96
  maxHeight: DEFAULT_MAX_HEIGHT,
92
97
  skeletonCount: SKELETON_ROW_COUNT,
98
+ actions: () => [],
93
99
  });
94
100
 
95
101
  const { locale, t } = useI18n();
@@ -247,11 +253,33 @@ const gridStyle = computed(() => ({
247
253
  :title="processI18n(title)"
248
254
  :description="description ? processI18n(description) : undefined"
249
255
  >
250
- <template v-if="periodScope" #trailing>
251
- <UBadge v-if="badgeLabel" :color="badgeColor" variant="soft" size="sm">
252
- {{ badgeLabel }}
253
- </UBadge>
254
- <USkeleton v-else class="h-5 w-16 rounded-[5px]" aria-hidden="true" />
256
+ <template v-if="periodScope || props.actions.length" #trailing>
257
+ <template v-if="periodScope">
258
+ <UBadge
259
+ v-if="badgeLabel"
260
+ :color="badgeColor"
261
+ variant="soft"
262
+ size="sm"
263
+ >
264
+ {{ badgeLabel }}
265
+ </UBadge>
266
+ <USkeleton
267
+ :aria-label="t('dms.a11y.loading')"
268
+ v-else
269
+ class="h-5 w-16 rounded-[5px]"
270
+ aria-hidden="true"
271
+ />
272
+ </template>
273
+ <!-- Quiet links, as in an ActivityFeed head: the rows stay the
274
+ card's content. -->
275
+ <DmsBlockActions
276
+ v-if="props.actions.length"
277
+ :actions="props.actions"
278
+ size="xs"
279
+ lead-variant="ghost"
280
+ lead-color="neutral"
281
+ rest-variant="ghost"
282
+ />
255
283
  </template>
256
284
  </DmsSectionHeader>
257
285
 
@@ -308,15 +336,23 @@ const gridStyle = computed(() => ({
308
336
  class="flex h-14 items-center gap-3 px-[18px]"
309
337
  >
310
338
  <USkeleton
339
+ :aria-label="t('dms.a11y.loading')"
311
340
  v-if="showRank"
312
341
  class="size-[26px] shrink-0 rounded-[7px]"
313
342
  />
314
343
  <div class="grid flex-1 gap-1.5">
315
- <USkeleton class="h-3 w-2/5" />
316
- <USkeleton class="h-2.5 w-1/4" />
344
+ <USkeleton :aria-label="t('dms.a11y.loading')" class="h-3 w-2/5" />
345
+ <USkeleton
346
+ :aria-label="t('dms.a11y.loading')"
347
+ class="h-2.5 w-1/4"
348
+ />
317
349
  </div>
318
- <USkeleton class="h-3 w-14" />
319
- <USkeleton v-if="showDelta" class="h-3 w-[46px]" />
350
+ <USkeleton :aria-label="t('dms.a11y.loading')" class="h-3 w-14" />
351
+ <USkeleton
352
+ :aria-label="t('dms.a11y.loading')"
353
+ v-if="showDelta"
354
+ class="h-3 w-[46px]"
355
+ />
320
356
  </div>
321
357
  </div>
322
358
  </div>
@@ -54,6 +54,8 @@ export interface FormSection {
54
54
  label: string;
55
55
  description?: string;
56
56
  icon?: string;
57
+ /** Heading of the side navigation the section's entry sits under. */
58
+ navGroup?: string;
57
59
  fieldIds: string[];
58
60
  }
59
61
 
@@ -31,6 +31,11 @@ export interface TabProps extends TabComponentProps {
31
31
  badgesUrl?: string;
32
32
  /** Topics whose events make the tab set read `badgesUrl` again. */
33
33
  realtimeTopic?: string | string[];
34
+ /**
35
+ * A horizontal `pill` list as wide as its tabs, not the full width; a
36
+ * `link` list already sizes its tabs to their labels.
37
+ */
38
+ fit?: boolean;
34
39
  }
35
40
 
36
41
  /** The badges `badgesUrl` answers with, by tab slot. */
@@ -95,6 +95,47 @@ export interface TableViewDisplayActions<T> {
95
95
  custom: (action: CustomRowAction) => void;
96
96
  }
97
97
 
98
+ /**
99
+ * Rows ordered by hand (backend `reorder`), as a display moves them. Set on
100
+ * the displays that draw the rows in their order (the grid and `cards`), for
101
+ * a caller who may edit them.
102
+ */
103
+ export interface TableViewDisplayReorder {
104
+ /** Off while a search, a filter or a tab narrows the rows. */
105
+ enabled: boolean;
106
+ /** Moves the row at `from` to `to`, both indexes into `items`. */
107
+ move: (from: number, to: number) => void;
108
+ }
109
+
110
+ /**
111
+ * The attributes and listeners of a move handle: `v-bind` them on a
112
+ * `<button>`. Dragged, it moves its row onto the card it is dropped on; with
113
+ * it focused, the arrow keys move the row one place back (up, left) or
114
+ * forward (down, right).
115
+ */
116
+ export interface TableViewReorderHandle {
117
+ type: "button";
118
+ draggable: boolean;
119
+ disabled: boolean;
120
+ "aria-label": string;
121
+ /** Says how to move the row, or, while moving is off, how to turn it on. */
122
+ title: string;
123
+ onClick: (event: MouseEvent) => void;
124
+ onDragstart: (event: DragEvent) => void;
125
+ onDragend: () => void;
126
+ onKeydown: (event: KeyboardEvent) => void;
127
+ }
128
+
129
+ /** A card's move handle, on a hand-ordered `cards` display. */
130
+ export interface TableViewCardReorder {
131
+ /** Off while a search, a filter or a tab narrows the rows. */
132
+ enabled: boolean;
133
+ /** The card is the one being dragged. */
134
+ dragging: boolean;
135
+ /** Bind on the element the card is moved by. */
136
+ handle: TableViewReorderHandle;
137
+ }
138
+
98
139
  /**
99
140
  * The card the `kanban` and `cards` displays draw (backend `card` option):
100
141
  * the columns it shows, or a component drawing it whole.
@@ -127,6 +168,11 @@ export interface TableViewCardProps<T = Record<string, unknown>> {
127
168
  open: () => void;
128
169
  /** Kanban only: the value of the column the card sits in. */
129
170
  groupValue?: string;
171
+ /**
172
+ * `cards` only, on a hand-ordered table (backend `reorder`) and for a
173
+ * caller who may edit: the card's move handle, to draw somewhere on it.
174
+ */
175
+ reorder?: TableViewCardReorder;
130
176
  }
131
177
 
132
178
  /**
@@ -182,6 +228,11 @@ export interface TableViewDisplayContext<T = Record<string, unknown>> {
182
228
  options?: Record<string, unknown>;
183
229
  /** Re-fetch the table view (shared list + tab counts + active display). */
184
230
  refresh: () => Promise<void> | void;
231
+ /**
232
+ * Rows ordered by hand (backend `reorder`): set on the grid and the
233
+ * `cards` display, for a caller who may edit the rows.
234
+ */
235
+ reorder?: TableViewDisplayReorder;
185
236
 
186
237
  /**
187
238
  * Escape hatch: the TanStack table instance, present when the display is
@@ -231,6 +231,18 @@ describe("ConfirmModal fields", () => {
231
231
  expect(closed).toHaveBeenCalledWith(true);
232
232
  });
233
233
 
234
+ it("says what the required mark means under the fields", async () => {
235
+ mount({ title: "Delete?", fields: [reassign] });
236
+ await flush();
237
+ expect(host.textContent).toContain("dms.form.required_legend");
238
+ });
239
+
240
+ it("draws no legend when no field is required", async () => {
241
+ mount({ title: "Delete?", fields: [{ ...reassign, required: false }] });
242
+ await flush();
243
+ expect(host.textContent).not.toContain("dms.form.required_legend");
244
+ });
245
+
234
246
  it("flags a required field left empty instead of confirming", async () => {
235
247
  const onConfirm = vi.fn();
236
248
  mount({ title: "Delete?", fields: [reassign], onConfirm });
@@ -184,6 +184,7 @@ describe("the drawer container", () => {
184
184
  clearGuards: vi.fn(),
185
185
  }));
186
186
  vi.stubGlobal("useDmsRoute", () => reactive({ path: "/" }));
187
+ vi.stubGlobal("useI18n", () => ({ t: (key: string) => key }));
187
188
  });
188
189
 
189
190
  it("slides in from the bottom, full width, by default", async () => {