@antelopejs/dms 0.4.2 → 0.4.4

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 (51) hide show
  1. package/CHANGELOG.md +43 -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/test/unit/interfaces/dms-base/form-catalog.test.d.ts +1 -0
  8. package/dist/test/unit/interfaces/dms-base/form-catalog.test.js +62 -0
  9. package/dist/test/unit/interfaces/dms-base/form-catalog.test.js.map +1 -0
  10. package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.d.ts +1 -0
  11. package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.js +36 -0
  12. package/dist/test/unit/interfaces/dms-base/period-selector-catalog.test.js.map +1 -0
  13. package/dist/test/unit/interfaces/dms-base/resource-form.test.d.ts +1 -0
  14. package/dist/test/unit/interfaces/dms-base/resource-form.test.js +117 -0
  15. package/dist/test/unit/interfaces/dms-base/resource-form.test.js.map +1 -0
  16. package/dist/test/unit/interfaces/dms-base/table-and-card-catalog.test.d.ts +1 -0
  17. package/dist/test/unit/interfaces/dms-base/table-and-card-catalog.test.js +62 -0
  18. package/dist/test/unit/interfaces/dms-base/table-and-card-catalog.test.js.map +1 -0
  19. package/frontend-vue/dms.frontend.ts +2 -0
  20. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardHeader.vue +4 -2
  21. package/frontend-vue/layers/dms-layout/app/composables/general/types/index.ts +7 -0
  22. package/frontend-vue/layers/dms-layout/app/composables/general/useColorModePreference.ts +17 -0
  23. package/frontend-vue/layers/dms-layout/app/custom-pages/settings/appearance.vue +6 -6
  24. package/frontend-vue/layers/dms-layout/app/plugins/color-mode.ts +100 -0
  25. package/frontend-vue/layers/dms-ui/app/components/Placeholder.vue +17 -5
  26. package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +14 -9
  27. package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +49 -6
  28. package/frontend-vue/layers/dms-ui/app/components/form/components/Calendar.vue +49 -26
  29. package/frontend-vue/layers/dms-ui/app/components/form/components/DatePicker.vue +77 -32
  30. package/frontend-vue/layers/dms-ui/app/components/grid/Grid.vue +15 -5
  31. package/frontend-vue/layers/dms-ui/app/components/grid/GridRow.vue +7 -4
  32. package/frontend-vue/layers/dms-ui/app/components/grid/constants.ts +8 -1
  33. package/frontend-vue/layers/dms-ui/app/components/kpi/KpiCard.vue +14 -9
  34. package/frontend-vue/layers/dms-ui/app/composables/chart/formatValue.ts +15 -0
  35. package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +4 -2
  36. package/frontend-vue/layers/dms-ui/app/composables/chart/useThemeRevision.ts +0 -22
  37. package/frontend-vue/layers/dms-ui/app/composables/form/types/props.ts +2 -0
  38. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +76 -4
  39. package/frontend-vue/layers/dms-ui/app/composables/tree/useTree.ts +5 -7
  40. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +2 -0
  41. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +2 -0
  42. package/frontend-vue/package.json +2 -2
  43. package/frontend-vue/pnpm-lock.yaml +7 -33
  44. package/frontend-vue/tests/chart-absent-value.test.ts +78 -0
  45. package/frontend-vue/tests/color-mode-server-render.test.ts +84 -0
  46. package/frontend-vue/tests/color-mode-sync.test.ts +320 -0
  47. package/frontend-vue/tests/form-actions.test.ts +27 -0
  48. package/frontend-vue/tests/form-required-fields.test.ts +44 -0
  49. package/frontend-vue/tests/form-submit-target.test.ts +28 -0
  50. package/frontend-vue/tests/tree-without-source.test.ts +79 -0
  51. package/package.json +1 -1
@@ -3,7 +3,7 @@ import ThemePreview from "../../build/components/pages/settings/theme/ThemePrevi
3
3
  import ScalePreview from "../../build/components/pages/settings/theme/ScalePreview.vue";
4
4
  import CssVariablesList from "../../build/components/pages/settings/theme/CssVariablesList.vue";
