@antelopejs/dms 0.7.4 → 0.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/implementations/dms-base/export-jobs.d.ts +4 -1
  3. package/dist/implementations/dms-base/export-jobs.js +10 -6
  4. package/dist/implementations/dms-base/export-jobs.js.map +1 -1
  5. package/dist/implementations/dms-base/search-route.js +20 -11
  6. package/dist/implementations/dms-base/search-route.js.map +1 -1
  7. package/dist/test/attachment-host/capabilities.d.ts +2 -0
  8. package/dist/test/attachment-host/capabilities.js +11 -0
  9. package/dist/test/attachment-host/capabilities.js.map +1 -1
  10. package/dist/test/integration/table-view-capabilities.test.js +23 -5
  11. package/dist/test/integration/table-view-capabilities.test.js.map +1 -1
  12. package/dist/test/unit/interfaces/dms-base/data-types/field-types.test.js +29 -0
  13. package/dist/test/unit/interfaces/dms-base/data-types/field-types.test.js.map +1 -1
  14. package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js +14 -0
  15. package/dist/test/unit/interfaces/dms-base/display-blocks-catalog.test.js.map +1 -1
  16. package/dist/test/unit/interfaces/dms-base/export-jobs-binding.test.d.ts +1 -0
  17. package/dist/test/unit/interfaces/dms-base/export-jobs-binding.test.js +49 -0
  18. package/dist/test/unit/interfaces/dms-base/export-jobs-binding.test.js.map +1 -0
  19. package/dist/test/unit/interfaces/dms-base/table-view-hidden-columns.test.d.ts +1 -0
  20. package/dist/test/unit/interfaces/dms-base/table-view-hidden-columns.test.js +105 -0
  21. package/dist/test/unit/interfaces/dms-base/table-view-hidden-columns.test.js.map +1 -0
  22. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +16 -0
  23. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
  24. package/dist/test/unit/request-tenant-guards.test.js +38 -0
  25. package/dist/test/unit/request-tenant-guards.test.js.map +1 -1
  26. package/frontend-vue/layers/dms-auth/app/custom-pages/auth/accounts.vue +9 -3
  27. package/frontend-vue/layers/dms-core/i18n/locales/core-en-GB.json +6 -0
  28. package/frontend-vue/layers/dms-core/i18n/locales/core-fr-FR.json +6 -0
  29. package/frontend-vue/layers/dms-layout/app/build/components/layout/CommandPaletteAssistantAnswer.vue +1 -0
  30. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardHeader.vue +2 -0
  31. package/frontend-vue/layers/dms-layout/app/build/components/layout/Footer.vue +1 -0
  32. package/frontend-vue/layers/dms-layout/app/build/components/layout/PageSkeleton.vue +13 -4
  33. package/frontend-vue/layers/dms-layout/app/build/components/layout/RolePreviewBar.vue +9 -3
  34. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/members/MemberRolePicker.vue +5 -1
  35. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/profile/ProfilePersonalInfo.vue +1 -0
  36. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/profile/ProfileSummaryRow.vue +4 -0
  37. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/roles/RoleEditor.vue +1 -1
  38. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityEmail.vue +4 -1
  39. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityPassword.vue +4 -1
  40. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecuritySessions.vue +1 -0
  41. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/security/SecurityTwoFactor.vue +19 -4
  42. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/theme/AppearanceDeveloper.vue +1 -0
  43. package/frontend-vue/layers/dms-layout/app/build/components/pages/settings/theme/CssVariablesList.vue +6 -3
  44. package/frontend-vue/layers/dms-layout/app/custom-pages/modules.vue +20 -5
  45. package/frontend-vue/layers/dms-ui/app/build/components/chart/internal/ChartSkeleton.vue +4 -0
  46. package/frontend-vue/layers/dms-ui/app/build/components/containers/drawer/DynamicDrawer.vue +10 -2
  47. package/frontend-vue/layers/dms-ui/app/build/components/containers/modal/DynamicModal.vue +7 -2
  48. package/frontend-vue/layers/dms-ui/app/build/components/form/FormSectionNav.vue +87 -40
  49. package/frontend-vue/layers/dms-ui/app/build/components/skeleton/RowSkeleton.vue +8 -1
  50. package/frontend-vue/layers/dms-ui/app/build/components/stat/StatCell.vue +54 -13
  51. package/frontend-vue/layers/dms-ui/app/build/components/table/Pagination.vue +9 -2
  52. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +22 -40
  53. package/frontend-vue/layers/dms-ui/app/build/components/table/Tabs.vue +1 -0
  54. package/frontend-vue/layers/dms-ui/app/build/components/table/Views.vue +1 -0
  55. package/frontend-vue/layers/dms-ui/app/build/components/table-view/CardGridSkeleton.vue +22 -5
  56. package/frontend-vue/layers/dms-ui/app/build/components/table-view/CardsDisplay.vue +49 -9
  57. package/frontend-vue/layers/dms-ui/app/build/components/table-view/ExpandedRowDetail.vue +1 -0
  58. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +3 -1
  59. package/frontend-vue/layers/dms-ui/app/build/composables/form/formFooter.ts +3 -2
  60. package/frontend-vue/layers/dms-ui/app/build/composables/form/formSections.ts +25 -0
  61. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useReorderHandles.ts +93 -0
  62. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableReorder.ts +8 -7
  63. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +20 -2
  64. package/frontend-vue/layers/dms-ui/app/build/utils/characterCount.ts +8 -0
  65. package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +4 -1
  66. package/frontend-vue/layers/dms-ui/app/components/blocks/CodeBlock.vue +3 -0
  67. package/frontend-vue/layers/dms-ui/app/components/blocks/NavCardGridBlock.vue +13 -5
  68. package/frontend-vue/layers/dms-ui/app/components/card/NavCard.vue +7 -1
  69. package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +10 -3
  70. package/frontend-vue/layers/dms-ui/app/components/confirm/ConfirmModal.vue +8 -0
  71. package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +4 -3
  72. package/frontend-vue/layers/dms-ui/app/components/form/components/Cascader.vue +6 -1
  73. package/frontend-vue/layers/dms-ui/app/components/form/components/Display.vue +5 -1
  74. package/frontend-vue/layers/dms-ui/app/components/form/components/InputNumber.vue +27 -3
  75. package/frontend-vue/layers/dms-ui/app/components/form/components/InputText.vue +24 -2
  76. package/frontend-vue/layers/dms-ui/app/components/form/components/RichText.vue +13 -4
  77. package/frontend-vue/layers/dms-ui/app/components/form/components/Textarea.vue +27 -2
  78. package/frontend-vue/layers/dms-ui/app/components/key-value-list/KeyValueList.vue +8 -4
  79. package/frontend-vue/layers/dms-ui/app/components/kpi/KpiCard.vue +12 -4
  80. package/frontend-vue/layers/dms-ui/app/components/meter/MeterBlock.vue +15 -4
  81. package/frontend-vue/layers/dms-ui/app/components/save-bar/SaveBar.vue +69 -42
  82. package/frontend-vue/layers/dms-ui/app/components/tab/Tab.vue +15 -1
  83. package/frontend-vue/layers/dms-ui/app/components/table-view/KanbanBoard.vue +13 -3
  84. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +8 -2
  85. package/frontend-vue/layers/dms-ui/app/components/top-list/TopListCard.vue +45 -9
  86. package/frontend-vue/layers/dms-ui/app/composables/form/types/field.ts +2 -0
  87. package/frontend-vue/layers/dms-ui/app/composables/tab/types/props.ts +5 -0
  88. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/display.ts +51 -0
  89. package/frontend-vue/tests/confirm-dialog-fields.test.ts +12 -0
  90. package/frontend-vue/tests/drawer-target-direction.test.ts +1 -0
  91. package/frontend-vue/tests/field-suffix-count.test.ts +120 -0
  92. package/frontend-vue/tests/form-section-nav-groups.test.ts +116 -0
  93. package/frontend-vue/tests/nav-card-badge.test.ts +5 -1
  94. package/frontend-vue/tests/nuxt-ui-aria-labels.test.ts +58 -0
  95. package/frontend-vue/tests/page-fill-height.test.ts +2 -2
  96. package/frontend-vue/tests/save-bar.test.ts +49 -0
  97. package/frontend-vue/tests/stat-group-detail-lines.test.ts +63 -0
  98. package/frontend-vue/tests/tab-fit.test.ts +99 -0
  99. package/frontend-vue/tests/table-cell-sublines.test.ts +15 -0
  100. package/frontend-vue/tests/table-cell-wrap.test.ts +27 -3
  101. package/frontend-vue/tests/table-row-action-errors.test.ts +26 -0
  102. package/frontend-vue/tests/table-row-hover.test.ts +3 -1
  103. package/frontend-vue/tests/table-scroll-area.test.ts +3 -1
  104. package/frontend-vue/tests/table-view-card-props.test.ts +8 -2
  105. package/frontend-vue/tests/table-view-cards-reorder.test.ts +250 -0
  106. package/frontend-vue/tests/table-view-reorder.test.ts +2 -2
  107. package/frontend-vue/tests/top-list-card-actions.test.ts +91 -0
  108. package/package.json +2 -2
  109. package/skills/dms/SKILL.md +14 -1
