@antelopejs/dms 0.7.5 → 0.7.7

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 +33 -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 +103 -41
  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 +177 -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 +12 -1
@@ -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 navigation (side list or chips) 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. */
@@ -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 () => {
@@ -0,0 +1,120 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * A number field writes its unit after the box ("30 days"), and a text field
4
+ * or a text area asked for it counts the characters typed, out of its
5
+ * `maxLength` ("4 / 60"). Without either option, the control is unchanged.
6
+ */
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8
+ import {
9
+ type App,
10
+ type Component,
11
+ createApp,
12
+ defineComponent,
13
+ h,
14
+ nextTick,
15
+ } from "vue";
16
+ import { characterCountText } from "../layers/dms-ui/app/build/utils/characterCount";
17
+
18
+ let app: App | undefined;
19
+ let host: HTMLDivElement;
20
+
21
+ const Control = (tag: string) =>
22
+ defineComponent({
23
+ inheritAttrs: false,
24
+ setup:
25
+ (_, { slots, attrs }) =>
26
+ () =>
27
+ h("div", { "data-control": tag }, [
28
+ h(tag, { ...attrs }),
29
+ slots.trailing?.(),
30
+ ]),
31
+ });
32
+
33
+ async function mount(component: Component, props: Record<string, unknown>) {
34
+ app = createApp({ render: () => h(component, props) });
35
+ app.component("UInput", Control("input"));
36
+ app.component("UTextarea", Control("textarea"));
37
+ app.component("UInputNumber", Control("input"));
38
+ app.mount(host);
39
+ await nextTick();
40
+ }
41
+
42
+ const load = async (name: string) =>
43
+ (await import(`../layers/dms-ui/app/components/form/components/${name}.vue`))
44
+ .default as Component;
45
+
46
+ beforeEach(() => {
47
+ vi.stubGlobal("useTranslation", () => ({
48
+ processI18n: (text: string) => (text.startsWith("$") ? `t(${text})` : text),
49
+ }));
50
+ host = document.createElement("div");
51
+ document.body.append(host);
52
+ });
53
+
54
+ afterEach(() => {
55
+ app?.unmount();
56
+ host.remove();
57
+ vi.unstubAllGlobals();
58
+ });
59
+
60
+ describe("characterCountText", () => {
61
+ it("counts out of the limit, or alone", () => {
62
+ expect(characterCountText("Acme", 60)).toBe("4 / 60");
63
+ expect(characterCountText("Acme")).toBe("4");
64
+ expect(characterCountText(undefined, 60)).toBe("0 / 60");
65
+ expect(characterCountText("👋🏽", 10)).toBe("2 / 10");
66
+ });
67
+ });
68
+
69
+ describe("a text field's counter", () => {
70
+ it("shows the count in the field when asked", async () => {
71
+ await mount(await load("InputText"), {
72
+ modelValue: "Acme",
73
+ maxLength: 60,
74
+ showCount: true,
75
+ });
76
+ expect(
77
+ host.querySelector("[data-character-count]")?.textContent?.trim(),
78
+ ).toBe("4 / 60");
79
+ const input = host.querySelector("input")!;
80
+ expect(input.getAttribute("maxlength")).toBe("60");
81
+ expect(input.hasAttribute("showcount")).toBe(false);
82
+ });
83
+
84
+ it("draws no counter by default", async () => {
85
+ await mount(await load("InputText"), { modelValue: "Acme", maxLength: 60 });
86
+ expect(host.querySelector("[data-character-count]")).toBeNull();
87
+ expect(host.querySelector("input")!.getAttribute("maxlength")).toBe("60");
88
+ });
89
+
90
+ it("counts under a text area", async () => {
91
+ await mount(await load("Textarea"), {
92
+ modelValue: "Hello",
93
+ maxLength: 280,
94
+ showCount: true,
95
+ });
96
+ expect(
97
+ host.querySelector("[data-character-count]")?.textContent?.trim(),
98
+ ).toBe("5 / 280");
99
+ expect(host.querySelector("textarea")!.hasAttribute("showcount")).toBe(
100
+ false,
101
+ );
102
+ });
103
+ });
104
+
105
+ describe("a number field's unit", () => {
106
+ it("writes the unit after the field, translated", async () => {
107
+ await mount(await load("InputNumber"), {
108
+ modelValue: 30,
109
+ suffix: "$billing.days",
110
+ });
111
+ const suffix = host.querySelector("[data-field-suffix]");
112
+ expect(suffix?.textContent?.trim()).toBe("t($billing.days)");
113
+ expect(host.querySelector("input")!.hasAttribute("suffix")).toBe(false);
114
+ });
115
+
116
+ it("draws no unit by default", async () => {
117
+ await mount(await load("InputNumber"), { modelValue: 30 });
118
+ expect(host.querySelector("[data-field-suffix]")).toBeNull();
119
+ });
120
+ });
@@ -0,0 +1,177 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * A long sectioned form groups its navigation under headings: the sections
4
+ * naming the same `navGroup` one after the other share one, in the side list
5
+ * and in the row of chips alike. Without a group, the chips stay one row.
6
+ */
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8
+ import { type App, createApp, defineComponent, h, nextTick } from "vue";
9
+ import {
10
+ groupSectionsForNav,
11
+ layoutSections,
12
+ } from "../layers/dms-ui/app/build/composables/form/formSections";
13
+ import FormSectionNav from "../layers/dms-ui/app/build/components/form/FormSectionNav.vue";
14
+
15
+ const field = (id: string) => ({
16
+ id,
17
+ component: { componentName: "dms-input-text" },
18
+ });
19
+
20
+ const SECTIONS = [
21
+ { id: "plan", label: "Plan", fieldIds: ["plan"] },
22
+ {
23
+ id: "dunning",
24
+ label: "Dunning",
25
+ navGroup: "$billing",
26
+ fieldIds: ["dunning"],
27
+ },
28
+ { id: "tax", label: "Tax", navGroup: "$billing", fieldIds: ["tax"] },
29
+ { id: "terms", label: "Terms", navGroup: "Legal", fieldIds: ["terms"] },
30
+ ];
31
+
32
+ const layout = layoutSections(
33
+ ["plan", "dunning", "tax", "terms"].map(field),
34
+ SECTIONS,
35
+ );
36
+
37
+ let app: App | undefined;
38
+ let host: HTMLDivElement;
39
+
40
+ const plainLayout = layoutSections(
41
+ [field("a"), field("b")],
42
+ [
43
+ { id: "a", label: "A", fieldIds: ["a"] },
44
+ { id: "b", label: "B", fieldIds: ["b"] },
45
+ ],
46
+ );
47
+
48
+ type NavProps = Record<string, unknown>;
49
+
50
+ async function mount(variant: "list" | "chips", props: NavProps = {}) {
51
+ app = createApp({
52
+ render: () =>
53
+ h(FormSectionNav, {
54
+ variant,
55
+ sections: layout.sections,
56
+ states: {},
57
+ anchorId: (id: string) => `form-${id}`,
58
+ ...props,
59
+ }),
60
+ });
61
+ app.component("UIcon", defineComponent({ render: () => h("i") }));
62
+ app.mount(host);
63
+ await nextTick();
64
+ }
65
+
66
+ beforeEach(() => {
67
+ vi.stubGlobal("useI18n", () => ({ t: (key: string) => key }));
68
+ vi.stubGlobal("useTranslation", () => ({
69
+ processI18n: (text: string) => (text.startsWith("$") ? `t(${text})` : text),
70
+ }));
71
+ host = document.createElement("div");
72
+ document.body.append(host);
73
+ });
74
+
75
+ afterEach(() => {
76
+ app?.unmount();
77
+ host.remove();
78
+ vi.unstubAllGlobals();
79
+ });
80
+
81
+ describe("groupSectionsForNav", () => {
82
+ it("cuts the sections wherever their heading changes", () => {
83
+ expect(
84
+ groupSectionsForNav(layout.sections).map((group) => [
85
+ group.label,
86
+ group.sections.map((section) => section.id),
87
+ ]),
88
+ ).toEqual([
89
+ [undefined, ["plan"]],
90
+ ["$billing", ["dunning", "tax"]],
91
+ ["Legal", ["terms"]],
92
+ ]);
93
+ });
94
+
95
+ it("keeps a form without groups in one run", () => {
96
+ expect(groupSectionsForNav(plainLayout.sections)).toHaveLength(1);
97
+ });
98
+ });
99
+
100
+ describe("the side navigation", () => {
101
+ it("titles each run, and names its list by it", async () => {
102
+ await mount("list");
103
+ const lists = [...host.querySelectorAll("ul")];
104
+ expect(lists).toHaveLength(3);
105
+ expect(lists[0]!.hasAttribute("aria-labelledby")).toBe(false);
106
+ const headings = lists.slice(1).map((list) => {
107
+ const id = list.getAttribute("aria-labelledby")!;
108
+ return document.getElementById(id)?.textContent?.trim();
109
+ });
110
+ expect(headings).toEqual(["t($billing)", "Legal"]);
111
+ expect(lists[1]!.querySelectorAll("li")).toHaveLength(2);
112
+ });
113
+ });
114
+
115
+ function labelledRuns(): [string | undefined, string[]][] {
116
+ return [...host.querySelectorAll("ul")].map((list) => {
117
+ const id = list.getAttribute("aria-labelledby");
118
+ const label = id
119
+ ? document.getElementById(id)?.textContent?.trim()
120
+ : undefined;
121
+ const chips = [...list.querySelectorAll("a")].map(
122
+ (chip) => chip.textContent?.trim() ?? "",
123
+ );
124
+ return [label, chips];
125
+ });
126
+ }
127
+
128
+ describe("the row of chips", () => {
129
+ it("leads each run of chips with its group's label", async () => {
130
+ await mount("chips");
131
+ expect(labelledRuns()).toEqual([
132
+ [undefined, ["Plan"]],
133
+ ["t($billing)", ["Dunning", "Tax"]],
134
+ ["Legal", ["Terms"]],
135
+ ]);
136
+ });
137
+
138
+ it("wraps the runs instead of scrolling one line", async () => {
139
+ await mount("chips");
140
+ const nav = host.querySelector("nav")!;
141
+ expect(nav.classList.contains("flex-wrap")).toBe(true);
142
+ for (const list of host.querySelectorAll("ul")) {
143
+ expect(list.classList.contains("overflow-x-auto")).toBe(false);
144
+ }
145
+ });
146
+
147
+ it("keeps a form without groups in one scrolling row", async () => {
148
+ await mount("chips", { sections: plainLayout.sections });
149
+ expect(host.querySelectorAll("ul")).toHaveLength(1);
150
+ expect(host.querySelectorAll("li")).toHaveLength(2);
151
+ expect(host.querySelector("[aria-labelledby]")).toBeNull();
152
+ expect(host.querySelector("p")).toBeNull();
153
+ expect(host.querySelector("ul")!.className).toBe(
154
+ "flex gap-1.5 overflow-x-auto pb-1",
155
+ );
156
+ });
157
+
158
+ it("links each chip to its section and reports the one chosen", async () => {
159
+ const selected: string[] = [];
160
+ await mount("chips", {
161
+ activeId: "tax",
162
+ onSelect: (id: string) => selected.push(id),
163
+ });
164
+ const chips = [...host.querySelectorAll("a")];
165
+ expect(chips.map((chip) => chip.getAttribute("href"))).toEqual([
166
+ "#form-plan",
167
+ "#form-dunning",
168
+ "#form-tax",
169
+ "#form-terms",
170
+ ]);
171
+ expect(
172
+ host.querySelector("[aria-current='location']")?.textContent?.trim(),
173
+ ).toBe("Tax");
174
+ chips[3]!.click();
175
+ expect(selected).toEqual(["terms"]);
176
+ });
177
+ });
@@ -1,6 +1,6 @@
1
1
  // @vitest-environment jsdom
2
2
  import { createApp, defineComponent, h, type App } from "vue";
3
- import { afterEach, expect, it } from "vitest";
3
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
4
4
  import NavCard from "../layers/dms-ui/app/components/card/NavCard.vue";
5
5
 
6
6
  // A nav card of the settings overview carries its page's navigation badge in
@@ -39,6 +39,10 @@ function mountCard(props: Record<string, unknown>): HTMLElement {
39
39
  const headRow = (container: HTMLElement) =>
40
40
  container.querySelector("h3")?.parentElement as HTMLElement;
41
41
 
42
+ beforeEach(() => {
43
+ vi.stubGlobal("useI18n", () => ({ t: (key: string) => key }));
44
+ });
45
+
42
46
  afterEach(() => {
43
47
  app?.unmount();
44
48
  app = undefined;
@@ -0,0 +1,58 @@
1
+ import { readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+ import coreEn from "../layers/dms-core/i18n/locales/core-en-GB.json";
5
+ import coreFr from "../layers/dms-core/i18n/locales/core-fr-FR.json";
6
+
7
+ // Nuxt UI hard-codes these labels in English, and its locales do not carry
8
+ // them: each use has to bind its own, translated.
9
+ const LABELLED_TAGS = {
10
+ UBreadcrumb: "dms.a11y.breadcrumb",
11
+ USkeleton: "dms.a11y.loading",
12
+ } as const;
13
+ const LOCALES = { "en-GB": coreEn, "fr-FR": coreFr };
14
+
15
+ function walk(directory: string): string[] {
16
+ return readdirSync(directory).flatMap((name) => {
17
+ const path = join(directory, name);
18
+ return statSync(path).isDirectory() ? walk(path) : [path];
19
+ });
20
+ }
21
+
22
+ function lookup(messages: unknown, key: string): unknown {
23
+ return key
24
+ .split(".")
25
+ .reduce<unknown>(
26
+ (node, part) => (node as Record<string, unknown> | undefined)?.[part],
27
+ messages,
28
+ );
29
+ }
30
+
31
+ describe("Nuxt UI hard-coded aria-labels", () => {
32
+ const sources = walk(join(process.cwd(), "layers"))
33
+ .filter((path) => path.endsWith(".vue"))
34
+ .map((path) => ({
35
+ path: relative(process.cwd(), path),
36
+ text: readFileSync(path, "utf8"),
37
+ }));
38
+
39
+ for (const [tag, key] of Object.entries(LABELLED_TAGS)) {
40
+ it(`binds a translated aria-label on every ${tag}`, () => {
41
+ const tagPattern = new RegExp(`<${tag}\\b[^>]*?>`, "gs");
42
+ const offenders = sources.flatMap(({ path, text }) =>
43
+ [...text.matchAll(tagPattern)]
44
+ .filter(([found]) => !found.includes(`:aria-label="t('${key}')"`))
45
+ .map(() => path),
46
+ );
47
+
48
+ expect(offenders).toEqual([]);
49
+ });
50
+
51
+ it(`translates ${key} in every locale`, () => {
52
+ for (const messages of Object.values(LOCALES)) {
53
+ expect(lookup(messages, key)).toBeTypeOf("string");
54
+ }
55
+ expect(lookup(coreFr, key)).not.toBe(lookup(coreEn, key));
56
+ });
57
+ }
58
+ });
@@ -68,11 +68,11 @@ const pageLayout = ref<PageLayoutFixture | null>(null);
68
68
  // is pending after a client navigation, once a short delay passes).
69
69
  const PAGE_SKELETON_MARKUP =
70
70
  '<div aria-hidden="true" class="dms-page-skeleton space-y-6 hidden [html[data-dms-role-preview=pending]_&amp;]:block"><!--v-if-->' +
71
- '<div class="dms-card space-y-4 p-[18px]"><USkeleton class="h-3.5 w-40"></USkeleton><!--[-->' +
71
+ '<div class="dms-card space-y-4 p-[18px]"><USkeleton aria-label="dms.a11y.loading" class="h-3.5 w-40"></USkeleton><!--[-->' +
72
72
  ["72%", "58%", "66%", "44%"]
73
73
  .map(
74
74
  (width) =>
75
- `<USkeleton class="h-2.5" style="width:${width};"></USkeleton>`,
75
+ `<USkeleton aria-label="dms.a11y.loading" class="h-2.5" style="width:${width};"></USkeleton>`,
76
76
  )
77
77
  .join("") +
78
78
  "<!--]--></div></div>";
@@ -104,3 +104,52 @@ it("lists a record form's unsaved changes beside Discard and Save", async () =>
104
104
  "dms.save_bar.save",
105
105
  ]);
106
106
  });
107
+
108
+ const classesOf = (element: Element | null | undefined) =>
109
+ (element?.className ?? "").split(/\s+/);
110
+ const anchor = () => host.firstElementChild!;
111
+ const bar = () => anchor().firstElementChild!;
112
+
113
+ it("takes no space while a clean page form's bar hides", async () => {
114
+ await mount({ variant: "floating", dirty: false });
115
+
116
+ expect(anchor().getAttribute("aria-hidden")).toBe("true");
117
+ expect(classesOf(anchor())).toEqual(
118
+ expect.arrayContaining(["sticky", "h-0"]),
119
+ );
120
+ expect(classesOf(bar())).toEqual(
121
+ expect.arrayContaining(["absolute", "invisible"]),
122
+ );
123
+ });
124
+
125
+ it("floats over the form when it shows, making room under the last field", async () => {
126
+ await mount({ variant: "floating", dirty: true, changes: ["Name"] });
127
+
128
+ expect(anchor().getAttribute("aria-hidden")).toBeNull();
129
+ expect(classesOf(anchor())).not.toContain("h-0");
130
+ expect(classesOf(anchor())).toContain("h-[calc(var(--dms-save-bar-h)+2rem)]");
131
+ expect(classesOf(bar())).toEqual(
132
+ expect.arrayContaining(["absolute", "h-(--dms-save-bar-h)"]),
133
+ );
134
+ expect(classesOf(bar())).not.toContain("invisible");
135
+ });
136
+
137
+ it("gives Cancel and Unsaved changes the same fixed height", async () => {
138
+ await mount({ variant: "floating", dirty: false, cancellable: true });
139
+ const cancelBar = classesOf(bar());
140
+ expect(buttons()).toEqual([{ label: "dms.button.cancel", disabled: false }]);
141
+ app?.unmount();
142
+
143
+ await mount({ variant: "floating", dirty: true, changes: ["Name"] });
144
+ expect(cancelBar).toContain("h-(--dms-save-bar-h)");
145
+ expect(classesOf(bar())).toContain("h-(--dms-save-bar-h)");
146
+ });
147
+
148
+ it("keeps a hidden band's place in its footer", async () => {
149
+ await mount({ variant: "band", kind: "action", dirty: false });
150
+
151
+ expect(anchor().getAttribute("aria-hidden")).toBe("true");
152
+ expect(classesOf(anchor())).toContain("invisible");
153
+ expect(classesOf(anchor())).not.toContain("h-0");
154
+ expect(classesOf(anchor())).not.toContain("absolute");
155
+ });
@@ -0,0 +1,63 @@
1
+ import { createSSRApp, defineComponent, h, ref, type Component } from "vue";
2
+ import { renderToString } from "vue/server-renderer";
3
+ import { beforeAll, describe, expect, it, vi } from "vitest";
4
+ import StatGroup from "../layers/dms-ui/app/components/stat-group/StatGroup.vue";
5
+ import NavCard from "../layers/dms-ui/app/components/card/NavCard.vue";
6
+
7
+ // A detail or a title cut on its first line loses what it says: a stat
8
+ // group's details and a nav card's title take two lines before the ellipsis.
9
+
10
+ const Slot = defineComponent({
11
+ setup:
12
+ (_, { slots }) =>
13
+ () =>
14
+ h("div", slots.default?.()),
15
+ });
16
+
17
+ async function render(component: Component, props: Record<string, unknown>) {
18
+ const app = createSSRApp({ render: () => h(component, props) });
19
+ for (const name of ["UIcon", "USkeleton", "DmsLink"]) {
20
+ app.component(name, Slot);
21
+ }
22
+ return renderToString(app);
23
+ }
24
+
25
+ beforeAll(() => {
26
+ vi.stubGlobal("useI18n", () => ({ locale: ref("en-GB"), t: String }));
27
+ });
28
+
29
+ const DETAIL = "Next invoice on Nov 1 for the three seats left";
30
+
31
+ function detailClasses(html: string): string {
32
+ const element = new RegExp(
33
+ `<(?:p|small) class="([^"]*)"[^>]*>\\s*${DETAIL}`,
34
+ ).exec(html);
35
+ return element?.[1] ?? "";
36
+ }
37
+
38
+ describe("StatGroup detail", () => {
39
+ it.each(["joined", "cards"])(
40
+ "gives a %s cell's detail two lines before its ellipsis",
41
+ async (layout) => {
42
+ const html = await render(StatGroup, {
43
+ layout,
44
+ items: [{ eyebrow: "Billing", value: "On", detail: DETAIL }],
45
+ });
46
+ expect(detailClasses(html)).toContain("line-clamp-2");
47
+ expect(detailClasses(html)).not.toContain("truncate");
48
+ },
49
+ );
50
+ });
51
+
52
+ describe("NavCard title", () => {
53
+ it("takes two lines before its ellipsis", async () => {
54
+ const html = await render(NavCard, {
55
+ to: "/workspaces?view=past-due",
56
+ icon: "i-ph-warning",
57
+ title: "Workspaces past due for more than seven days",
58
+ });
59
+ const title = /<h3 class="([^"]*)"/.exec(html)?.[1] ?? "";
60
+ expect(title).toContain("line-clamp-2");
61
+ expect(title).not.toContain("truncate");
62
+ });
63
+ });