5
5
 
6
- const colorOptions = [
6
+ const colorOptions: ColorModeOption[] = [
7
7
  {
8
8
  value: "system",
9
9
  label: "$page.settings.appearance.system",
@@ -36,7 +36,7 @@ const scaleOptions: ScaleOption[] = [
36
36
  },
37
37
  ];
38
38
 
39
- const colorMode = useColorMode();
39
+ const colorModePreference = useColorModePreference();
40
40
  const interfaceScale = useInterfaceScale();
41
41
  const { processI18n } = useTranslation();
42
42
  </script>
@@ -60,13 +60,13 @@ const { processI18n } = useTranslation();
60
60
  :key="`color-option-${index}`"
61
61
  class="group bg-elevated relative flex cursor-pointer flex-col overflow-hidden rounded-xl transition-all"
62
62
  :class="
63
- colorMode.preference === option.value
63
+ colorModePreference === option.value
64
64
  ? 'outline-primary outline-2 outline-offset-2'
65
65
  : 'ring-default hover:ring-accented ring hover:-translate-y-0.5'
66
66
  "
67
67
  >
68
68
  <input
69
- v-model="colorMode.preference"
69
+ v-model="colorModePreference"
70
70
  type="radio"
71
71
  :value="option.value"
72
72
  class="sr-only"
@@ -85,13 +85,13 @@ const { processI18n } = useTranslation();
85
85
  <span
86
86
  class="ring-accented grid size-4 shrink-0 place-items-center rounded-full ring ring-inset"
87
87
  :class="
88
- colorMode.preference === option.value
88
+ colorModePreference === option.value
89
89
  ? 'bg-primary ring-primary'
90
90
  : ''
91
91
  "
92
92
  >
93
93
  <span
94
- v-if="colorMode.preference === option.value"
94
+ v-if="colorModePreference === option.value"
95
95
  class="bg-default size-1.5 rounded-full"
96
96
  />
97
97
  </span>
@@ -0,0 +1,100 @@
1
+ import {
2
+ type BasicColorSchema,
3
+ useColorMode as useVueUseColorMode,
4
+ } from "@vueuse/core";
5
+ import { type Ref, watch } from "vue";
6
+ import type { ColorModePreference } from "../composables/general/types";
7
+ import {
8
+ COLOR_MODE_COOKIE,
9
+ useColorModePreference,
10
+ } from "../composables/general/useColorModePreference";
11
+
12
+ type ExplicitColorMode = Exclude<ColorModePreference, "system">;
13
+
14
+ /** The preferences that pin their class on `<html>`; any other follows the system. */
15
+ const EXPLICIT_COLOR_MODES: ExplicitColorMode[] = ["light", "dark"];
16
+ const SYSTEM_PREFERENCE: ColorModePreference = "system";
17
+ const VUEUSE_SYSTEM_MODE: BasicColorSchema = "auto";
18
+ /**
19
+ * vueuse's default key. Nuxt UI's `useDark()`, installed with the app, and its
20
+ * color-mode components (`UDashboardSearch`'s theme group…) all read it.
21
+ */
22
+ const VUEUSE_STORAGE_KEY = "vueuse-color-scheme";
23
+ const DARK_SCHEME_QUERY = "(prefers-color-scheme: dark)";
24
+ const PRE_PAINT_SCRIPT_ID = "dms-color-mode";
25
+
26
+ /**
27
+ * Runs in `<head>` before first paint, and so before the entry module installs
28
+ * Nuxt UI: it hands vueuse the cookie's mode, which `useDark()` reads on boot,
29
+ * and sets the `<html>` class, resolving `system` itself. The cookie is read
30
+ * the way `useDmsCookie` writes it (URI-encoded JSON), falling back to the raw
31
+ * value. Source text rather than a serialized function, which a bundler could
32
+ * fill with helpers the page does not have.
33
+ */
34
+ const PRE_PAINT_SCRIPT = `(() => {
35
+ const modes = ${JSON.stringify(EXPLICIT_COLOR_MODES)};
36
+ const prefix = ${JSON.stringify(`${COLOR_MODE_COOKIE}=`)};
37
+ const entry = document.cookie.split("; ").find((cookie) => cookie.startsWith(prefix)) ?? "";
38
+ let preference = "";
39
+ try {
40
+ preference = decodeURIComponent(entry.slice(prefix.length));
41
+ preference = JSON.parse(preference);
42
+ } catch {}
43
+ const mode = modes.includes(preference) ? preference : ${JSON.stringify(VUEUSE_SYSTEM_MODE)};
44
+ try {
45
+ localStorage.setItem(${JSON.stringify(VUEUSE_STORAGE_KEY)}, mode);
46
+ } catch {}
47
+ const system = matchMedia(${JSON.stringify(DARK_SCHEME_QUERY)}).matches ? "dark" : "light";
48
+ document.documentElement.classList.remove(...modes);
49
+ document.documentElement.classList.add(modes.includes(mode) ? mode : system);
50
+ })();`;
51
+
52
+ function explicitColorMode(value: string): ExplicitColorMode | undefined {
53
+ return EXPLICIT_COLOR_MODES.find((mode) => mode === value);
54
+ }
55
+
56
+ /**
57
+ * Mirrors the cookie, the source of truth, into the vueuse store Nuxt UI
58
+ * shares, and a change made from a Nuxt UI component back into the cookie.
59
+ * Each side is written only when it would change, so neither echoes the other.
60
+ */
61
+ function syncWithVueUse(preference: Ref<ColorModePreference>): void {
62
+ const { store } = useVueUseColorMode({ storageKey: VUEUSE_STORAGE_KEY });
63
+ watch(
64
+ preference,
65
+ (next) => {
66
+ const mode = explicitColorMode(next) ?? VUEUSE_SYSTEM_MODE;
67
+ if (store.value !== mode) store.value = mode;
68
+ },
69
+ { immediate: true },
70
+ );
71
+ watch(store, (next) => {
72
+ const nextPreference = explicitColorMode(next) ?? SYSTEM_PREFERENCE;
73
+ if (preference.value !== nextPreference) preference.value = nextPreference;
74
+ });
75
+ }
76
+
77
+ /**
78
+ * The server renders an explicit preference's class, so the first paint is
79
+ * right before any script runs, and the pre-paint script, which only matters
80
+ * before that paint: inserted later by the browser head it would run after
81
+ * boot and redo what the sync already did. In the browser the class belongs to
82
+ * vueuse, which Nuxt UI already drives it with: were unhead tracking it as
83
+ * well, it would remove it when `dark` gives way to a `system` that still
84
+ * resolves dark, and vueuse, seeing no change, would not put it back. A page
85
+ * rendered without the script (client-side fallback) is corrected by the sync,
86
+ * which applies the cookie on boot.
87
+ */
88
+ export default defineDmsPlugin(() => {
89
+ const preference = useColorModePreference();
90
+
91
+ if (!import.meta.env.SSR) {
92
+ syncWithVueUse(preference);
93
+ return;
94
+ }
95
+ const mode = explicitColorMode(preference.value);
96
+ useHead({
97
+ htmlAttrs: mode ? { class: mode } : {},
98
+ script: [{ id: PRE_PAINT_SCRIPT_ID, innerHTML: PRE_PAINT_SCRIPT }],
99
+ });
100
+ });
@@ -5,19 +5,31 @@ interface PlaceholderProps {
5
5
  width?: string;
6
6
  }
7
7
 
8
+ /** What a box nobody sized is worth showing at, when nothing stretches it. */
9
+ const MIN_HEIGHT = "120px";
10
+
8
11
  const props = withDefaults(defineProps<PlaceholderProps>(), {
9
12
  label: undefined,
10
- height: "120px",
13
+ height: undefined,
11
14
  width: undefined,
12
15
  });
16
+
17
+ /**
18
+ * A placeholder stands in for content still to come, so it takes the room it is
19
+ * given: a height of its own would keep it short in a row sized by its tallest
20
+ * cell, and a layout laid out with these could not be read for what it will be.
21
+ * A given height still wins, and a floor keeps a lone one visible.
22
+ */
23
+ const box = computed(() => ({
24
+ height: props.height,
25
+ minHeight: props.height ? undefined : MIN_HEIGHT,
26
+ width: props.width,
27
+ }));
13
28
  </script>
14
29
 
15
30
  <template>
16
31
  <div
17
- :style="{
18
- height: props.height,
19
- width: props.width,
20
- }"
32
+ :style="box"
21
33
  class="border-accented relative flex items-center justify-center overflow-hidden rounded-md border border-dashed px-4 opacity-75"
22
34
  >
23
35
  <svg
@@ -1,7 +1,10 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, provide, reactive, watchEffect } from "vue";
3
3
  import { useChartFetch } from "../../composables/chart/useChartFetch";
