@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
@@ -1,8 +1,12 @@
1
1
  <script setup lang="ts">
2
- import type {
3
- FormSectionLayout,
4
- FormSectionState,
2
+ import { computed, useId } from "vue";
3
+ import {
4
+ type FormSectionLayout,
5
+ type FormSectionNavGroup,
6
+ type FormSectionState,
7
+ groupSectionsForNav,
5
8
  } from "../../composables/form/formSections";
9
+ import DmsEyebrow from "../../../components/section-header/Eyebrow.vue";
6
10
 
7
11
  type FormSectionNavVariant = "list" | "chips";
8
12
 
@@ -21,6 +25,18 @@ const emit = defineEmits<{ select: [sectionId: string] }>();
21
25
  const { t } = useI18n();
22
26
  const { processI18n } = useTranslation();
23
27
 
28
+ // The side list reads its sections in titled runs (`navGroup`); the row of
29
+ // chips has no room for headings and lists them all in one run.
30
+ const GROUPS_CLASSES: Record<FormSectionNavVariant, string> = {
31
+ list: "flex flex-col gap-4",
32
+ chips: "",
33
+ };
34
+
35
+ const GROUP_CLASSES: Record<FormSectionNavVariant, string> = {
36
+ list: "grid gap-1",
37
+ chips: "contents",
38
+ };
39
+
24
40
  const VARIANT_CLASSES: Record<FormSectionNavVariant, string> = {
25
41
  list: "flex flex-col gap-0.5",
26
42
  chips: "flex gap-1.5 overflow-x-auto pb-1",
@@ -37,6 +53,17 @@ const ACTIVE_CLASSES: Record<FormSectionNavVariant, string> = {
37
53
  chips: "border-accented bg-elevated text-highlighted",
38
54
  };
39
55
 
56
+ const groupIdPrefix = useId();
57
+ const groups = computed<FormSectionNavGroup[]>(() =>
58
+ props.variant === "list"
59
+ ? groupSectionsForNav(props.sections)
60
+ : [{ sections: props.sections }],
61
+ );
62
+
63
+ function groupHeadingId(index: number): string {
64
+ return `${groupIdPrefix}-group-${index}`;
65
+ }
66
+
40
67
  function stateLabel(state: FormSectionState | undefined): string | undefined {
41
68
  if (state?.invalidCount) {
42
69
  const count = state.invalidCount;
@@ -52,43 +79,63 @@ function onSelect(event: MouseEvent, sectionId: string): void {
52
79
  </script>
53
80
 
54
81
  <template>
55
- <nav :aria-label="t('dms.form.sections.nav')">
56
- <ul :class="VARIANT_CLASSES[props.variant]">
57
- <li v-for="section in props.sections" :key="section.id">
58
- <a
59
- :href="`#${props.anchorId(section.id)}`"
60
- :class="[
61
- ITEM_CLASSES[props.variant],
62
- section.id === props.activeId && ACTIVE_CLASSES[props.variant],
63
- ]"
64
- :aria-current="section.id === props.activeId ? 'location' : undefined"
65
- @click="onSelect($event, section.id)"
66
- >
67
- <UIcon
68
- v-if="section.icon && props.variant === 'list'"
69
- :name="section.icon"
70
- class="size-4 shrink-0"
71
- />
72
- <span class="truncate">{{ processI18n(section.label) }}</span>
73
- <!-- Read by its sr-only label below, not twice. -->
74
- <span
75
- v-if="props.states[section.id]?.invalidCount"
76
- class="text-error ms-auto inline-flex items-center gap-1 font-mono text-[11px]"
77
- aria-hidden="true"
82
+ <nav
83
+ :aria-label="t('dms.form.sections.nav')"
84
+ :class="GROUPS_CLASSES[props.variant]"
85
+ >
86
+ <div
87
+ v-for="(group, groupIndex) in groups"
88
+ :key="group.sections[0]?.id ?? groupIndex"
89
+ :class="GROUP_CLASSES[props.variant]"
90
+ >
91
+ <DmsEyebrow
92
+ v-if="group.label"
93
+ :id="groupHeadingId(groupIndex)"
94
+ :label="processI18n(group.label)"
95
+ class="px-2.5"
96
+ />
97
+ <ul
98
+ :class="VARIANT_CLASSES[props.variant]"
99
+ :aria-labelledby="group.label ? groupHeadingId(groupIndex) : undefined"
100
+ >
101
+ <li v-for="section in group.sections" :key="section.id">
102
+ <a
103
+ :href="`#${props.anchorId(section.id)}`"
104
+ :class="[
105
+ ITEM_CLASSES[props.variant],
106
+ section.id === props.activeId && ACTIVE_CLASSES[props.variant],
107
+ ]"
108
+ :aria-current="
109
+ section.id === props.activeId ? 'location' : undefined
110
+ "
111
+ @click="onSelect($event, section.id)"
78
112
  >
79
- <span class="bg-error size-1.5 rounded-full" aria-hidden="true" />
80
- {{ props.states[section.id]?.invalidCount }}
81
- </span>
82
- <span
83
- v-else-if="props.states[section.id]?.dirty"
84
- class="bg-warning ms-auto size-1.5 shrink-0 rounded-full"
85
- aria-hidden="true"
86
- />
87
- <span v-if="stateLabel(props.states[section.id])" class="sr-only">
88
- {{ stateLabel(props.states[section.id]) }}
89
- </span>
90
- </a>
91
- </li>
92
- </ul>
113
+ <UIcon
114
+ v-if="section.icon && props.variant === 'list'"
115
+ :name="section.icon"
116
+ class="size-4 shrink-0"
117
+ />
118
+ <span class="truncate">{{ processI18n(section.label) }}</span>
119
+ <!-- Read by its sr-only label below, not twice. -->
120
+ <span
121
+ v-if="props.states[section.id]?.invalidCount"
122
+ class="text-error ms-auto inline-flex items-center gap-1 font-mono text-[11px]"
123
+ aria-hidden="true"
124
+ >
125
+ <span class="bg-error size-1.5 rounded-full" aria-hidden="true" />
126
+ {{ props.states[section.id]?.invalidCount }}
127
+ </span>
128
+ <span
129
+ v-else-if="props.states[section.id]?.dirty"
130
+ class="bg-warning ms-auto size-1.5 shrink-0 rounded-full"
131
+ aria-hidden="true"
132
+ />
133
+ <span v-if="stateLabel(props.states[section.id])" class="sr-only">
134
+ {{ stateLabel(props.states[section.id]) }}
135
+ </span>
136
+ </a>
137
+ </li>
138
+ </ul>
139
+ </div>
93
140
  </nav>
94
141
  </template>
@@ -18,6 +18,8 @@ const props = withDefaults(defineProps<RowSkeletonProps>(), {
18
18
  linesClass: "space-y-1.5",
19
19
  isCentered: false,
20
20
  });
21
+
22
+ const { t } = useI18n();
21
23
  </script>
22
24
 
23
25
  <template>
@@ -26,9 +28,14 @@ const props = withDefaults(defineProps<RowSkeletonProps>(), {
26
28
  :class="props.isCentered ? 'items-center' : 'items-start'"
27
29
  aria-hidden="true"
28
30
  >
29
- <USkeleton class="shrink-0" :class="props.well" />
31
+ <USkeleton
32
+ :aria-label="t('dms.a11y.loading')"
33
+ class="shrink-0"
34
+ :class="props.well"
35
+ />
30
36
  <div class="min-w-0 flex-1" :class="props.linesClass">
31
37
  <USkeleton
38
+ :aria-label="t('dms.a11y.loading')"
32
39
  v-for="(line, index) in props.lines"
33
40
  :key="index"
34
41
  :class="line"
@@ -26,7 +26,10 @@ interface StatCellProps {
26
26
  /** Icon of the well; the cell has no well without one. */
27
27
  icon?: string;
28
28
  tone?: IconWellTone;
29
- /** One line under the value. */
29
+ /**
30
+ * The line under the value; a longer one wraps to a second line before its
31
+ * ellipsis, the row taking the height of its tallest cell.
32
+ */
30
33
  detail?: string;
31
34
  /** Tone of the detail line (`neutral` = muted text). */
32
35
  detailTone?: Tone;
@@ -91,6 +94,8 @@ const cellClass = computed(() => {
91
94
  ];
92
95
  });
93
96
  const detailClass = computed(() => DMS_TONE_TEXT[props.detailTone]);
97
+
98
+ const { t } = useI18n();
94
99
  </script>
95
100
 
96
101
  <template>
@@ -101,6 +106,7 @@ const detailClass = computed(() => DMS_TONE_TEXT[props.detailTone]);
101
106
  >
102
107
  <template v-if="isMd">
103
108
  <USkeleton
109
+ :aria-label="t('dms.a11y.loading')"
104
110
  v-if="props.placeholder || (props.loading && props.icon)"
105
111
  class="size-10 shrink-0 rounded-[10px]"
106
112
  />
@@ -113,9 +119,18 @@ const detailClass = computed(() => DMS_TONE_TEXT[props.detailTone]);
113
119
  <!-- A placeholder keeps the loaded lines' boxes (eyebrow 13px, value
114
120
  24px, detail 16px), so the card keeps its height when it lands. -->
115
121
  <div v-if="props.placeholder" class="grid flex-1 gap-[3px]">
116
- <USkeleton class="my-[1.5px] h-2.5 w-20" />
117
- <USkeleton class="my-px h-[22px] w-10" />
118
- <USkeleton class="my-0.5 h-3 w-24" />
122
+ <USkeleton
123
+ :aria-label="t('dms.a11y.loading')"
124
+ class="my-[1.5px] h-2.5 w-20"
125
+ />
126
+ <USkeleton
127
+ :aria-label="t('dms.a11y.loading')"
128
+ class="my-px h-[22px] w-10"
129
+ />
130
+ <USkeleton
131
+ :aria-label="t('dms.a11y.loading')"
132
+ class="my-0.5 h-3 w-24"
133
+ />
119
134
  </div>
120
135
  <div v-else class="grid min-w-0 flex-1 gap-[3px]">
121
136
  <DmsEyebrow
@@ -124,7 +139,11 @@ const detailClass = computed(() => DMS_TONE_TEXT[props.detailTone]);
124
139
  :class="wraps"
125
140
  :label="props.eyebrow"
126
141
  />
127
- <USkeleton v-if="props.loading" class="h-[22px] w-16" />
142
+ <USkeleton
143
+ :aria-label="t('dms.a11y.loading')"
144
+ v-if="props.loading"
145
+ class="h-[22px] w-16"
146
+ />
128
147
  <p
129
148
  v-else
130
149
  class="text-highlighted text-[22px] leading-[1.1] font-[650] tracking-[-0.035em] tabular-nums transition-opacity"
@@ -134,8 +153,8 @@ const detailClass = computed(() => DMS_TONE_TEXT[props.detailTone]);
134
153
  </p>
135
154
  <p
136
155
  v-if="props.detail && !props.loading"
137
- class="truncate text-xs"
138
- :class="[wraps, detailClass]"
156
+ class="line-clamp-2 text-xs [overflow-wrap:anywhere]"
157
+ :class="detailClass"
139
158
  >
140
159
  {{ props.detail }}
141
160
  </p>
@@ -145,6 +164,7 @@ const detailClass = computed(() => DMS_TONE_TEXT[props.detailTone]);
145
164
 
146
165
  <template v-else>
147
166
  <USkeleton
167
+ :aria-label="t('dms.a11y.loading')"
148
168
  v-if="props.placeholder || (props.loading && props.icon)"
149
169
  class="size-8 shrink-0 rounded-[9px]"
150
170
  />
@@ -157,13 +177,26 @@ const detailClass = computed(() => DMS_TONE_TEXT[props.detailTone]);
157
177
  <!-- Each placeholder line keeps the loaded line's box (eyebrow 11px,
158
178
  value 19px, detail 16px), so the group keeps its height. -->
159
179
  <div v-if="props.placeholder" class="grid flex-1 gap-0.5">
160
- <USkeleton class="my-px h-[9px] w-16" />
161
- <USkeleton class="my-[2.5px] h-3.5 w-24" />
162
- <USkeleton class="my-0.5 h-3 w-20" />
180
+ <USkeleton
181
+ :aria-label="t('dms.a11y.loading')"
182
+ class="my-px h-[9px] w-16"
183
+ />
184
+ <USkeleton
185
+ :aria-label="t('dms.a11y.loading')"
186
+ class="my-[2.5px] h-3.5 w-24"
187
+ />
188
+ <USkeleton
189
+ :aria-label="t('dms.a11y.loading')"
190
+ class="my-0.5 h-3 w-20"
191
+ />
163
192
  </div>
164
193
  <span v-else class="grid min-w-0 gap-0.5">
165
194
  <DmsEyebrow as="span" size="xs" :label="props.eyebrow" />
166
- <USkeleton v-if="props.loading" class="my-0.5 h-3.5 w-24" />
195
+ <USkeleton
196
+ :aria-label="t('dms.a11y.loading')"
197
+ v-if="props.loading"
198
+ class="my-0.5 h-3.5 w-24"
199
+ />
167
200
  <b
168
201
  v-else
169
202
  class="text-highlighted text-sm leading-[1.35] font-semibold whitespace-nowrap tabular-nums transition-opacity"
@@ -172,8 +205,16 @@ const detailClass = computed(() => DMS_TONE_TEXT[props.detailTone]);
172
205
  <slot name="value">{{ props.value }}</slot>
173
206
  </b>
174
207
  <template v-if="props.detail">
175
- <USkeleton v-if="props.loading" class="h-3 w-20" />
176
- <small v-else class="truncate text-xs" :class="detailClass">
208
+ <USkeleton
209
+ :aria-label="t('dms.a11y.loading')"
210
+ v-if="props.loading"
211
+ class="h-3 w-20"
212
+ />
213
+ <small
214
+ v-else
215
+ class="line-clamp-2 text-xs [overflow-wrap:anywhere]"
216
+ :class="detailClass"
217
+ >
177
218
  {{ props.detail }}
178
219
  </small>
179
220
  </template>
@@ -130,8 +130,14 @@ const uiTablePagination = computed(() => uiTablePaginationVariant());
130
130
  aria-hidden="true"
131
131
  :class="[uiTablePagination.root(), uiTablePagination.placeholderRoot()]"
132
132
  >
133
- <USkeleton :class="uiTablePagination.placeholderCount()" />
134
- <USkeleton :class="uiTablePagination.placeholderPager()" />
133
+ <USkeleton
134
+ :aria-label="t('dms.a11y.loading')"
135
+ :class="uiTablePagination.placeholderCount()"
136
+ />
137
+ <USkeleton
138
+ :aria-label="t('dms.a11y.loading')"
139
+ :class="uiTablePagination.placeholderPager()"
140
+ />
135
141
  </div>
136
142
  <nav
137
143
  v-else-if="rowCount"
@@ -183,6 +189,7 @@ const uiTablePagination = computed(() => uiTablePaginationVariant());
183
189
  </span>
184
190
  {{ summary.label }}
185
191
  <USkeleton
192
+ :aria-label="t('dms.a11y.loading')"
186
193
  v-if="summary.value === undefined"
187
194
  aria-hidden="true"
188
195
  :class="uiTablePagination.summaryPlaceholder()"
@@ -534,8 +534,12 @@ const theme = tv({
534
534
  handle: REORDER_HANDLE_CLASS,
535
535
  },
536
536
  variants: {
537
+ // A wrapped cell grows its row past the 44px band: it keeps the band's
538
+ // breathing room above and below its lines instead of touching the
539
+ // hairlines (one line still fits the band, so its row keeps its height).
537
540
  cellWrap: {
538
541
  true: {
542
+ rowCell: "py-2.5",
539
543
  rowSpan: "line-clamp-none whitespace-normal [overflow-wrap:anywhere]",
540
544
  },
541
545
  },
@@ -1249,6 +1253,7 @@ defineShortcuts({
1249
1253
  {{ caption }}
1250
1254
  </span>
1251
1255
  <USkeleton
1256
+ :aria-label="t('dms.a11y.loading')"
1252
1257
  v-if="isFirstPageLoading"
1253
1258
  aria-hidden="true"
1254
1259
  :class="uiTable.captionCountPlaceholder()"
@@ -1516,6 +1521,8 @@ defineShortcuts({
1516
1521
  pinned: getPinnedVariant(cell.column),
1517
1522
  loading,
1518
1523
  rowClickable: isRowClickable(row.original),
1524
+ cellWrap: (cell.column.columnDef as TableColumn<T>)
1525
+ .cellWrap,
1519
1526
  })
1520
1527
  "
1521
1528
  :style="{
@@ -1590,6 +1597,7 @@ defineShortcuts({
1590
1597
  :class="uiTable.rowCell()"
1591
1598
  >
1592
1599
  <USkeleton
1600
+ :aria-label="t('dms.a11y.loading')"
1593
1601
  v-if="column.columnDef.meta"
1594
1602
  :class="uiTable.skeletonCell()"
1595
1603
  :style="{ width: skeletonWidth(rowIndex, columnIndex) }"
@@ -196,6 +196,7 @@ const countFormat = computed(() => new Intl.NumberFormat(locale.value));
196
196
  {{ tab.label }}
197
197
  </span>
198
198
  <USkeleton
199
+ :aria-label="t('dms.a11y.loading')"
199
200
  v-if="tab.countPending"
200
201
  aria-hidden="true"
201
202
  :class="ui.countPlaceholder()"
@@ -243,6 +243,7 @@ const openTab = (id: string) => emit("open", id);
243
243
  />
244
244
  {{ view.label }}
245
245
  <USkeleton
246
+ :aria-label="t('dms.a11y.loading')"
246
247
  v-if="view.countPending"
247
248
  aria-hidden="true"
248
249
  class="h-3 w-4 rounded-[3px]"
@@ -21,6 +21,8 @@ const MAX_CARD_COUNT = 12;
21
21
  const cardCount = computed(() =>
22
22
  Math.max(1, Math.min(props.count, MAX_CARD_COUNT)),
23
23
  );
24
+
25
+ const { t } = useI18n();
24
26
  </script>
25
27
 
26
28
  <template>
@@ -34,13 +36,22 @@ const cardCount = computed(() =>
34
36
  class="border-default @container rounded-[10px] border bg-(--ui-bg) p-3.5 text-[12.5px]"
35
37
  >
36
38
  <div class="flex items-center gap-2.5">
37
- <USkeleton class="size-7 shrink-0 rounded-[7px]" />
39
+ <USkeleton
40
+ :aria-label="t('dms.a11y.loading')"
41
+ class="size-7 shrink-0 rounded-[7px]"
42
+ />
38
43
  <div class="min-w-0 flex-1">
39
44
  <div class="text-[13px]">
40
- <USkeleton class="inline-block h-3 w-3/5 align-middle" />
45
+ <USkeleton
46
+ :aria-label="t('dms.a11y.loading')"
47
+ class="inline-block h-3 w-3/5 align-middle"
48
+ />
41
49
  </div>
42
50
  <div class="font-mono text-[11px]">
43
- <USkeleton class="inline-block h-2 w-2/5 align-middle" />
51
+ <USkeleton
52
+ :aria-label="t('dms.a11y.loading')"
53
+ class="inline-block h-2 w-2/5 align-middle"
54
+ />
44
55
  </div>
45
56
  </div>
46
57
  </div>
@@ -49,10 +60,16 @@ const cardCount = computed(() =>
49
60
  >
50
61
  <div v-for="field in props.fields" :key="field" class="min-w-0">
51
62
  <div :class="['font-mono', props.labelClass]">
52
- <USkeleton class="inline-block h-2 w-1/2 align-middle" />
63
+ <USkeleton
64
+ :aria-label="t('dms.a11y.loading')"
65
+ class="inline-block h-2 w-1/2 align-middle"
66
+ />
53
67
  </div>
54
68
  <div class="mt-0.5">
55
- <USkeleton class="inline-block h-2.5 w-4/5 align-middle" />
69
+ <USkeleton
70
+ :aria-label="t('dms.a11y.loading')"
71
+ class="inline-block h-2.5 w-4/5 align-middle"
72
+ />
56
73
  </div>
57
74
  </div>
58
75
  </div>
@@ -96,6 +96,7 @@ const FieldValue = (fieldProps: { item: KeyValueItem }) =>
96
96
  aria-busy="true"
97
97
  >
98
98
  <USkeleton
99
+ :aria-label="t('dms.a11y.loading')"
99
100
  v-for="line in SKELETON_LINE_COUNT"
100
101
  :key="line"
101
102
  class="h-4 w-full max-w-md"
@@ -386,6 +386,8 @@ interface IdentityBadgeOption {
386
386
  interface IdentityOptions {
387
387
  avatarField?: string;
388
388
  icon?: string;
389
+ /** Row field holding the tile's icon; `icon` when it is empty. */
390
+ iconField?: string;
389
391
  subtitleField?: string;
390
392
  subtitleTone?: string;
391
393
  selfField?: string;
@@ -431,7 +433,7 @@ function renderIdentity(value: unknown, options: unknown, row: Row) {
431
433
  | { key: string }
432
434
  | string
433
435
  | null,
434
- icon: opts.icon,
436
+ icon: stringOf(readRowField(row, opts.iconField)) ?? opts.icon,
435
437
  selfId: stringOf(readRowField(row, opts.selfField)),
436
438
  selfLabel: opts.selfLabel ? processI18n(opts.selfLabel) : undefined,
437
439
  badges,
@@ -51,8 +51,9 @@ export interface SaveBarState {
51
51
  /** The submit shows but waits for a change. */
52
52
  isSubmitHeld: boolean;
53
53
  /**
54
- * Nothing to offer: the bar keeps its place, invisible, so its showing up
55
- * never moves the content around it.
54
+ * Nothing to offer: the bar hides. The floating bar takes no space then
55
+ * (it overlays the form), a band keeps its place; either way its showing
56
+ * up never moves the content above it.
56
57
  */
57
58
  isHidden: boolean;
58
59
  }
@@ -21,6 +21,31 @@ export interface FormSectionsLayout {
21
21
  sections: FormSectionLayout[];
22
22
  }
23
23
 
24
+ /** A run of the side navigation's entries under one heading, or none. */
25
+ export interface FormSectionNavGroup {
26
+ label?: string;
27
+ sections: FormSectionLayout[];
28
+ }
29
+
30
+ /**
31
+ * The side navigation's entries, in order, cut wherever the heading changes:
32
+ * consecutive sections naming the same `navGroup` share one heading.
33
+ */
34
+ export function groupSectionsForNav(
35
+ sections: readonly FormSectionLayout[],
36
+ ): FormSectionNavGroup[] {
37
+ const groups: FormSectionNavGroup[] = [];
38
+ for (const section of sections) {
39
+ const current = groups.at(-1);
40
+ if (current && current.label === section.navGroup) {
41
+ current.sections.push(section);
42
+ } else {
43
+ groups.push({ label: section.navGroup, sections: [section] });
44
+ }
45
+ }
46
+ return groups;
47
+ }
48
+
24
49
  /** An error of the form, as UForm lists them: the name it went under. */
25
50
  export interface FormErrorRef {
26
51
  name?: string;
@@ -652,6 +652,25 @@ export const useTableRowActions = <T extends Data>(
652
652
  };
653
653
  };
654
654
 
655
+ const findShownRow = (id: string | undefined): Data | undefined =>
656
+ config.rowNavigation
657
+ ?.rows()
658
+ .find(
659
+ (row) =>
660
+ String(getItemId(row, config.rowIdKey ?? DEFAULT_ROW_ID_KEY)) === id,
661
+ );
662
+
663
+ /**
664
+ * What a fixed dialog words: the rows' count, and for one row shown in the
665
+ * table its own fields too (`{name}`). The count wins a clash, it is what
666
+ * every bulk dialog may name.
667
+ */
668
+ const confirmRowFields = (ids: string[]): Data => {
669
+ const count = { count: ids.length };
670
+ const row = ids.length === 1 ? findShownRow(ids[0]) : undefined;
671
+ return row ? { ...row, ...count } : count;
672
+ };
673
+
655
674
  /** Asks the declared confirmation of a bulk action, running it inside. */
656
675
  const askDeclaredBulkConfirm = (
657
676
  declared: ActionConfirm,
@@ -664,8 +683,7 @@ export const useTableRowActions = <T extends Data>(
664
683
  id: ids[0],
665
684
  [config.rowIdKey ?? DEFAULT_ROW_ID_KEY]: ids[0],
666
685
  },
667
- // A fixed dialog names the rows it reaches.
668
- row: { count: ids.length },
686
+ row: confirmRowFields(ids),
669
687
  fallback,
670
688
  run,
671
689
  });
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The counter a text field shows under `showCount`: the characters typed,
3
+ * out of `maxLength` when the field has one ("4 / 60").
4
+ */
5
+ export function characterCountText(value: unknown, maxLength?: number): string {
6
+ const count = typeof value === "string" ? [...value].length : 0;
7
+ return maxLength === undefined ? String(count) : `${count} / ${maxLength}`;
8
+ }
@@ -213,7 +213,10 @@ const wrapperProps = computed(() =>
213
213
  v-if="props.groupByDay"
214
214
  class="flex items-center gap-2.5 px-[18px] pt-3.5 pb-1.5 after:h-px after:flex-1 after:bg-(--ui-border-muted)"
215
215
  >
216
- <USkeleton class="my-[1.5px] h-2.5 w-24" />
216
+ <USkeleton
217
+ :aria-label="t('dms.a11y.loading')"
218
+ class="my-[1.5px] h-2.5 w-24"
219
+ />
217
220
  </div>
218
221
  <div class="divide-y divide-(--ui-border-muted)">
219
222
  <DmsRowSkeleton
@@ -67,6 +67,8 @@ const showSkeleton = computed(() => isLoading.value && !data.value);
67
67
  const hasError = computed(
68
68
  () => !isLoading.value && !data.value && Boolean(error.value),
69
69
  );
70
+
71
+ const { t } = useI18n();
70
72
  </script>
71
73
 
72
74
  <template>
@@ -77,6 +79,7 @@ const hasError = computed(
77
79
  aria-busy="true"
78
80
  >
79
81
  <USkeleton
82
+ :aria-label="t('dms.a11y.loading')"
80
83
  v-for="width in SKELETON_LINE_WIDTHS"
81
84
  :key="width"
82
85
  class="h-3"
@@ -146,6 +146,8 @@ const hasStates = computed(() => cards.value.some((card) => !!card.state));
146
146
  const isLeftOut = computed(
147
147
  () => !!props.categoryId && !props.empty && cards.value.length === 0,
148
148
  );
149
+
150
+ const { t } = useI18n();
149
151
  </script>
150
152
 
151
153
  <template>
@@ -180,14 +182,20 @@ const isLeftOut = computed(
180
182
  class="dms-card flex flex-col gap-2.5 p-[18px]"
181
183
  >
182
184
  <div class="flex items-center gap-3">
183
- <USkeleton class="size-9 rounded-[10px]" />
184
- <USkeleton class="h-3.5 w-28" />
185
+ <USkeleton
186
+ :aria-label="t('dms.a11y.loading')"
187
+ class="size-9 rounded-[10px]"
188
+ />
189
+ <USkeleton :aria-label="t('dms.a11y.loading')" class="h-3.5 w-28" />
185
190
  </div>
186
191
  <div class="grid h-[39px] content-center gap-[7.5px]">
187
- <USkeleton class="h-3 w-4/5" />
188
- <USkeleton class="h-3 w-3/5" />
192
+ <USkeleton :aria-label="t('dms.a11y.loading')" class="h-3 w-4/5" />
193
+ <USkeleton :aria-label="t('dms.a11y.loading')" class="h-3 w-3/5" />
189
194
  </div>
190
- <USkeleton class="my-[2.5px] h-3 w-28" />
195
+ <USkeleton
196
+ :aria-label="t('dms.a11y.loading')"
197
+ class="my-[2.5px] h-3 w-28"
198
+ />
191
199
  </div>
192
200
  </template>
193
201
  <template v-else>
@@ -61,7 +61,10 @@ const attrs = useAttrs();
61
61
  const theme = tv({
62
62
  slots: {
63
63
  root: "dms-card dms-card--interactive group flex flex-col gap-3 p-[18px] text-start",
64
- title: "text-highlighted min-w-0 truncate text-sm font-[650]",
64
+ // Two lines before the ellipsis: a title cut on its first line no longer
65
+ // says where the card leads, and the card already grows with its text.
66
+ title:
67
+ "text-highlighted line-clamp-2 min-w-0 text-sm font-[650] [overflow-wrap:anywhere]",
65
68
  tag: `${MONO_TAG_CLASS} bg-elevated shrink-0 text-dimmed`,
66
69
  badge: "shrink-0",
67
70
  arrow:
@@ -78,6 +81,8 @@ const passthrough = computed(() => {
78
81
  const { class: _class, ...rest } = attrs;
79
82
  return rest;
80
83
  });
84
+
85
+ const { t } = useI18n();
81
86
  </script>
82
87
 
83
88
  <template>
@@ -110,6 +115,7 @@ const passthrough = computed(() => {
110
115
  </div>
111
116
 
112
117
  <USkeleton
118
+ :aria-label="t('dms.a11y.loading')"
113
119
  v-if="props.statePending && !props.state"
114
120
  aria-hidden="true"
115
121
  class="mt-auto h-[18px] w-28 self-start"