@antelopejs/dms 0.4.1 → 0.4.3

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 (33) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/pages/settings/users/invites.js +6 -5
  3. package/dist/pages/settings/users/invites.js.map +1 -1
  4. package/dist/pages/settings/users/members.d.ts +1 -0
  5. package/dist/pages/settings/users/members.js +12 -3
  6. package/dist/pages/settings/users/members.js.map +1 -1
  7. package/dist/pages/settings/users/permission-tree-nodes.d.ts +3 -0
  8. package/dist/pages/settings/users/permission-tree-nodes.js +22 -0
  9. package/dist/pages/settings/users/permission-tree-nodes.js.map +1 -0
  10. package/dist/pages/settings/users/roles.js +2 -16
  11. package/dist/pages/settings/users/roles.js.map +1 -1
  12. package/dist/test/unit/pages/settings/permission-tree-nodes.test.d.ts +1 -0
  13. package/dist/test/unit/pages/settings/permission-tree-nodes.test.js +68 -0
  14. package/dist/test/unit/pages/settings/permission-tree-nodes.test.js.map +1 -0
  15. package/frontend-vue/dms.frontend.ts +2 -0
  16. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardHeader.vue +4 -2
  17. package/frontend-vue/layers/dms-layout/app/composables/general/types/index.ts +7 -0
  18. package/frontend-vue/layers/dms-layout/app/composables/general/useColorModePreference.ts +17 -0
  19. package/frontend-vue/layers/dms-layout/app/custom-pages/settings/appearance.vue +6 -6
  20. package/frontend-vue/layers/dms-layout/app/plugins/color-mode.ts +100 -0
  21. package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +41 -1
  22. package/frontend-vue/layers/dms-ui/app/components/form/components/Calendar.vue +49 -26
  23. package/frontend-vue/layers/dms-ui/app/components/form/components/DatePicker.vue +77 -32
  24. package/frontend-vue/layers/dms-ui/app/composables/chart/useThemeRevision.ts +0 -22
  25. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +23 -0
  26. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +1 -0
  27. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +1 -0
  28. package/frontend-vue/package.json +2 -2
  29. package/frontend-vue/pnpm-lock.yaml +7 -33
  30. package/frontend-vue/tests/color-mode-server-render.test.ts +84 -0
  31. package/frontend-vue/tests/color-mode-sync.test.ts +320 -0
  32. package/frontend-vue/tests/form-required-fields.test.ts +44 -0
  33. package/package.json +1 -1
@@ -1,7 +1,10 @@
1
1
  <script setup lang="ts">
2
2
  import type { FormProps } from "../../composables/form/types";
3
3
  import type { FormFieldValue } from "../../composables/form/types/value";