@@ -6,12 +6,17 @@ import CardGridSkeleton from "./CardGridSkeleton.vue";
6
6
  import type { TableViewColumn } from "../../../composables/table-view/types/column";
7
7
  import type {
8
8
  TableViewCardConfig,
9
+ TableViewCardReorder,
9
10
  TableViewDisplayContext,
10
11
  } from "../../../composables/table-view/types/display";
11
12
  import {
12
13
  buildCardProps,
13
14
  cardFieldColumns,
14
15
  } from "../../composables/table-view/utils/card";
16
+ import {
17
+ REORDER_HANDLE_CLASS,
18
+ useReorderHandles,
19
+ } from "../../composables/table-view/useReorderHandles";
15
20
 
16
21
  interface CardsDisplayProps {
17
22
  context: TableViewDisplayContext<T>;
@@ -85,6 +90,22 @@ const FieldValue = (fieldProps: { column: TableViewColumn; item: T }) => {
85
90
  };
86
91
 
87
92
  const openItem = (item: T) => props.context.actions.open(item);
93
+
94
+ // A hand-ordered table (backend `reorder`): each card carries a move handle,
95
+ // and is where another card dragged by its handle drops.
96
+ const { draggedIndex, handleFor, drop, allowDrop } = useReorderHandles({
97
+ reorder: () => props.context.reorder,
98
+ count: () => props.context.items.length,
99
+ });
100
+
101
+ const cardReorder = (index: number): TableViewCardReorder | undefined =>
102
+ props.context.reorder
103
+ ? {
104
+ enabled: props.context.reorder.enabled,
105
+ dragging: draggedIndex.value === index,
106
+ handle: handleFor(index),
107
+ }
108
+ : undefined;
88
109
  </script>
89
110
 
90
111
  <template>
@@ -107,30 +128,49 @@ const openItem = (item: T) => props.context.actions.open(item);
107
128
  class="grid grid-cols-[repeat(auto-fill,minmax(min(230px,100%),1fr))] gap-3 px-[18px] pt-4 pb-[18px]"
108
129
  >
109
130
  <template v-if="cardComponent">
110
- <component
111
- :is="cardComponent"
112
- v-for="item in props.context.items"
131
+ <!-- A wrapper out of the grid's layout, catching the drop on the
132
+ card whatever its root element. -->
133
+ <div
134
+ v-for="(item, index) in props.context.items"
113
135
  :key="rowId(item)"
114
- v-bind="{
115
- ...(card?.component?.options ?? {}),
116
- ...buildCardProps(item, props.context),
117
- }"
118
- />
136
+ class="contents"
137
+ @dragover="allowDrop"
138
+ @drop="drop(index)"
139
+ >
140
+ <component
141
+ :is="cardComponent"
142
+ v-bind="{
143
+ ...(card?.component?.options ?? {}),
144
+ ...buildCardProps(item, props.context),
145
+ reorder: cardReorder(index),
146
+ }"
147
+ />
148
+ </div>
119
149
  </template>
