@antelopejs/dms 0.7.5 → 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 (97) hide show
  1. package/CHANGELOG.md +21 -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/request-tenant-guards.test.js +38 -0
  23. package/dist/test/unit/request-tenant-guards.test.js.map +1 -1
  24. package/frontend-vue/layers/dms-auth/app/custom-pages/auth/accounts.vue +9 -3
  25. package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +6 -0
  26. package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +6 -0
  27. package/frontend-vue/layers/dms-layout/app/build/components/layout/CommandPaletteAssistantAnswer.vue +1 -0
  28. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardHeader.vue +2 -0
  29. package/frontend-vue/layers/dms-layout/app/build/components/layout/Footer.vue +1 -0
  30. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageSkeleton.vue +13 -4
  31. package/frontend-vue/layers/dms-layout/app/build/components/layout/RolePreviewBar.vue +9 -3
  32. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/members/MemberRolePicker.vue +5 -1
  33. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/profile/ProfilePersonalInfo.vue +1 -0
  34. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/profile/ProfileSummaryRow.vue +4 -0
  35. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/roles/RoleEditor.vue +1 -1
  36. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityEmail.vue +4 -1
  37. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityPassword.vue +4 -1
  38. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecuritySessions.vue +1 -0
  39. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityTwoFactor.vue +19 -4
  40. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/theme/AppearanceDeveloper.vue +1 -0
  41. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/theme/CssVariablesList.vue +6 -3
  42. package/frontend-vue/layers/dms-layout/app/custom-pages/modules.vue +20 -5
  43. package/frontend-vue/layers/dms-ui/app/build/components/chart/internal/ChartSkeleton.vue +4 -0
  44. package/frontend-vue/layers/dms-ui/app/build/components/containers/drawer/DynamicDrawer.vue +10 -2
  45. package/frontend-vue/layers/dms-ui/app/build/components/containers/modal/DynamicModal.vue +7 -2
  46. package/frontend-vue/layers/dms-ui/app/build/components/form/FormSectionNav.vue +87 -40
  47. package/frontend-vue/layers/dms-ui/app/build/components/skeleton/RowSkeleton.vue +8 -1
  48. package/frontend-vue/layers/dms-ui/app/build/components/stat/StatCell.vue +54 -13
  49. package/frontend-vue/layers/dms-ui/app/build/components/table/Pagination.vue +9 -2
  50. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +8 -0
  51. package/frontend-vue/layers/dms-ui/app/build/components/table/Tabs.vue +1 -0
  52. package/frontend-vue/layers/dms-ui/app/build/components/table/Views.vue +1 -0
  53. package/frontend-vue/layers/dms-ui/app/build/components/table-view/CardGridSkeleton.vue +22 -5
  54. package/frontend-vue/layers/dms-ui/app/build/components/table-view/ExpandedRowDetail.vue +1 -0
  55. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +3 -1
  56. package/frontend-vue/layers/dms-ui/app/build/composables/form/formFooter.ts +3 -2
  57. package/frontend-vue/layers/dms-ui/app/build/composables/form/formSections.ts +25 -0
  58. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +20 -2
  59. package/frontend-vue/layers/dms-ui/app/build/utils/characterCount.ts +8 -0
  60. package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +4 -1
  61. package/frontend-vue/layers/dms-ui/app/components/blocks/CodeBlock.vue +3 -0
  62. package/frontend-vue/layers/dms-ui/app/components/blocks/NavCardGridBlock.vue +13 -5
  63. package/frontend-vue/layers/dms-ui/app/components/card/NavCard.vue +7 -1
  64. package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +10 -3
  65. package/frontend-vue/layers/dms-ui/app/components/confirm/ConfirmModal.vue +8 -0
  66. package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +4 -3
  67. package/frontend-vue/layers/dms-ui/app/components/form/components/Cascader.vue +6 -1
  68. package/frontend-vue/layers/dms-ui/app/components/form/components/Display.vue +5 -1
  69. package/frontend-vue/layers/dms-ui/app/components/form/components/InputNumber.vue +27 -3
  70. package/frontend-vue/layers/dms-ui/app/components/form/components/InputText.vue +24 -2
  71. package/frontend-vue/layers/dms-ui/app/components/form/components/RichText.vue +13 -4
  72. package/frontend-vue/layers/dms-ui/app/components/form/components/Textarea.vue +27 -2
  73. package/frontend-vue/layers/dms-ui/app/components/key-value-list/KeyValueList.vue +8 -4
  74. package/frontend-vue/layers/dms-ui/app/components/kpi/KpiCard.vue +12 -4
  75. package/frontend-vue/layers/dms-ui/app/components/meter/MeterBlock.vue +15 -4
  76. package/frontend-vue/layers/dms-ui/app/components/save-bar/SaveBar.vue +69 -42
  77. package/frontend-vue/layers/dms-ui/app/components/tab/Tab.vue +15 -1
  78. package/frontend-vue/layers/dms-ui/app/components/table-view/KanbanBoard.vue +13 -3
  79. package/frontend-vue/layers/dms-ui/app/components/top-list/TopListCard.vue +45 -9
  80. package/frontend-vue/layers/dms-ui/app/composables/form/types/field.ts +2 -0
  81. package/frontend-vue/layers/dms-ui/app/composables/tab/types/props.ts +5 -0
  82. package/frontend-vue/tests/confirm-dialog-fields.test.ts +12 -0
  83. package/frontend-vue/tests/drawer-target-direction.test.ts +1 -0
  84. package/frontend-vue/tests/field-suffix-count.test.ts +120 -0
  85. package/frontend-vue/tests/form-section-nav-groups.test.ts +116 -0
  86. package/frontend-vue/tests/nav-card-badge.test.ts +5 -1
  87. package/frontend-vue/tests/nuxt-ui-aria-labels.test.ts +58 -0
  88. package/frontend-vue/tests/page-fill-height.test.ts +2 -2
  89. package/frontend-vue/tests/save-bar.test.ts +49 -0
  90. package/frontend-vue/tests/stat-group-detail-lines.test.ts +63 -0
  91. package/frontend-vue/tests/tab-fit.test.ts +99 -0
  92. package/frontend-vue/tests/table-cell-sublines.test.ts +15 -0
  93. package/frontend-vue/tests/table-cell-wrap.test.ts +24 -2
  94. package/frontend-vue/tests/table-row-action-errors.test.ts +26 -0
  95. package/frontend-vue/tests/top-list-card-actions.test.ts +91 -0
  96. package/package.json +2 -2
  97. package/skills/dms/SKILL.md +11 -1