4
- import { cloneFormValue } from "../../composables/form/useForm";
4
+ import {
5
+ cloneFormValue,
6
+ isFieldMarkedRequired,
7
+ } from "../../composables/form/useForm";
5
8
  import {
6
9
  FORM_VALIDATOR_KEY,
7
10
  type FormValidator,
@@ -38,6 +41,7 @@ const {
38
41
  allFields,
39
42
  disabledFields,
40
43
  hiddenFields,
44
+ requiredFields,
41
45
  isFieldGroup,
42
46
  submitSucceeded,
43
47
  resolvedFetchUrl,
@@ -200,6 +204,23 @@ function isGroupVisible(group: { fields: FormField[] }): boolean {
200
204
  return group.fields.some((field) => !isFieldHidden(field));
201
205
  }
202
206
 
207
+ function isFieldRequired(field: FormField): boolean {
208
+ return isFieldMarkedRequired(
209
+ field,
210
+ disabledFields.value,
211
+ hiddenFields.value,
212
+ requiredFields.value,
213
+ );
214
+ }
215
+
216
+ function isGroupRequired(group: { fields: FormField[] }): boolean {
217
+ return group.fields.some(isFieldRequired);
218
+ }
219
+
220
+ const hasRequiredFields = computed(() =>
221
+ toValue(allFields).some(isFieldRequired),
222
+ );
223
+
203
224
  function shouldShowDisplay(field: FormField): boolean {
204
225
  return isFieldDisabled(field) && !!field.type;
205
226
  }
@@ -379,6 +400,13 @@ onUnmounted(async () => {
379
400
  class="text-default block text-base font-semibold sm:text-sm"
380
401
  >
381
402
  {{ processI18n(item.label || "") }}
403
+ <span
404
+ v-if="isGroupRequired(item)"
405
+ class="text-error ms-0.5"
406
+ aria-hidden="true"
407
+ >
408
+ *
409
+ </span>
382
410
  </label>
383
411
  <p v-if="item.description" class="text-dimmed text-xs">
384
412
  {{ processI18n(item.description || "") }}
@@ -453,6 +481,13 @@ onUnmounted(async () => {
453
481
  class="text-default block text-base font-semibold sm:text-sm"
454
482
  >
455
483
  {{ processI18n(item.label || "") }}
484
+ <span
485
+ v-if="isFieldRequired(item)"
486
+ class="text-error ms-0.5"
487
+ aria-hidden="true"
488
+ >
489
+ *
490
+ </span>
456
491
  </label>
457
492
  <p v-if="item.description" class="text-dimmed text-xs">
458
493
  {{ processI18n(item.description || "") }}
@@ -509,6 +544,11 @@ onUnmounted(async () => {
509
544
  </template>
510
545
  </div>
511
546
 
547
+ <p v-if="hasRequiredFields" class="text-dimmed mt-6 text-xs">
548
+ <span class="text-error" aria-hidden="true">*</span>
549
+ {{ $t("dms.form.required_legend") }}
550
+ </p>
551
+
512
552
  <template v-if="showActions">
513
553
  <section class="mt-6 flex justify-end gap-2">
514
554
  <UButton
@@ -11,27 +11,39 @@ import {
11
11
  type CalendarDate,
12
12
  } from "@internationalized/date";
13
13
 
14
+ // Mid-selection, the range calendar emits a range whose end is still unset.
14
15
  interface StrictDateRange {
15
- start: CalendarDate;
16
- end: CalendarDate;
16
+ start: CalendarDate | undefined;
17
+ end: CalendarDate | undefined;
17
18
  }
18
19
 
19
20
  interface StringDateRange {
20
- start: string;
21
- end: string;
21
+ start: string | undefined;
22
+ end: string | undefined;
22
23
  }
23
24
 
24
25
  interface NativeDateRange {
25
- start: Date;
26
- end: Date;
26
+ start: Date | undefined;
27
+ end: Date | undefined;
27
28
  }
28
29
 
29
- type DateValue = CalendarDate | StrictDateRange | string | null | undefined;
30
+ type DateValue =
31
+ | CalendarDate
32
+ | StrictDateRange
33
+ | string
34
+ | (string | Date | CalendarDate)[]
35
+ | null
36
+ | undefined;
30
37
 
31
38
  const props = defineProps<CalendarProps<R, M>>();
32
39
  const emits = defineEmits<{
33
40
  "update:modelValue": [
34
- value: string | { start: string; end: string } | null | undefined,
41
+ value:
42
+ | string
43
+ | { start: string | undefined; end: string | undefined }
44
+ | string[]
45
+ | null
46
+ | undefined,
35
47
  ];
36
48
  "update:placeholder": [date: CalendarDate];
37
49
  "update:startValue": [date: CalendarDate | undefined];
@@ -42,22 +54,25 @@ function convertIsoStringToCalendarDate(value: string): CalendarDate {
42
54
  return parseDate(value.split("T")[0]!);
43
55
  }
44
56
 
45
- function convertDateRangeValue(
46
- rangeValue: StringDateRange | StrictDateRange | NativeDateRange,
47
- ): StrictDateRange | undefined {
48
- if (isString(rangeValue.start) && isString(rangeValue.end)) {
49
- return {
50
- start: convertIsoStringToCalendarDate(rangeValue.start),
51
- end: convertIsoStringToCalendarDate(rangeValue.end),
52
- };
57
+ function convertToCalendarDate(
58
+ value: string | Date | CalendarDate | undefined,
59
+ ): CalendarDate | undefined {
60
+ if (isString(value)) {
61
+ return convertIsoStringToCalendarDate(value);
53
62
  }
54
- if (rangeValue.start instanceof Date && rangeValue.end instanceof Date) {
55
- return {
56
- start: toCalendarDate(fromDate(rangeValue.start, "UTC")),
57
- end: toCalendarDate(fromDate(rangeValue.end, "UTC")),
58
- };
63
+ if (value instanceof Date) {
64
+ return toCalendarDate(fromDate(value, "UTC"));
59
65
  }
60
- return undefined;
66
+ return value;
67
+ }
68
+
69
+ function convertDateRangeValue(
70
+ rangeValue: StringDateRange | StrictDateRange | NativeDateRange,
71
+ ): StrictDateRange {
72
+ return {
73
+ start: convertToCalendarDate(rangeValue.start),
74
+ end: convertToCalendarDate(rangeValue.end),
75
+ };
61
76
  }
62
77
 
63
78
  function isDateRangeValue(
@@ -73,7 +88,10 @@ function convertModelValue(value: DateValue): DateValue {
73
88
  return convertIsoStringToCalendarDate(value);
74
89
  }
75
90
  if (isDateRangeValue(value)) {
76
- return convertDateRangeValue(value) ?? value;
91
+ return convertDateRangeValue(value);
92
+ }
93
+ if (Array.isArray(value)) {
94
+ return value.map(convertToCalendarDate) as CalendarDate[];
77
95
  }
78
96
  return value;
79
97
  }
@@ -88,7 +106,12 @@ type EmitValue = unknown;
88
106
 
89
107
  const wrappedEmits = (event: EmitEvent, value: EmitValue) => {
90
108
  if (event === "update:modelValue" && value) {
91
- if (
109
+ if (Array.isArray(value)) {
110
+ const isoStrings = (value as CalendarDate[]).map((date) =>
111
+ date.toString(),
112
+ );
113
+ emits("update:modelValue", isoStrings);
114
+ } else if (
92
115
  isObject(value) &&
93
116
  "year" in value &&
94
117
  "month" in value &&
@@ -98,8 +121,8 @@ const wrappedEmits = (event: EmitEvent, value: EmitValue) => {
98
121
  emits("update:modelValue", isoString);
99
122
  } else if (isObject(value) && "start" in value && "end" in value) {
100
123
  const dateRange = value as unknown as StrictDateRange;
101
- const startIso = dateRange.start.toString();
102
- const endIso = dateRange.end.toString();
124
+ const startIso = dateRange.start?.toString();
125
+ const endIso = dateRange.end?.toString();
103
126
  emits("update:modelValue", { start: startIso, end: endIso });
104
127
  } else {
105
128
  emits("update:modelValue", value as string | null | undefined);
@@ -13,29 +13,41 @@ import {
13
13
  } from "@internationalized/date";
14
14
  import { reactivePick } from "@vueuse/core";
15
15
 
16
+ // Mid-selection, the range calendar emits a range whose end is still unset.
16
17
  interface StrictDateRange {
17
- start: CalendarDate;
18
- end: CalendarDate;
18
+ start: CalendarDate | undefined;
19
+ end: CalendarDate | undefined;
19
20
  }
20
21
 
21
22
  interface StringDateRange {
22
- start: string;
23
- end: string;
23
+ start: string | undefined;
24
+ end: string | undefined;
24
25
  }
25
26
 
26
27
  interface NativeDateRange {
27
- start: Date;
28
- end: Date;
28
+ start: Date | undefined;
29
+ end: Date | undefined;
29
30
  }
30
31
 
31
- type DateValue = CalendarDate | StrictDateRange | string | null | undefined;
32
+ type DateValue =
33
+ | CalendarDate
34
+ | StrictDateRange
35
+ | string
36
+ | (string | Date | CalendarDate)[]
37
+ | null
38
+ | undefined;
32
39
 
33
40
  type DatePickerProps = Omit<CalendarProps<R, M>, "modelValue">;
34
41
 
35
42
  const props = defineProps<DatePickerProps>();
36
43
  const emits = defineEmits<{
37
44
  "update:modelValue": [
38
- value: string | { start: string; end: string } | null | undefined,
45
+ value:
46
+ | string
47
+ | { start: string | undefined; end: string | undefined }
48
+ | string[]
49
+ | null
50
+ | undefined,
39
51
  ];
40
52
  "update:placeholder": [date: CalendarDate];
41
53
  "update:startValue": [date: CalendarDate | undefined];
@@ -50,22 +62,25 @@ function convertIsoStringToCalendarDate(value: string): CalendarDate {
50
62
  return parseDate(value.split("T")[0]!);
51
63
  }
52
64
 
53
- function convertDateRangeValue(
54
- rangeValue: StringDateRange | StrictDateRange | NativeDateRange,
55
- ): StrictDateRange | undefined {
56
- if (isString(rangeValue.start) && isString(rangeValue.end)) {
57
- return {
58
- start: convertIsoStringToCalendarDate(rangeValue.start),
59
- end: convertIsoStringToCalendarDate(rangeValue.end),
60
- };
65
+ function convertToCalendarDate(
66
+ value: string | Date | CalendarDate | undefined,
67
+ ): CalendarDate | undefined {
68
+ if (isString(value)) {
69
+ return convertIsoStringToCalendarDate(value);
61
70
  }
62
- if (rangeValue.start instanceof Date && rangeValue.end instanceof Date) {
63
- return {
64
- start: toCalendarDate(fromDate(rangeValue.start, "UTC")),
65
- end: toCalendarDate(fromDate(rangeValue.end, "UTC")),
66
- };
71
+ if (value instanceof Date) {
72
+ return toCalendarDate(fromDate(value, "UTC"));
67
73
  }
68
- return undefined;
74
+ return value;
75
+ }
76
+
77
+ function convertDateRangeValue(
78
+ rangeValue: StringDateRange | StrictDateRange | NativeDateRange,
79
+ ): StrictDateRange {
80
+ return {
81
+ start: convertToCalendarDate(rangeValue.start),
82
+ end: convertToCalendarDate(rangeValue.end),
83
+ };
69
84
  }
70
85
 
71
86
  function isDateRangeValue(
@@ -81,7 +96,10 @@ function convertModelValue(value: DateValue): DateValue {
81
96
  return convertIsoStringToCalendarDate(value);
82
97
  }
83
98
  if (isDateRangeValue(value)) {
84
- return convertDateRangeValue(value) ?? value;
99
+ return convertDateRangeValue(value);
100
+ }
101
+ if (Array.isArray(value)) {
102
+ return value.map(convertToCalendarDate) as CalendarDate[];
85
103
  }
86
104
  return value;
87
105
  }
@@ -96,7 +114,12 @@ type EmitValue = unknown;
96
114
 
97
115
  const wrappedEmits = (event: EmitEvent, value: EmitValue) => {
98
116
  if (event === "update:modelValue" && value) {
99
- if (
117
+ if (Array.isArray(value)) {
118
+ const isoStrings = (value as CalendarDate[]).map((date) =>
119
+ date.toString(),
120
+ );
121
+ emits("update:modelValue", isoStrings);
122
+ } else if (
100
123
  isObject(value) &&
101
124
  "year" in value &&
102
125
  "month" in value &&
@@ -106,8 +129,8 @@ const wrappedEmits = (event: EmitEvent, value: EmitValue) => {
106
129
  emits("update:modelValue", isoString);
107
130
  } else if (isObject(value) && "start" in value && "end" in value) {
108
131
  const dateRange = value as unknown as StrictDateRange;
109
- const startIso = dateRange.start.toString();
110
- const endIso = dateRange.end.toString();
132
+ const startIso = dateRange.start?.toString();
133
+ const endIso = dateRange.end?.toString();
111
134
  emits("update:modelValue", { start: startIso, end: endIso });
112
135
  } else {
113
136
  emits("update:modelValue", value as string | null | undefined);
@@ -123,18 +146,40 @@ const forwarded = useForwardPropsEmits(
123
146
  wrappedEmits as typeof emits,
124
147
  );
125
148
  const buttonProps = reactivePick(props, "disabled");
149
+
150
+ function formatDateLabel(value: unknown): string | undefined {
151
+ if (value === undefined || value === null) {
152
+ return undefined;
153
+ }
154
+ // A CalendarDate stringifies to its ISO date.
155
+ const date = value instanceof Date ? value : String(value);
156
+ return formatDate(date, locale.value) ?? undefined;
157
+ }
158
+
159
+ // Range mode holds a { start, end } object and multiple mode an array, which
160
+ // formatDate cannot read as a single date.
161
+ const label = computed(() => {
162
+ const value: unknown = modelValue.value;
163
+ if (Array.isArray(value)) {
164
+ return value.map(formatDateLabel).filter(Boolean).join(", ") || undefined;
165
+ }
166
+ if (isDateRangeValue(value)) {
167
+ return (
168
+ [formatDateLabel(value.start), formatDateLabel(value.end)]
169
+ .filter(Boolean)
170
+ .join(" – ") || undefined
171
+ );
172
+ }
173
+ return formatDateLabel(value);
174
+ });
126
175
  </script>
127
176
 
128
177
  <template>
129
178
  <UPopover>
130
179
  <UButton
131
- :label="
132
- modelValue
133
- ? (formatDate(modelValue, locale) ?? undefined)
134
- : t('dms.form.select_date')
135
- "
180
+ :label="label ?? t('dms.form.select_date')"
136
181
  :ui="{
137
- label: modelValue ? 'text-default' : 'text-dimmed',
182
+ label: label ? 'text-default' : 'text-dimmed',
138
183
  }"
139
184
  v-bind="buttonProps"
140
185
  variant="outline"
@@ -1,29 +1,8 @@
1
1
  import { onBeforeUnmount, onMounted, ref, type Ref } from "vue";
2
2
  import { clearChartColorCache } from "./useChartTheme";
3
3
 
4
- interface ColorModeLike {
5
- preference?: string;
6
- value?: string;
7
- $subscribe?: (cb: () => void) => () => void;
8
- }
9
-
10
- type ColorModeFactory = () => ColorModeLike;
11
-
12
4
  const THEME_OBSERVED_ATTRIBUTES = ["class", "data-theme", "style"];
13
5
 
14
- function getColorModeFactory(): ColorModeFactory | undefined {
15
- const fn = (globalThis as unknown as { useColorMode?: ColorModeFactory })
16
- .useColorMode;
17
- return typeof fn === "function" ? fn : undefined;
18
- }
19
-
20
- function trySubscribeColorMode(bump: () => void) {
21
- const factory = getColorModeFactory();
22
- if (!factory) return;
23
- const mode = factory();
24
- mode?.$subscribe?.(bump);
25
- }
26
-
27
6
  function createThemeObserver(bump: () => void): MutationObserver {
28
7
  const observer = new MutationObserver(bump);
29
8
  observer.observe(document.documentElement, {
@@ -47,7 +26,6 @@ export function useThemeRevision(): Ref<number> {
47
26
 
48
27
  onMounted(() => {
49
28
  observer = createThemeObserver(bump);
50
- trySubscribeColorMode(bump);
51
29
  });
52
30
 
53
31
  onBeforeUnmount(() => {
@@ -258,6 +258,28 @@ export function buildValidationSchema(
258
258
  return z.object(newShape).passthrough();
259
259
  }
260
260
 
261
+ // A switch always holds a value (off is `false`): it can never be left
262
+ // missing, so marking it required would only suggest an action that does not
263
+ // exist.
264
+ const ALWAYS_FILLED_FIELD_TYPES = new Set(["boolean"]);
265
+
266
+ /**
267
+ * Whether the form marks a field as required: declared so, or made so by a
268
+ * watch action, and neither disabled nor hidden, since `buildValidationSchema`
269
+ * does not validate inactive fields, nor a type that always holds a value.
270
+ */
271
+ export function isFieldMarkedRequired(
272
+ field: Pick<FormField, "id" | "required" | "disabled" | "type">,
273
+ disabled: Set<string> | undefined,
274
+ hidden: Set<string> | undefined,
275
+ required: Set<string> | undefined,
276
+ ): boolean {
277
+ if (field.disabled || disabled?.has(field.id) || hidden?.has(field.id))
278
+ return false;
279
+ if (field.type && ALWAYS_FILLED_FIELD_TYPES.has(field.type)) return false;
280
+ return !!field.required || (required?.has(field.id) ?? false);
281
+ }
282
+
261
283
  function watchFieldChanges(
262
284
  state: Ref<Record<string, unknown>>,
263
285
  componentId: string | undefined,
@@ -546,6 +568,7 @@ export const useForm = (props: FormProps) => {
546
568
  allFields,
547
569
  disabledFields,
548
570
  hiddenFields,
571
+ requiredFields,
549
572
  isFieldGroup,
550
573
  submitSucceeded,
551
574
  resolvedFetchUrl,
@@ -169,6 +169,7 @@
169
169
  "row_added_success": "The item has been successfully added",
170
170
  "error_missing_fields": "Missing required fields",
171
171
  "error_fill_all_fields": "Please fill in all required fields",
172
+ "required_legend": "Required fields",
172
173
  "fetch_error_title": "Failed to fetch data",
173
174
  "fetch_error_unknown": "An unexpected error occurred. Please try again later.",
174
175
  "select_date": "Select date",
@@ -169,6 +169,7 @@
169
169
  "row_added_success": "L'élément a été ajouté avec succès",
170
170
  "error_missing_fields": "Champs requis manquants",
171
171
  "error_fill_all_fields": "Veuillez remplir tous les champs requis",
172
+ "required_legend": "Champs requis",
172
173
  "error_not_submitted": "Le formulaire n'a pas été soumis",
173
174
  "fetch_error_title": "Échec de la récupération des données",
174
175
  "fetch_error_unknown": "Une erreur inattendue s'est produite. Veuillez réessayer plus tard.",
@@ -39,7 +39,7 @@
39
39
  "@vue-flow/controls": "^1.1.3",
40
40
  "@vue-flow/core": "^1.48.2",
41
41
  "@vue-flow/minimap": "^1.5.4",
42
- "@vueuse/core": "^13.0.0",
42
+ "@vueuse/core": "^14.3.0",
43
43
  "@vueuse/integrations": "^14.1.0",
44
44
  "apexcharts": "^4.4.0",
45
45
  "defu": "^6.1.4",
@@ -59,7 +59,7 @@
59
59
  },
60
60
  "devDependencies": {
61
61
  "@antelopejs/core": ">=1.9.1 <2",
62
- "@antelopejs/dms-frontend": ">=0.2.6 <1.0.0",
62
+ "@antelopejs/dms-frontend": ">=0.2.8 <1.0.0",
63
63
  "@eslint/js": "^8.57.0",
64
64
  "@inertiajs/vue3": "^3.7.0",
65
65
  "@nuxt/ui": "4.10.0",
@@ -66,8 +66,8 @@ importers:
66
66
  specifier: ^1.5.4
67
67
  version: 1.5.4(@vue-flow/core@1.48.2(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3))
68
68
  '@vueuse/core':
69
- specifier: ^13.0.0
70
- version: 13.9.0(vue@3.5.25(typescript@5.9.3))
69
+ specifier: ^14.3.0
70
+ version: 14.4.0(vue@3.5.25(typescript@5.9.3))
71
71
  '@vueuse/integrations':
72
72
  specifier: ^14.1.0
73
73
  version: 14.1.0(focus-trap@7.8.0)(fuse.js@7.5.0)(sortablejs@1.14.0)(vue@3.5.25(typescript@5.9.3))
@@ -124,8 +124,8 @@ importers:
124
124
  specifier: '>=1.9.1 <2'
125
125
  version: 1.9.1(@types/node@24.8.1)
126
126
  '@antelopejs/dms-frontend':
127
- specifier: '>=0.2.6 <1.0.0'
128
- version: 0.2.6(@antelopejs/core@1.9.1(@types/node@24.8.1))
127
+ specifier: '>=0.2.8 <1.0.0'
128
+ version: 0.2.8(@antelopejs/core@1.9.1(@types/node@24.8.1))
129
129
  '@eslint/js':
130
130
  specifier: ^8.57.0
131
131
  version: 8.57.0
@@ -200,8 +200,8 @@ packages:
200
200
  resolution: {integrity: sha512-MPLJak0rPluV4lgsdsO5ba03sgTxIGSncajBcoaoppuppQmksdpaemojIHiVLJ4LiG0iiH21Roaed8XXy1XkYQ==}
201
201
  hasBin: true
202
202
 
203
- '@antelopejs/dms-frontend@0.2.6':
204
- resolution: {integrity: sha512-dCR9EWkpW4CekV5M87igyCZyRNXt9fVr5M2t9neATj1JqN/r6MltYJd4ShKnilSN4zQZ4ykkuFL/b9b0k79rDw==}
203
+ '@antelopejs/dms-frontend@0.2.8':
204
+ resolution: {integrity: sha512-7qrlf2M3J26DZxGZVvM/3qjPDeDfoRMrE7HEPFxWav8Pe1QFct7LtgY7NmKup9XdrRyUw4nqckxvW8UZDSjpeg==}
205
205
  engines: {node: '>=20.12.0'}
206
206
  hasBin: true
207
207
  peerDependencies:
@@ -1753,11 +1753,6 @@ packages:
1753
1753
  '@vueuse/core@12.8.2':
1754
1754
  resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==}
1755
1755
 
1756
- '@vueuse/core@13.9.0':
1757
- resolution: {integrity: sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==}
1758
- peerDependencies:
1759
- vue: ^3.5.0
1760
-
1761
1756
  '@vueuse/core@14.1.0':
1762
1757
  resolution: {integrity: sha512-rgBinKs07hAYyPF834mDTigH7BtPqvZ3Pryuzt1SD/lg5wEcWqvwzXXYGEDb2/cP0Sj5zSvHl3WkmMELr5kfWw==}
1763
1758
  peerDependencies:
@@ -1858,9 +1853,6 @@ packages:
1858
1853
  '@vueuse/metadata@12.8.2':
1859
1854
  resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==}
1860
1855
 
1861
- '@vueuse/metadata@13.9.0':
1862
- resolution: {integrity: sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==}
1863
-
1864
1856
  '@vueuse/metadata@14.1.0':
1865
1857
  resolution: {integrity: sha512-7hK4g015rWn2PhKcZ99NyT+ZD9sbwm7SGvp7k+k+rKGWnLjS/oQozoIZzWfCewSUeBmnJkIb+CNr7Zc/EyRnnA==}
1866
1858
 
@@ -1873,11 +1865,6 @@ packages:
1873
1865
  '@vueuse/shared@12.8.2':
1874
1866
  resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
1875
1867
 
1876
- '@vueuse/shared@13.9.0':
1877
- resolution: {integrity: sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==}
1878
- peerDependencies:
1879
- vue: ^3.5.0
1880
-
1881
1868
  '@vueuse/shared@14.1.0':
1882
1869
  resolution: {integrity: sha512-EcKxtYvn6gx1F8z9J5/rsg3+lTQnvOruQd8fUecW99DCK04BkWD7z5KQ/wTAx+DazyoEE9dJt/zV8OIEQbM6kw==}
1883
1870
  peerDependencies:
@@ -4344,7 +4331,7 @@ snapshots:
4344
4331
  transitivePeerDependencies:
4345
4332
  - '@types/node'
4346
4333
 
4347
- '@antelopejs/dms-frontend@0.2.6(@antelopejs/core@1.9.1(@types/node@24.8.1))':
4334
+ '@antelopejs/dms-frontend@0.2.8(@antelopejs/core@1.9.1(@types/node@24.8.1))':
4348
4335
  dependencies:
4349
4336
  boxen: 5.1.2
4350
4337
  chalk: 4.1.2
@@ -5944,13 +5931,6 @@ snapshots:
5944
5931
  transitivePeerDependencies:
5945
5932
  - typescript
5946
5933
 
5947
- '@vueuse/core@13.9.0(vue@3.5.25(typescript@5.9.3))':
5948
- dependencies:
5949
- '@types/web-bluetooth': 0.0.21
5950
- '@vueuse/metadata': 13.9.0
5951
- '@vueuse/shared': 13.9.0(vue@3.5.25(typescript@5.9.3))
5952
- vue: 3.5.25(typescript@5.9.3)
5953
-
5954
5934
  '@vueuse/core@14.1.0(vue@3.5.25(typescript@5.9.3))':
5955
5935
  dependencies:
5956
5936
  '@types/web-bluetooth': 0.0.21
@@ -5989,8 +5969,6 @@ snapshots:
5989
5969
 
5990
5970
  '@vueuse/metadata@12.8.2': {}
5991
5971
 
5992
- '@vueuse/metadata@13.9.0': {}
5993
-
5994
5972
  '@vueuse/metadata@14.1.0': {}
5995
5973
 
5996
5974
  '@vueuse/metadata@14.4.0': {}
@@ -6008,10 +5986,6 @@ snapshots:
6008
5986
  transitivePeerDependencies:
6009
5987
  - typescript
6010
5988
 
6011
- '@vueuse/shared@13.9.0(vue@3.5.25(typescript@5.9.3))':
6012
- dependencies:
6013
- vue: 3.5.25(typescript@5.9.3)
6014
-
6015
5989
  '@vueuse/shared@14.1.0(vue@3.5.25(typescript@5.9.3))':
6016
5990
  dependencies:
6017
5991
  vue: 3.5.25(typescript@5.9.3)
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The color-mode plugin as the server runs it: the `<html>` tag of the rendered
3
+ * document already carries an explicit preference, so the first paint is right
4
+ * without waiting for JavaScript, and `system` is left to the pre-paint script.
5
+ */
6
+ import { useHead } from "@unhead/vue";
7
+ import { createHead, renderSSRHead } from "@unhead/vue/server";
8
+ import { createSSRApp, ref } from "vue";
9
+ import { beforeEach, expect, it, vi } from "vitest";
10
+
11
+ interface CookieOptions {
12
+ default: () => unknown;
13
+ maxAge?: number;
14
+ }
15
+
16
+ const COLOR_MODE_COOKIE = "dms-color-mode";
17
+ const ONE_YEAR_IN_SECONDS = 60 * 60 * 24 * 365;
18
+ const requestCookies = new Map<string, unknown>();
19
+ const cookieReads = vi.fn();
20
+
21
+ vi.stubGlobal("defineDmsPlugin", (setup: unknown) => setup);
22
+ vi.stubGlobal("useHead", useHead);
23
+ vi.stubGlobal("useDmsCookie", (name: string, options: CookieOptions) => {
24
+ cookieReads(name, options);
25
+ return ref(
26
+ requestCookies.has(name) ? requestCookies.get(name) : options.default(),
27
+ );
28
+ });
29
+
30
+ const { default: colorModePlugin } = await import(
31
+ "../layers/dms-layout/app/plugins/color-mode"
32
+ );
33
+
34
+ async function renderHead(preference?: string) {
35
+ if (preference !== undefined)
36
+ requestCookies.set(COLOR_MODE_COOKIE, preference);
37
+ const app = createSSRApp({ render: () => null });
38
+ const head = createHead();
39
+ app.use(head);
40
+ await app.runWithContext(() => colorModePlugin({} as never));
41
+ return renderSSRHead(head);
42
+ }
43
+
44
+ beforeEach(() => {
45
+ requestCookies.clear();
46
+ cookieReads.mockClear();
47
+ });
48
+
49
+ it.each(["light", "dark"])(
50
+ "renders the %s class on <html> for an explicit preference",
51
+ async (preference) => {
52
+ const { htmlAttrs } = await renderHead(preference);
53
+
54
+ expect(htmlAttrs).toContain(` class="${preference}"`);
55
+ },
56
+ );
57
+
58
+ it.each([undefined, "system", "sepia"])(
59
+ "leaves the class to the browser for a %s preference",
60
+ async (preference) => {
61
+ const { htmlAttrs } = await renderHead(preference);
62
+
63
+ expect(htmlAttrs).not.toContain("class");
64
+ },
65
+ );
66
+
67
+ it("reads the dms-color-mode cookie, kept a year and defaulting to system", async () => {
68
+ await renderHead();
69
+
70
+ expect(cookieReads).toHaveBeenCalledOnce();
71
+ const [name, options] = cookieReads.mock.calls[0] as [string, CookieOptions];
72
+ expect(name).toBe(COLOR_MODE_COOKIE);
73
+ expect(options.maxAge).toBe(ONE_YEAR_IN_SECONDS);
74
+ expect(options.default()).toBe("system");
75
+ });
76
+
77
+ it("ships the pre-paint script as a classic inline script in <head>", async () => {
78
+ // No src, type, defer or async: the browser runs it while it parses <head>,
79
+ // before the deferred entry module that installs Nuxt UI.
80
+ const { headTags, bodyTags, bodyTagsOpen } = await renderHead("dark");
81
+
82
+ expect(headTags).toMatch(/<script id="dms-color-mode">\(\(\) => \{/);
83
+ expect(bodyTags + bodyTagsOpen).toBe("");
84
+ });