120
150
  <template v-else>
121
151
  <article
122
- v-for="item in props.context.items"
152
+ v-for="(item, index) in props.context.items"
123
153
  :key="rowId(item)"
124
154
  class="group border-default hover:border-primary/35 @container cursor-pointer rounded-[10px] border bg-(--ui-bg) p-3.5 text-[12.5px] transition-colors focus-visible:outline-2 focus-visible:outline-(--dms-accent-line)"
125
155
  :class="{
126
156
  'border-primary ring-primary ring-1':
127
157
  props.context.selection.isSelected(rowId(item)),
158
+ 'opacity-50': draggedIndex === index,
128
159
  }"
129
160
  tabindex="0"
130
161
  @click="openItem(item)"
131
162
  @keydown.enter.self="openItem(item)"
163
+ @dragover="allowDrop"
164
+ @drop="drop(index)"
132
165
  >
133
166
  <header class="flex items-center gap-2.5">
167
+ <button
168
+ v-if="props.context.reorder"
169
+ v-bind="handleFor(index)"
170
+ :class="['-ms-1.5', REORDER_HANDLE_CLASS]"
171
+ >
172
+ <UIcon name="i-ph-dots-six-vertical" />
173
+ </button>
134
174
  <span
135
175
  class="bg-accented text-default grid size-7 shrink-0 place-items-center rounded-[7px] font-mono text-[10.5px] font-bold"