4
- import { formatValue } from "../../composables/chart/formatValue";
4
+ import {
5
+ ABSENT_VALUE_TEXT,
6
+ formatValue,
7
+ } from "../../composables/chart/formatValue";
5
8
  import { resolveChartColor } from "../../composables/chart/useChartTheme";
6
9
  import { useThemeRevision } from "../../composables/chart/useThemeRevision";
7
10
  import {
@@ -64,7 +67,7 @@ const { data, isLoading } = useChartFetch<ChartCardResponse>({
64
67
  watchSource: () => watchKey.value,
65
68
  });
66
69
 
67
- const value = computed(() => data.value?.value ?? 0);
70
+ const value = computed(() => data.value?.value);
68
71
  const previousValue = computed(() => data.value?.previousValue);
69
72
  const delta = computed(() => data.value?.delta ?? null);
70
73
 
@@ -87,13 +90,15 @@ const cardComparisonSeries = computed<ChartSeries[]>(() =>
87
90
  );
88
91
 
89
92
  const formatted = computed(() =>
90
- formatValue(
91
- value.value,
92
- props.valueFormat,
93
- locale.value,
94
- props.currencyCode,
95
- props.valuePrecision,
96
- ),
93
+ value.value === undefined
94
+ ? ABSENT_VALUE_TEXT
95
+ : formatValue(
96
+ value.value,
97
+ props.valueFormat,
98
+ locale.value,
99
+ props.currencyCode,
100
+ props.valuePrecision,
101
+ ),
97
102
  );
98
103
 
99
104
  const previousFormatted = computed(() =>
@@ -1,7 +1,11 @@
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
+ formShowsActions,
7
+ isFieldMarkedRequired,
8
+ } from "../../composables/form/useForm";
5
9
  import {
6
10
  FORM_VALIDATOR_KEY,
7
11
  type FormValidator,
@@ -17,7 +21,12 @@ const REALTIME_ROW_TOPIC_PREFIX = "tableview:row:";
17
21
  const REALTIME_GET_SEGMENT = "/get";
18
22
  const REALTIME_EVENT_UPDATED = "updated";
19
23
 
20
- const props = defineProps<FormProps>();
24
+ // `showActions` left out has to read as left out: Vue casts an absent boolean
25
+ // prop to `false`, which would hide the buttons of every form not asking for
26
+ // them.
27
+ const props = withDefaults(defineProps<FormProps>(), {
28
+ showActions: undefined,
29
+ });
21
30
  const form = useTemplateRef("form");
22
31
 
23
32
  // Inside a DynamicModal/DynamicDrawer the container already provides the
@@ -38,15 +47,13 @@ const {
38
47
  allFields,
39
48
  disabledFields,
40
49
  hiddenFields,
50
+ requiredFields,
41
51
  isFieldGroup,
42
52
  submitSucceeded,
43
53
  resolvedFetchUrl,
44
54
  } = useForm(props);
45
55
 
46
- const showActions = computed(
47
- () =>
48
- !!props.submitUrl && !toValue(allFields).every((field) => field.disabled),
49
- );
56
+ const showActions = computed(() => formShowsActions(props, toValue(allFields)));
50
57
 
51
58
  const { addGuard } = useLeaveGuard();
52
59
  const { confirm } = useConfirm();
@@ -200,6 +207,23 @@ function isGroupVisible(group: { fields: FormField[] }): boolean {
200
207
  return group.fields.some((field) => !isFieldHidden(field));
201
208
  }
202
209
 
210
+ function isFieldRequired(field: FormField): boolean {
211
+ return isFieldMarkedRequired(
212
+ field,
213
+ disabledFields.value,
214
+ hiddenFields.value,
215
+ requiredFields.value,
216
+ );
217
+ }
218
+
219
+ function isGroupRequired(group: { fields: FormField[] }): boolean {
220
+ return group.fields.some(isFieldRequired);
221
+ }
222
+
223
+ const hasRequiredFields = computed(() =>
224
+ toValue(allFields).some(isFieldRequired),
225
+ );
226
+
203
227
  function shouldShowDisplay(field: FormField): boolean {
204
228
  return isFieldDisabled(field) && !!field.type;
205
229
  }
@@ -379,6 +403,13 @@ onUnmounted(async () => {
379
403
  class="text-default block text-base font-semibold sm:text-sm"
380
404
  >
381
405
  {{ processI18n(item.label || "") }}
406
+ <span
407
+ v-if="isGroupRequired(item)"
408
+ class="text-error ms-0.5"
409
+ aria-hidden="true"
410
+ >
411
+ *
412
+ </span>
382
413
  </label>
383
414
  <p v-if="item.description" class="text-dimmed text-xs">
384
415
  {{ processI18n(item.description || "") }}
@@ -453,6 +484,13 @@ onUnmounted(async () => {
453
484
  class="text-default block text-base font-semibold sm:text-sm"
454
485
  >
455
486
  {{ processI18n(item.label || "") }}
487
+ <span
488
+ v-if="isFieldRequired(item)"
489
+ class="text-error ms-0.5"
490
+ aria-hidden="true"
491
+ >
492
+ *
493
+ </span>
456
494
  </label>
457
495
  <p v-if="item.description" class="text-dimmed text-xs">
458
496
  {{ processI18n(item.description || "") }}
@@ -509,6 +547,11 @@ onUnmounted(async () => {
509
547
  </template>
510
548
  </div>
511
549
 
550
+ <p v-if="hasRequiredFields" class="text-dimmed mt-6 text-xs">
551
+ <span class="text-error" aria-hidden="true">*</span>
552
+ {{ $t("dms.form.required_legend") }}
553
+ </p>
554
+
512
555
  <template v-if="showActions">
513
556
  <section class="mt-6 flex justify-end gap-2">
514
557
  <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"
@@ -13,19 +13,29 @@ const props = withDefaults(defineProps<GridProps>(), {
13
13
  minColumnWidth: GRID_DEFAULT_MIN_COLUMN_WIDTH,
14
14
  });
15
15
 
16
- const rowColumnCounts = ref<number[]>([]);
16
+ /**
17
+ * Keyed by row rather than appended to: a list that only grows keeps counting
18
+ * rows that have gone and widths that were never asked for, so the grid ends up
19
+ * reserving columns nothing fills.
20
+ */
21
+ const rowColumnCounts = ref(new Map<symbol, number>());
17
22
 
18
23
  const maxColumns = computed(() => {
19
- if (rowColumnCounts.value.length === 0) return 1;
20
- return Math.max(...rowColumnCounts.value);
24
+ const counts = [...rowColumnCounts.value.values()].filter(
25
+ (count) => count > 0,
26
+ );
27
+ return counts.length === 0 ? 1 : Math.max(...counts);
21
28
  });
22
29
 
23
30
  const gapRef = computed(() => props.gap);
24
31
  const minColumnWidthRef = computed(() => props.minColumnWidth);
25
32
 
26
33
  provide<GridContext>(GRID_CONTEXT, {
27
- registerRowColumnCount: (columnCount: number) => {
28
- rowColumnCounts.value.push(columnCount);
34
+ setRowColumnCount: (row: symbol, columnCount: number) => {
35
+ rowColumnCounts.value.set(row, columnCount);
36
+ },
37
+ dropRow: (row: symbol) => {
38
+ rowColumnCounts.value.delete(row);
29
39
  },
30
40
  maxColumns,
31
41
  gap: gapRef,