@@ -178,7 +178,11 @@ const hasMeta = computed(
178
178
  <div
179
179
  class="text-highlighted mt-2 text-[30px] leading-[1.1] font-[650] tracking-[-0.035em] tabular-nums"
180
180
  >
181
- <USkeleton v-if="isFirstLoad" class="h-[33px] w-36" />
181
+ <USkeleton
182
+ :aria-label="t('dms.a11y.loading')"
183
+ v-if="isFirstLoad"
184
+ class="h-[33px] w-36"
185
+ />
182
186
  <span
183
187
  v-else
184
188
  class="transition-opacity"
@@ -193,8 +197,11 @@ const hasMeta = computed(
193
197
  class="mt-1.5 flex h-5 items-center gap-2"
194
198
  aria-hidden="true"
195
199
  >
196
- <USkeleton class="h-4 w-14 rounded-full" />
197
- <USkeleton class="h-3 w-20" />
200
+ <USkeleton
201
+ :aria-label="t('dms.a11y.loading')"
202
+ class="h-4 w-14 rounded-full"
203
+ />
204
+ <USkeleton :aria-label="t('dms.a11y.loading')" class="h-3 w-20" />
198
205
  </div>
199
206
  <div
200
207
  v-else-if="hasMeta"
@@ -3,6 +3,7 @@ import { nextTick, useTemplateRef, watch, type FunctionalComponent } from "vue";
3
3
  import { DialogDescription, DialogTitle } from "reka-ui";
4
4
  import DmsIconWell from "../icon-well/IconWell.vue";
5
5
  import DmsModalEscHint from "../../build/components/modal/ModalEscHint.vue";
6
+ import DmsFormRequiredLegend from "../../build/components/form/FormRequiredLegend.vue";
6
7
  import {
7
8
  ConfirmActionError,
8
9
  type ConfirmBodyRender,
@@ -174,6 +175,12 @@ const headerIcon = computed(() =>
174
175
  : (props.icon ?? DEFAULT_ICONS[props.color] ?? FALLBACK_ICON),
175
176
  );
176
177
 
178
+ // A field marked required says what its mark means, as a form does under
179
+ // its fields.
180
+ const hasRequiredFields = computed(() =>
181
+ props.fields.some((field) => field.required),
182
+ );
183
+
177
184
  const hasBody = computed(
178
185
  () =>
179
186
  props.impact.length > 0 ||
@@ -398,6 +405,7 @@ function handleCancel() {
398
405
  </template>
399
406
  </template>
400
407
  </UFormField>
408
+ <DmsFormRequiredLegend v-if="hasRequiredFields" />
401
409
 
402
410
  <component
403
411
  :is="bodyComponent"
@@ -954,8 +954,9 @@ onUnmounted(async () => {
954
954
  </div>
955
955
 
956
956
  <!-- One bar for every form (see `saveBarState`): the v2 floating bar
957
- on a page, the footer band of a card, a drawer or a modal. It holds
958
- its place while hidden, so its showing up never moves anything. -->
957
+ on a page, the footer band of a card, a drawer or a modal. The
958
+ floating bar overlays the form and takes no space while hidden; the
959
+ band holds its place. Neither moves anything when it shows. -->
959
960
  <DmsSaveBar
960
961
  v-if="canSave && !isInstant && !loadFailure"
961
962
  :variant="usesSaveBar ? 'floating' : 'band'"
@@ -967,7 +968,7 @@ onUnmounted(async () => {
967
968
  :save-label="usesSaveBar ? props.submitLabel : submitButtonLabel"
968
969
  :cancellable="canCancel"
969
970
  :resettable="hasChangeableFields"
970
- :class="usesSaveBar ? 'mx-3 mb-3' : surfaceClasses.foot"
971
+ :class="usesSaveBar ? 'mx-3' : surfaceClasses.foot"
971
972
  @discard="discardChanges"
972
973
  @cancel="cancelForm"
973
974
  />
@@ -402,7 +402,12 @@ function nodeClasses(node: CascaderNode, isHighlighted: boolean): string[] {
402
402
  class="w-52 space-y-2 p-2"
403
403
  aria-busy="true"
404
404
  >
405
- <USkeleton v-for="i in 5" :key="i" class="h-6 w-full" />
405
+ <USkeleton
406
+ :aria-label="t('dms.a11y.loading')"
407
+ v-for="i in 5"
408
+ :key="i"
409
+ class="h-6 w-full"
410
+ />
406
411
  </div>
407
412
 
408
413
  <div
@@ -40,7 +40,11 @@ const isVNodeValue = computed(() => isVNode(formattedValue.value));
40
40
  </script>
41
41
 
42
42
  <template>
43
- <USkeleton v-if="loading" class="h-9 w-full" />
43
+ <USkeleton
44
+ :aria-label="t('dms.a11y.loading')"
45
+ v-if="loading"
46
+ class="h-9 w-full"
47
+ />
44
48
 
45
49
  <Component
46
50
  :is="dedicatedComponent"
@@ -4,16 +4,27 @@ import type {
4
4
  InputNumberEmits,
5
5
  InputNumberSlots,
6
6
  } from "@nuxt/ui/components/InputNumber.vue";
7
+ import { computed } from "vue";
7
8
  import { useForwardPropsEmits } from "reka-ui";
8
9
  import { useControlError } from "../../../build/composables/form/useControlError";
9
10
 
10
11
  defineOptions({ inheritAttrs: false });
11
12
 
12
- const props = defineProps<InputNumberProps>();
13
+ interface InputNumberFieldProps extends InputNumberProps {
14
+ /** Unit after the field ("days"): an i18n key with `$`, or a literal. */
15
+ suffix?: string;
16
+ }
17
+
18
+ const props = defineProps<InputNumberFieldProps>();
13
19
  const emits = defineEmits<InputNumberEmits>();
14
20
  defineSlots<InputNumberSlots>();
15
21
 
16
22
  const forwarded = useForwardPropsEmits(props, emits);
23
+ const inputProps = computed(() => {
24
+ const { suffix: _suffix, ...rest } = forwarded.value;
25
+ return rest;
26
+ });
27
+ const { processI18n } = useTranslation();
17
28
 
18
29
  // v2 number fields keep the value left-aligned with a compact caret stepper.
19
30
  const DEFAULT_ORIENTATION = "vertical";
@@ -33,12 +44,25 @@ function onIncomingText(event: InputEvent): void {
33
44
  </script>
34
45
 
35
46
  <template>
36
- <div class="contents" @beforeinput.capture="onIncomingText">
47
+ <!-- The unit sits after the field rather than inside it, where the
48
+ stepper already takes the end of the box. -->
49
+ <div
50
+ :class="props.suffix ? 'flex items-center gap-2' : 'contents'"
51
+ @beforeinput.capture="onIncomingText"
52
+ >
37
53
  <UInputNumber
38
- v-bind="{ ...forwarded, ...$attrs }"
54
+ v-bind="{ ...inputProps, ...$attrs }"
55
+ :class="props.suffix && 'min-w-0 flex-1'"
39
56
  :orientation="props.orientation ?? DEFAULT_ORIENTATION"
40
57
  :increment="{ color: 'neutral' }"
41
58
  :decrement="{ color: 'neutral' }"
42
59
  />
60
+ <span
61
+ v-if="props.suffix"
62
+ class="text-muted shrink-0 text-[13px]"
63
+ data-field-suffix
64
+ >
65
+ {{ processI18n(props.suffix) }}
66
+ </span>
43
67
  </div>
44
68
  </template>
@@ -4,15 +4,37 @@ import type {
4
4
  InputEmits,
5
5
  InputSlots,
6
6
  } from "@nuxt/ui/components/Input.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<InputProps>();
11
+ interface InputTextProps extends InputProps {
12
+ /** Longest value accepted; the counter reads against it. */
13
+ maxLength?: number;
14
+ /** Shows the characters typed at the end of the field ("4 / 60"). */
15
+ showCount?: boolean;
16
+ }
17
+
18
+ const props = defineProps<InputTextProps>();
10
19
  const emits = defineEmits<InputEmits>();
11
20
  defineSlots<InputSlots>();
12
21
 
13
22
  const forwarded = useForwardPropsEmits(props, emits);
23
+ const inputProps = 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
- <UInput v-bind="forwarded" />
33
+ <UInput v-bind="inputProps">
34
+ <template v-if="props.showCount" #trailing>
35
+ <span class="text-dimmed text-xs tabular-nums" data-character-count>
36
+ {{ countText }}
37
+ </span>
38
+ </template>
39
+ </UInput>
18
40
  </template>
@@ -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>