136
176
  >
@@ -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;
@@ -0,0 +1,93 @@
1
+ import { nextTick, ref } from "vue";
2
+ import type {
3
+ TableViewDisplayReorder,
4
+ TableViewReorderHandle,
5
+ } from "../../../composables/table-view/types/display";
6
+
7
+ // One place back or forward: a row of the grid steps up and down, a card of
8
+ // the cards grid also left and right.
9
+ const STEP_BY_KEY: Record<string, number> = {
10
+ ArrowUp: -1,
11
+ ArrowLeft: -1,
12
+ ArrowDown: 1,
13
+ ArrowRight: 1,
14
+ };
15
+
16
+ const DRAG_EFFECT = "move";
17
+
18
+ /** The look of a move handle, on a grid row and on a card. */
19
+ export const REORDER_HANDLE_CLASS =
20
+ "inline-flex size-6 shrink-0 cursor-grab items-center justify-center rounded text-dimmed hover:text-highlighted focus-visible:outline-2 focus-visible:outline-(--dms-accent-line) disabled:cursor-not-allowed disabled:opacity-40 [&>svg]:size-4";
21
+
22
+ export interface ReorderHandlesOptions {
23
+ reorder: () => TableViewDisplayReorder | undefined;
24
+ /** How many rows the display draws: a step past either end does nothing. */
25
+ count: () => number;
26
+ }
27
+
28
+ /**
29
+ * The move handles of a hand-ordered display, the grid's and the cards'
30
+ * alike: the row dragged by its handle is dropped on another row, and the
31
+ * arrow keys on a handle move its row by one, keeping the focus on it.
32
+ */
33
+ export function useReorderHandles(options: ReorderHandlesOptions) {
34
+ const { t } = useI18n();
35
+ const draggedIndex = ref<number | undefined>();
36
+
37
+ const isEnabled = () => !!options.reorder()?.enabled;
38
+
39
+ const drop = (index: number) => {
40
+ const from = draggedIndex.value;
41
+ draggedIndex.value = undefined;
42
+ if (from !== undefined && isEnabled()) options.reorder()!.move(from, index);
43
+ };
44
+
45
+ const allowDrop = (event: DragEvent) => {
46
+ if (isEnabled()) event.preventDefault();
47
+ };
48
+
49
+ const step = (index: number, delta: number) => {
50
+ const target = index + delta;
51
+ if (!isEnabled() || target < 0 || target >= options.count()) return false;
52
+ options.reorder()!.move(index, target);
53
+ return true;
54
+ };
55
+
56
+ const startDrag = (index: number, event: DragEvent) => {
57
+ draggedIndex.value = index;
58
+ // Firefox starts a drag only once it carries data.
59
+ event.dataTransfer?.setData("text/plain", String(index));
60
+ if (event.dataTransfer) event.dataTransfer.effectAllowed = DRAG_EFFECT;
61
+ };
62
+
63
+ // The handle moves with its row (rows are keyed by id), and the browser
64
+ // drops the focus of an element it moves: give it back.
65
+ const stepByKey = (index: number, event: KeyboardEvent) => {
66
+ const delta = STEP_BY_KEY[event.key];
67
+ if (delta === undefined) return;
68
+ event.preventDefault();
69
+ const handle = event.currentTarget as HTMLElement | null;
70
+ if (step(index, delta)) void nextTick(() => handle?.focus());
71
+ };
72
+
73
+ const handleFor = (index: number): TableViewReorderHandle => {
74
+ const enabled = isEnabled();
75
+ return {
76
+ type: "button",
77
+ draggable: enabled,
78
+ disabled: !enabled,
79
+ "aria-label": t("dms.table.reorder.move"),
80
+ title: enabled
81
+ ? t("dms.table.reorder.move")
82
+ : t("dms.table.reorder.disabled"),
83
+ onClick: (event) => event.stopPropagation(),
84
+ onDragstart: (event) => startDrag(index, event),
85
+ onDragend: () => {
86
+ draggedIndex.value = undefined;
87
+ },
88
+ onKeydown: (event) => stepByKey(index, event),
89
+ };
90
+ };
91
+
92
+ return { draggedIndex, handleFor, drop, allowDrop };
93
+ }
@@ -1,10 +1,10 @@
1
1
  import { get } from "@nuxt/ui/runtime/utils/index.js";
2
2
  import type { ComputedRef, Ref } from "vue";
3
3
  import type {
4
+ TableViewDisplayReorder,
4
5
  TableViewListResponse,
5
6
  TableViewReorderConfig,
6
7
  } from "../../../composables/table-view/types";
7
- import type { TableReorder } from "../../components/table/Table.vue";
8
8
  import { moveRow, reorderEdits } from "./utils/reorder";
9
9
 
10
10
  export interface TableReorderOptions<T> {
@@ -18,8 +18,8 @@ export interface TableReorderOptions<T> {
18
18
  canEdit: ComputedRef<boolean>;
19
19
  /** A search, a filter or a tab narrows the rows. */
20
20
  isNarrowed: ComputedRef<boolean>;
21
- /** The grid is the display shown. */
22
- isGrid: ComputedRef<boolean>;
21
+ /** The display shown draws the rows in their order: the grid, the cards. */
22
+ isOrderedDisplay: ComputedRef<boolean>;
23
23
  /** Lists the rows again, after a move failed. */
24
24
  refresh: () => Promise<void> | void;
25
25
  onError: (error: unknown) => void;
@@ -28,8 +28,9 @@ export interface TableReorderOptions<T> {
28
28
  /**
29
29
  * Rows ordered by hand: a move updates the page at once and saves, by a
30
30
  * partial edit, only the rows whose position changed. Moving is off while
31
- * the rows are narrowed (the handle says how to turn it back on); outside
32
- * the grid, or for a caller who may not edit, there is no handle at all.
31
+ * the rows are narrowed (the handle says how to turn it back on); on a
32
+ * display that does not draw the rows in their order (the board, the
33
+ * groups), or for a caller who may not edit, there is no handle at all.
33
34
  */
34
35
  export function useTableReorder<T>(options: TableReorderOptions<T>) {
35
36
  const { reorder, data, rowIdKey } = options;
@@ -66,8 +67,8 @@ export function useTableReorder<T>(options: TableReorderOptions<T>) {
66
67
  }
67
68
  };
68
69
 
69
- const state = computed<TableReorder | undefined>(() => {
70
- if (!reorder || !options.isGrid.value || !options.canEdit.value) {
70
+ const state = computed<TableViewDisplayReorder | undefined>(() => {
71
+ if (!reorder || !options.isOrderedDisplay.value || !options.canEdit.value) {
71
72
  return undefined;
72
73
  }
73
74
  return { enabled: !options.isNarrowed.value, move };
@@ -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"
@@ -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>