nuxt-ui-tools 1.25.1 → 1.26.0
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.
- package/dist/module.json +1 -1
- package/dist/runtime/file-preview/components/shell/file-preview-header.d.vue.ts +2 -0
- package/dist/runtime/file-preview/components/shell/file-preview-header.vue +13 -7
- package/dist/runtime/file-preview/components/shell/file-preview-header.vue.d.ts +2 -0
- package/dist/runtime/file-preview/components/shell/file-preview-shell.vue +6 -0
- package/dist/runtime/file-preview/components/shell/file-preview-stage.d.vue.ts +5 -3
- package/dist/runtime/file-preview/components/shell/file-preview-stage.vue +4 -10
- package/dist/runtime/file-preview/components/shell/file-preview-stage.vue.d.ts +5 -3
- package/dist/runtime/form/components/root/form.vue +1 -0
- package/dist/runtime/form/composables/use-field-options.js +3 -1
- package/dist/runtime/form/composables/use-form-overlay-controller.js +1 -1
- package/dist/runtime/form/composables/use-form-root.d.ts +3 -1
- package/dist/runtime/form/composables/use-form-root.js +26 -11
- package/dist/runtime/form/composables/use-form-runtime.js +14 -4
- package/dist/runtime/form/composables/use-form-validation.d.ts +5 -2
- package/dist/runtime/form/composables/use-form-validation.js +51 -5
- package/dist/runtime/form/fields/custom-component/component.vue +2 -3
- package/dist/runtime/form/fields/date-family/component.vue +7 -6
- package/dist/runtime/form/fields/date-family/utils.d.ts +5 -0
- package/dist/runtime/form/fields/date-family/utils.js +13 -0
- package/dist/runtime/form/fields/number/component.vue +11 -5
- package/dist/runtime/form/fields/phone-number/component.vue +20 -1
- package/dist/runtime/form/types/api.d.ts +9 -2
- package/dist/runtime/form/types/options.d.ts +4 -1
- package/dist/runtime/form/types/runtime.d.ts +10 -0
- package/dist/runtime/form/types/schema.d.ts +5 -1
- package/dist/runtime/form/utils/api.js +1 -0
- package/dist/runtime/i18n/locales/en.js +12 -3
- package/dist/runtime/i18n/locales/fr.js +12 -3
- package/dist/runtime/i18n/types.d.ts +16 -0
- package/dist/runtime/table/components/filters/tags/date-filter-tag.vue +4 -0
- package/dist/runtime/table/types/filters.d.ts +17 -0
- package/dist/runtime/table/utils/filters/options.js +2 -1
- package/dist/runtime/table/utils/filters/preview/option-preview.js +4 -1
- package/package.json +1 -1
package/dist/module.json
CHANGED
|
@@ -16,12 +16,13 @@ const props = defineProps({
|
|
|
16
16
|
narrow: { type: Boolean, required: true },
|
|
17
17
|
canExpand: { type: Boolean, required: true },
|
|
18
18
|
expanded: { type: Boolean, required: true },
|
|
19
|
-
details: { type: Boolean, required: true }
|
|
19
|
+
details: { type: Boolean, required: true },
|
|
20
|
+
unavailable: { type: Boolean, required: true }
|
|
20
21
|
});
|
|
21
22
|
const emit = defineEmits(["previous", "next", "close", "expand", "details", "download", "open", "action"]);
|
|
22
23
|
const { t } = useUiToolsLocale();
|
|
23
24
|
const name = computed(() => splitFilePreviewName(props.item.name || t("filePreview.untitled")));
|
|
24
|
-
const downloadable = computed(() => props.item.file.download !== false);
|
|
25
|
+
const downloadable = computed(() => !props.unavailable && props.item.file.download !== false);
|
|
25
26
|
const actions = computed(() => props.item.file.actions ?? []);
|
|
26
27
|
const menu = computed(() => [
|
|
27
28
|
[
|
|
@@ -33,11 +34,13 @@ const menu = computed(() => [
|
|
|
33
34
|
onSelect: () => emit("download")
|
|
34
35
|
}
|
|
35
36
|
] : [],
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
...props.unavailable ? [] : [
|
|
38
|
+
{
|
|
39
|
+
icon: "i-lucide-external-link",
|
|
40
|
+
label: t("filePreview.openInNewTab"),
|
|
41
|
+
onSelect: () => emit("open")
|
|
42
|
+
}
|
|
43
|
+
]
|
|
41
44
|
],
|
|
42
45
|
actions.value.map((action) => ({
|
|
43
46
|
icon: action.icon,
|
|
@@ -124,11 +127,14 @@ const menu = computed(() => [
|
|
|
124
127
|
v-if="downloadable"
|
|
125
128
|
icon="i-lucide-download"
|
|
126
129
|
:label="t('filePreview.download')"
|
|
130
|
+
data-file-preview-header-download=""
|
|
127
131
|
@click="emit('download')"
|
|
128
132
|
/>
|
|
129
133
|
<FilePreviewButton
|
|
134
|
+
v-if="!unavailable"
|
|
130
135
|
icon="i-lucide-external-link"
|
|
131
136
|
:label="t('filePreview.openInNewTab')"
|
|
137
|
+
data-file-preview-open-tab=""
|
|
132
138
|
@click="emit('open')"
|
|
133
139
|
/>
|
|
134
140
|
<span class="mx-1 h-5 w-px shrink-0 bg-(--ui-border)" aria-hidden="true" />
|
|
@@ -46,6 +46,7 @@ const stage = useTemplateRef("stage");
|
|
|
46
46
|
const strip = useTemplateRef("strip");
|
|
47
47
|
const showDetails = ref(false);
|
|
48
48
|
const reported = shallowRef([]);
|
|
49
|
+
const failure = shallowRef(null);
|
|
49
50
|
const files = computed(() => props.instance.files.value);
|
|
50
51
|
const index = computed(() => props.instance.index.value);
|
|
51
52
|
const loop = computed(() => props.instance.options.loop === true);
|
|
@@ -59,6 +60,9 @@ const itemRenderer = computed(
|
|
|
59
60
|
);
|
|
60
61
|
const kindLabel = computed(() => filePreviewKindLabel(itemRenderer.value, t));
|
|
61
62
|
const source = useFilePreviewSource(() => display.value);
|
|
63
|
+
const unavailable = computed(
|
|
64
|
+
() => source.status.value === "error" || failure.value?.reason === "source"
|
|
65
|
+
);
|
|
62
66
|
const many = computed(() => files.value.length > 1);
|
|
63
67
|
const hasPrevious = computed(() => loop.value || index.value > 0);
|
|
64
68
|
const hasNext = computed(() => loop.value || index.value < files.value.length - 1);
|
|
@@ -194,6 +198,7 @@ async function runAction(action) {
|
|
|
194
198
|
:can-expand="canExpand"
|
|
195
199
|
:expanded="expanded"
|
|
196
200
|
:details="showDetails"
|
|
201
|
+
:unavailable="unavailable"
|
|
197
202
|
@previous="instance.handle.previous"
|
|
198
203
|
@next="instance.handle.next"
|
|
199
204
|
@close="emit('close')"
|
|
@@ -227,6 +232,7 @@ async function runAction(action) {
|
|
|
227
232
|
:kind-label="kindLabel"
|
|
228
233
|
:arrows="arrows"
|
|
229
234
|
@ready="reported = $event"
|
|
235
|
+
@failure="failure = $event"
|
|
230
236
|
@previous="instance.handle.previous"
|
|
231
237
|
@next="instance.handle.next"
|
|
232
238
|
@download="download"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { useFilePreviewSource } from '../../composables/use-file-preview-source.js';
|
|
2
|
-
import type { FilePreviewContainer, FilePreviewDetail, FilePreviewItem, FilePreviewRendererDefinition } from '../../types/index.js';
|
|
2
|
+
import type { FilePreviewContainer, FilePreviewDetail, FilePreviewItem, FilePreviewRendererDefinition, FilePreviewRendererError } from '../../types/index.js';
|
|
3
3
|
type __VLS_Props = {
|
|
4
4
|
item: FilePreviewItem;
|
|
5
5
|
display: FilePreviewItem;
|
|
@@ -12,20 +12,22 @@ type __VLS_Props = {
|
|
|
12
12
|
next: boolean;
|
|
13
13
|
} | null;
|
|
14
14
|
};
|
|
15
|
-
declare var
|
|
15
|
+
declare var __VLS_57: {};
|
|
16
16
|
type __VLS_Slots = {} & {
|
|
17
|
-
default?: (props: typeof
|
|
17
|
+
default?: (props: typeof __VLS_57) => any;
|
|
18
18
|
};
|
|
19
19
|
declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
20
20
|
previous: () => any;
|
|
21
21
|
ready: (details: readonly FilePreviewDetail[]) => any;
|
|
22
22
|
download: () => any;
|
|
23
23
|
next: () => any;
|
|
24
|
+
failure: (error: FilePreviewRendererError | null) => any;
|
|
24
25
|
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
25
26
|
onPrevious?: (() => any) | undefined;
|
|
26
27
|
onReady?: ((details: readonly FilePreviewDetail[]) => any) | undefined;
|
|
27
28
|
onDownload?: (() => any) | undefined;
|
|
28
29
|
onNext?: (() => any) | undefined;
|
|
30
|
+
onFailure?: ((error: FilePreviewRendererError | null) => any) | undefined;
|
|
29
31
|
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
30
32
|
declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
|
|
31
33
|
declare const _default: typeof __VLS_export;
|
|
@@ -15,7 +15,7 @@ const props = defineProps({
|
|
|
15
15
|
kindLabel: { type: String, required: true },
|
|
16
16
|
arrows: { type: [Object, null], required: true }
|
|
17
17
|
});
|
|
18
|
-
const emit = defineEmits(["ready", "previous", "next", "download"]);
|
|
18
|
+
const emit = defineEmits(["ready", "failure", "previous", "next", "download"]);
|
|
19
19
|
const { t } = useUiToolsLocale();
|
|
20
20
|
const failure = shallowRef(null);
|
|
21
21
|
const ready = ref(false);
|
|
@@ -28,6 +28,7 @@ const downloadable = computed(() => props.item.file.download !== false);
|
|
|
28
28
|
const extension = computed(() => (props.display.extension ?? props.kindLabel).toUpperCase());
|
|
29
29
|
function restart() {
|
|
30
30
|
failure.value = null;
|
|
31
|
+
emit("failure", null);
|
|
31
32
|
ready.value = false;
|
|
32
33
|
slow.value = false;
|
|
33
34
|
delay.start();
|
|
@@ -41,6 +42,7 @@ function handleReady(details) {
|
|
|
41
42
|
function handleError(error) {
|
|
42
43
|
failure.value = error;
|
|
43
44
|
delay.stop();
|
|
45
|
+
emit("failure", error);
|
|
44
46
|
}
|
|
45
47
|
function retry() {
|
|
46
48
|
attempt.value += 1;
|
|
@@ -101,14 +103,6 @@ function retry() {
|
|
|
101
103
|
data-file-preview-retry=""
|
|
102
104
|
@click="retry"
|
|
103
105
|
/>
|
|
104
|
-
<UButton
|
|
105
|
-
v-if="downloadable"
|
|
106
|
-
icon="i-lucide-download"
|
|
107
|
-
:label="t('filePreview.download')"
|
|
108
|
-
color="neutral"
|
|
109
|
-
variant="outline"
|
|
110
|
-
@click="emit('download')"
|
|
111
|
-
/>
|
|
112
106
|
</FilePreviewMessage>
|
|
113
107
|
|
|
114
108
|
<FilePreviewMessage
|
|
@@ -121,7 +115,7 @@ function retry() {
|
|
|
121
115
|
:detail="failure.message"
|
|
122
116
|
>
|
|
123
117
|
<UButton
|
|
124
|
-
v-if="downloadable"
|
|
118
|
+
v-if="downloadable && failure.reason !== 'source'"
|
|
125
119
|
icon="i-lucide-download"
|
|
126
120
|
:label="t('filePreview.download')"
|
|
127
121
|
@click="emit('download')"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { useFilePreviewSource } from '../../composables/use-file-preview-source.js';
|
|
2
|
-
import type { FilePreviewContainer, FilePreviewDetail, FilePreviewItem, FilePreviewRendererDefinition } from '../../types/index.js';
|
|
2
|
+
import type { FilePreviewContainer, FilePreviewDetail, FilePreviewItem, FilePreviewRendererDefinition, FilePreviewRendererError } from '../../types/index.js';
|
|
3
3
|
type __VLS_Props = {
|
|
4
4
|
item: FilePreviewItem;
|
|
5
5
|
display: FilePreviewItem;
|
|
@@ -12,20 +12,22 @@ type __VLS_Props = {
|
|
|
12
12
|
next: boolean;
|
|
13
13
|
} | null;
|
|
14
14
|
};
|
|
15
|
-
declare var
|
|
15
|
+
declare var __VLS_57: {};
|
|
16
16
|
type __VLS_Slots = {} & {
|
|
17
|
-
default?: (props: typeof
|
|
17
|
+
default?: (props: typeof __VLS_57) => any;
|
|
18
18
|
};
|
|
19
19
|
declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
20
20
|
previous: () => any;
|
|
21
21
|
ready: (details: readonly FilePreviewDetail[]) => any;
|
|
22
22
|
download: () => any;
|
|
23
23
|
next: () => any;
|
|
24
|
+
failure: (error: FilePreviewRendererError | null) => any;
|
|
24
25
|
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
25
26
|
onPrevious?: (() => any) | undefined;
|
|
26
27
|
onReady?: ((details: readonly FilePreviewDetail[]) => any) | undefined;
|
|
27
28
|
onDownload?: (() => any) | undefined;
|
|
28
29
|
onNext?: (() => any) | undefined;
|
|
30
|
+
onFailure?: ((error: FilePreviewRendererError | null) => any) | undefined;
|
|
29
31
|
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
30
32
|
declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
|
|
31
33
|
declare const _default: typeof __VLS_export;
|
|
@@ -49,6 +49,7 @@ const {
|
|
|
49
49
|
onCancelled: (value) => emit("cancel", value),
|
|
50
50
|
onSubmitted: (value, result) => emit("submit", value, result),
|
|
51
51
|
schema: () => props.schema,
|
|
52
|
+
shell: () => props.shell,
|
|
52
53
|
syncInput: () => props.syncInput,
|
|
53
54
|
ui: () => props.ui,
|
|
54
55
|
validate: () => props.validate
|
|
@@ -198,7 +198,9 @@ export function useFieldOptions(params) {
|
|
|
198
198
|
const createLabel = computed(
|
|
199
199
|
() => resolveFormText(optionConfig.value?.create?.label)
|
|
200
200
|
);
|
|
201
|
-
const disableOnLoading = computed(
|
|
201
|
+
const disableOnLoading = computed(
|
|
202
|
+
() => !remoteConfig.value && optionConfig.value?.disableOnLoading !== false
|
|
203
|
+
);
|
|
202
204
|
const selectCreatedOption = computed(
|
|
203
205
|
() => optionConfig.value?.create?.selectOnCreation !== false
|
|
204
206
|
);
|
|
@@ -55,7 +55,7 @@ export function useFormOverlayController(instance) {
|
|
|
55
55
|
open.value = true;
|
|
56
56
|
return;
|
|
57
57
|
}
|
|
58
|
-
|
|
58
|
+
void runtime.close();
|
|
59
59
|
}
|
|
60
60
|
function requestComplete(formData, submitData) {
|
|
61
61
|
closeWithResolution({ formData, submitData, type: "complete" });
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FormObject, FormRendererController, FormRuntime, FormSubmitHandlerResult, FormUiConfig, FormValidationMode, FormValue } from '../types/index.js';
|
|
1
|
+
import type { FormObject, FormRenderShell, FormRendererController, FormRuntime, FormSubmitHandlerResult, FormUiConfig, FormValidationMode, FormValue } from '../types/index.js';
|
|
2
2
|
export interface UseFormRootParams {
|
|
3
3
|
/** Controller from `useForm`. Its schema, input, and policies win over the direct ones. */
|
|
4
4
|
form: () => FormRendererController | undefined;
|
|
@@ -9,6 +9,8 @@ export interface UseFormRootParams {
|
|
|
9
9
|
syncInput: () => boolean | readonly string[] | undefined;
|
|
10
10
|
validate: () => FormValidationMode | undefined;
|
|
11
11
|
ui: () => FormUiConfig | undefined;
|
|
12
|
+
/** Shell the form renders in. Overlay shells ask before closing a modified form by default. */
|
|
13
|
+
shell?: () => FormRenderShell | undefined;
|
|
12
14
|
onSubmitted: (value: FormObject, result: FormSubmitHandlerResult<FormValue>) => void;
|
|
13
15
|
onCancelled: (value: FormObject) => void;
|
|
14
16
|
}
|
|
@@ -42,6 +42,7 @@ export function useFormRoot(params) {
|
|
|
42
42
|
onBeforeUnmount(() => runtime.bindCancel(null));
|
|
43
43
|
const confirm = useFormConfirm();
|
|
44
44
|
let leaveConfirmed = false;
|
|
45
|
+
let pendingLeave = null;
|
|
45
46
|
watch(
|
|
46
47
|
() => runtime.dirtyPaths.value,
|
|
47
48
|
() => {
|
|
@@ -139,24 +140,35 @@ export function useFormRoot(params) {
|
|
|
139
140
|
});
|
|
140
141
|
}
|
|
141
142
|
async function cancel() {
|
|
142
|
-
if (!leaveConfirmed &&
|
|
143
|
+
if (!leaveConfirmed && shouldConfirmDirtyClose() && !await confirmLeave()) {
|
|
143
144
|
return;
|
|
144
145
|
}
|
|
145
146
|
params.onCancelled(runtime.output.value);
|
|
146
147
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
message: getDirtyNavigationMessage()
|
|
151
|
-
});
|
|
152
|
-
return leaveConfirmed;
|
|
148
|
+
function confirmLeave() {
|
|
149
|
+
pendingLeave ??= askLeave();
|
|
150
|
+
return pendingLeave;
|
|
153
151
|
}
|
|
154
|
-
function
|
|
155
|
-
|
|
156
|
-
|
|
152
|
+
async function askLeave() {
|
|
153
|
+
try {
|
|
154
|
+
leaveConfirmed = await confirm({
|
|
155
|
+
kind: "unsaved-changes",
|
|
156
|
+
message: getDirtyNavigationMessage()
|
|
157
|
+
});
|
|
158
|
+
return leaveConfirmed;
|
|
159
|
+
} finally {
|
|
160
|
+
pendingLeave = null;
|
|
157
161
|
}
|
|
162
|
+
}
|
|
163
|
+
function shouldConfirmDirtyNavigation() {
|
|
164
|
+
return confirmsDirtyChanges(getSchemaDirtyNavigation(schema.value));
|
|
165
|
+
}
|
|
166
|
+
function shouldConfirmDirtyClose() {
|
|
158
167
|
const config = getSchemaDirtyNavigation(schema.value);
|
|
159
|
-
|
|
168
|
+
return confirmsDirtyChanges(isUndefined(config) ? isOverlayShell(params.shell?.()) : config);
|
|
169
|
+
}
|
|
170
|
+
function confirmsDirtyChanges(config) {
|
|
171
|
+
if (!runtime.isDirty.value || !config) {
|
|
160
172
|
return false;
|
|
161
173
|
}
|
|
162
174
|
if (!isRecord(config)) {
|
|
@@ -197,6 +209,9 @@ export function useFormRoot(params) {
|
|
|
197
209
|
validationMode
|
|
198
210
|
};
|
|
199
211
|
}
|
|
212
|
+
function isOverlayShell(shell) {
|
|
213
|
+
return shell === "modal" || shell === "drawer" || shell === "fullscreen";
|
|
214
|
+
}
|
|
200
215
|
function isSameDocument(to, from) {
|
|
201
216
|
return to.fullPath.split("#")[0] === from.fullPath.split("#")[0];
|
|
202
217
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { computed, inject, nextTick, provide, ref, shallowRef, watch } from "vue";
|
|
2
2
|
import { useUiToolsLocale } from "../../i18n/use-locale.js";
|
|
3
|
+
import { dateFamilyCalendarType, formatDateFamilyBound } from "../fields/date-family/utils.js";
|
|
3
4
|
import { createPublicFormApi } from "../utils/api.js";
|
|
4
5
|
import { resolveFieldDependencies } from "../utils/dependencies.js";
|
|
5
6
|
import { resolveFormLayoutConfig } from "../utils/layout.js";
|
|
@@ -45,7 +46,7 @@ export function useFormRuntime(params) {
|
|
|
45
46
|
const navigationActionPending = ref(null);
|
|
46
47
|
const effects = createEffectLifecycle();
|
|
47
48
|
const cancelHandler = shallowRef(null);
|
|
48
|
-
const { t } = useUiToolsLocale();
|
|
49
|
+
const { code, t } = useUiToolsLocale();
|
|
49
50
|
setContext(getSchemaContext(params.schema.value));
|
|
50
51
|
function createFormNamespace(path, read) {
|
|
51
52
|
const parentPath = path.slice(0, -1);
|
|
@@ -66,7 +67,7 @@ export function useFormRuntime(params) {
|
|
|
66
67
|
state: () => read.state(),
|
|
67
68
|
submit: () => runtime.submit(),
|
|
68
69
|
validate: () => runtime.validate(),
|
|
69
|
-
close: () =>
|
|
70
|
+
close: () => runtime.close()
|
|
70
71
|
};
|
|
71
72
|
}
|
|
72
73
|
function createApiFactory(read) {
|
|
@@ -111,8 +112,15 @@ export function useFormRuntime(params) {
|
|
|
111
112
|
const validation = useFormValidation({
|
|
112
113
|
apiFactory,
|
|
113
114
|
context,
|
|
114
|
-
getDateMaxMessage: (max) => t(
|
|
115
|
-
|
|
115
|
+
getDateMaxMessage: (max, type) => t(`form.validation.${dateFamilyCalendarType(type)}Max`, {
|
|
116
|
+
max: formatDateFamilyBound(max, code.value)
|
|
117
|
+
}),
|
|
118
|
+
getDateMinMessage: (min, type) => t(`form.validation.${dateFamilyCalendarType(type)}Min`, {
|
|
119
|
+
min: formatDateFamilyBound(min, code.value)
|
|
120
|
+
}),
|
|
121
|
+
getNumberBoundMessage: (edge, bound) => t(`form.validation.number${edge === "min" ? "Min" : "Max"}`, {
|
|
122
|
+
bound: new Intl.NumberFormat(code.value).format(bound)
|
|
123
|
+
}),
|
|
116
124
|
getRequiredMessage: () => t("form.validation.required"),
|
|
117
125
|
getUniqueMessage: () => t("form.fields.array.unique"),
|
|
118
126
|
getValidationMode: () => params.validationMode?.value ?? true,
|
|
@@ -432,6 +440,7 @@ export function useFormRuntime(params) {
|
|
|
432
440
|
},
|
|
433
441
|
schema: params.schema,
|
|
434
442
|
setError: (path, message, options) => validation.setError(pathSegments(path), message, options),
|
|
443
|
+
setInputError: (path, message) => validation.setInputError(pathSegments(path), message),
|
|
435
444
|
setValue: state.setValue,
|
|
436
445
|
settleEffects: settleEffectRounds,
|
|
437
446
|
shouldRender: (field, path) => shouldRenderField(
|
|
@@ -454,6 +463,7 @@ export function useFormRuntime(params) {
|
|
|
454
463
|
bindCancel: (cancel) => {
|
|
455
464
|
cancelHandler.value = cancel;
|
|
456
465
|
},
|
|
466
|
+
close: () => cancelHandler.value?.() ?? Promise.resolve(),
|
|
457
467
|
trackEffect: effects.track,
|
|
458
468
|
validate,
|
|
459
469
|
validateCurrentStep
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { FormDateFamilyType } from '../fields/date-family/utils.js';
|
|
1
2
|
import type { FormValue, FormErrorOptions, FormField, FormObject, FormRuntimeContext, FormValidationError, FormValidationMode } from '../types/index.js';
|
|
2
3
|
import type { FormFieldApiFactory } from './use-form-state.js';
|
|
3
4
|
/**
|
|
@@ -14,8 +15,9 @@ export declare function useFormValidation(params: {
|
|
|
14
15
|
getValidationMode: () => FormValidationMode;
|
|
15
16
|
getRequiredMessage?: () => string;
|
|
16
17
|
getUniqueMessage?: () => string;
|
|
17
|
-
getDateMinMessage?: (bound: string) => string;
|
|
18
|
-
getDateMaxMessage?: (bound: string) => string;
|
|
18
|
+
getDateMinMessage?: (bound: string, type: FormDateFamilyType) => string;
|
|
19
|
+
getDateMaxMessage?: (bound: string, type: FormDateFamilyType) => string;
|
|
20
|
+
getNumberBoundMessage?: (edge: 'min' | 'max', bound: number) => string;
|
|
19
21
|
}): {
|
|
20
22
|
clearError: (path?: readonly string[]) => void;
|
|
21
23
|
clearValidationState: () => void;
|
|
@@ -25,6 +27,7 @@ export declare function useFormValidation(params: {
|
|
|
25
27
|
isTouched: (path: readonly string[]) => boolean;
|
|
26
28
|
markTouched: (path: readonly string[]) => void;
|
|
27
29
|
setError: (path: readonly string[], message: string, options?: FormErrorOptions) => void;
|
|
30
|
+
setInputError: (path: readonly string[], message: string | null) => void;
|
|
28
31
|
validate: () => Promise<boolean>;
|
|
29
32
|
validateFields: (fields: readonly FormField[], parentPath: readonly string[]) => Promise<boolean>;
|
|
30
33
|
validationErrors: import("vue").ComputedRef<readonly FormValidationError[]>;
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { resolveFormBoundaryText, resolveFormText } from "../utils/text.js";
|
|
24
24
|
export function useFormValidation(params) {
|
|
25
25
|
const customErrors = ref([]);
|
|
26
|
+
const inputErrors = ref(/* @__PURE__ */ new Map());
|
|
26
27
|
const touchedPaths = ref([]);
|
|
27
28
|
const pendingPaths = ref(/* @__PURE__ */ new Map());
|
|
28
29
|
const validatedMessages = ref(/* @__PURE__ */ new Map());
|
|
@@ -34,6 +35,7 @@ export function useFormValidation(params) {
|
|
|
34
35
|
context: params.context,
|
|
35
36
|
getDateMaxMessage: params.getDateMaxMessage,
|
|
36
37
|
getDateMinMessage: params.getDateMinMessage,
|
|
38
|
+
getNumberBoundMessage: params.getNumberBoundMessage,
|
|
37
39
|
getRequiredMessage: params.getRequiredMessage,
|
|
38
40
|
getUniqueMessage: params.getUniqueMessage,
|
|
39
41
|
includeAsync: true,
|
|
@@ -50,6 +52,7 @@ export function useFormValidation(params) {
|
|
|
50
52
|
context: params.context,
|
|
51
53
|
getDateMaxMessage: params.getDateMaxMessage,
|
|
52
54
|
getDateMinMessage: params.getDateMinMessage,
|
|
55
|
+
getNumberBoundMessage: params.getNumberBoundMessage,
|
|
53
56
|
getRequiredMessage: params.getRequiredMessage,
|
|
54
57
|
getUniqueMessage: params.getUniqueMessage,
|
|
55
58
|
includeAsync: false,
|
|
@@ -67,6 +70,7 @@ export function useFormValidation(params) {
|
|
|
67
70
|
context: params.context,
|
|
68
71
|
getDateMaxMessage: params.getDateMaxMessage,
|
|
69
72
|
getDateMinMessage: params.getDateMinMessage,
|
|
73
|
+
getNumberBoundMessage: params.getNumberBoundMessage,
|
|
70
74
|
getRequiredMessage: params.getRequiredMessage,
|
|
71
75
|
getUniqueMessage: params.getUniqueMessage,
|
|
72
76
|
includeAsync: true,
|
|
@@ -83,6 +87,7 @@ export function useFormValidation(params) {
|
|
|
83
87
|
context: params.context,
|
|
84
88
|
getDateMaxMessage: params.getDateMaxMessage,
|
|
85
89
|
getDateMinMessage: params.getDateMinMessage,
|
|
90
|
+
getNumberBoundMessage: params.getNumberBoundMessage,
|
|
86
91
|
getRequiredMessage: params.getRequiredMessage,
|
|
87
92
|
getUniqueMessage: params.getUniqueMessage,
|
|
88
93
|
includeAsync: false,
|
|
@@ -185,7 +190,7 @@ export function useFormValidation(params) {
|
|
|
185
190
|
if (validationRuns.get("$form") !== run) {
|
|
186
191
|
return asyncValid;
|
|
187
192
|
}
|
|
188
|
-
return syncResult.valid && asyncValid && !hasBlockingErrors(paths);
|
|
193
|
+
return syncResult.valid && asyncValid && !hasBlockingErrors(paths) && !hasInputErrors(paths);
|
|
189
194
|
} finally {
|
|
190
195
|
removePendingPaths(pendingPaths, potentialAsyncPaths, pendingToken);
|
|
191
196
|
}
|
|
@@ -236,7 +241,7 @@ export function useFormValidation(params) {
|
|
|
236
241
|
(error) => paths.some((path) => isScopedPath(error.path, path))
|
|
237
242
|
);
|
|
238
243
|
}
|
|
239
|
-
return syncResult.valid && asyncValid && !hasBlockingErrors(paths);
|
|
244
|
+
return syncResult.valid && asyncValid && !hasBlockingErrors(paths) && !hasInputErrors(paths);
|
|
240
245
|
} finally {
|
|
241
246
|
removePendingPaths(pendingPaths, potentialAsyncPaths, pendingToken);
|
|
242
247
|
}
|
|
@@ -259,6 +264,21 @@ export function useFormValidation(params) {
|
|
|
259
264
|
{ blocking: options?.blocking === true, message, path: key }
|
|
260
265
|
];
|
|
261
266
|
}
|
|
267
|
+
function setInputError(path, message) {
|
|
268
|
+
const key = path.join(".");
|
|
269
|
+
if ((inputErrors.value.get(key) ?? null) === message) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const next = new Map(inputErrors.value);
|
|
273
|
+
if (message === null) next.delete(key);
|
|
274
|
+
else next.set(key, message);
|
|
275
|
+
inputErrors.value = next;
|
|
276
|
+
}
|
|
277
|
+
function hasInputErrors(paths) {
|
|
278
|
+
return [...inputErrors.value.keys()].some(
|
|
279
|
+
(key) => paths.some((path) => isScopedPath(key, path))
|
|
280
|
+
);
|
|
281
|
+
}
|
|
262
282
|
function hasBlockingErrors(paths) {
|
|
263
283
|
return customErrors.value.some(
|
|
264
284
|
(error) => error.blocking === true && paths.some((path) => isScopedPath(error.path, path))
|
|
@@ -307,7 +327,8 @@ export function useFormValidation(params) {
|
|
|
307
327
|
validatedMessages.value = next;
|
|
308
328
|
}
|
|
309
329
|
function resolveFieldErrors(path) {
|
|
310
|
-
|
|
330
|
+
const inputError = inputErrors.value.get(path);
|
|
331
|
+
return inputError ? [inputError] : validatedMessages.value.get(path) ?? [];
|
|
311
332
|
}
|
|
312
333
|
function markTouched(path) {
|
|
313
334
|
touchPaths([path.join(".")]);
|
|
@@ -338,6 +359,7 @@ export function useFormValidation(params) {
|
|
|
338
359
|
isTouched,
|
|
339
360
|
markTouched,
|
|
340
361
|
setError,
|
|
362
|
+
setInputError,
|
|
341
363
|
validate,
|
|
342
364
|
validateFields,
|
|
343
365
|
validationErrors
|
|
@@ -488,6 +510,7 @@ function buildLeafRules(params) {
|
|
|
488
510
|
return output;
|
|
489
511
|
}
|
|
490
512
|
applyDateBoundsRules(output, params);
|
|
513
|
+
applyNumberBoundsRules(output, params);
|
|
491
514
|
applyPasswordRequirementRules(output, params);
|
|
492
515
|
const authoredValidators = "validators" in params.field ? params.field.validators : void 0;
|
|
493
516
|
let validators;
|
|
@@ -522,6 +545,29 @@ function applyPasswordRequirementRules(output, params) {
|
|
|
522
545
|
);
|
|
523
546
|
}
|
|
524
547
|
}
|
|
548
|
+
function applyNumberBoundsRules(output, params) {
|
|
549
|
+
if (params.field.type !== "number") {
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
const authoredProps = Object.getOwnPropertyDescriptor(params.field, "props")?.value;
|
|
553
|
+
const resolvedProps = isFunction(authoredProps) ? authoredProps(params.callbackParams) : authoredProps;
|
|
554
|
+
if (!isObject(resolvedProps)) {
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
const { max, min } = resolvedProps;
|
|
558
|
+
if (isNumber(min)) {
|
|
559
|
+
output.numberMin = withMessage(
|
|
560
|
+
(value) => !isNumber(value) || value >= min,
|
|
561
|
+
params.getNumberBoundMessage?.("min", min) ?? `Must be at least ${min}`
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
if (isNumber(max)) {
|
|
565
|
+
output.numberMax = withMessage(
|
|
566
|
+
(value) => !isNumber(value) || value <= max,
|
|
567
|
+
params.getNumberBoundMessage?.("max", max) ?? `Must be at most ${max}`
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
525
571
|
function isValidatorMap(value) {
|
|
526
572
|
return isObject(value);
|
|
527
573
|
}
|
|
@@ -779,13 +825,13 @@ function applyDateBoundsRules(output, params) {
|
|
|
779
825
|
if (bounds.min !== void 0) {
|
|
780
826
|
output.dateMin = withMessage(
|
|
781
827
|
(value) => isWithinDateMin(value, bounds.min, dateField.type),
|
|
782
|
-
params.getDateMinMessage?.(bounds.minLabel ?? "") ?? `Must be on or after ${bounds.minLabel}`
|
|
828
|
+
params.getDateMinMessage?.(bounds.minLabel ?? "", dateField.type) ?? `Must be on or after ${bounds.minLabel}`
|
|
783
829
|
);
|
|
784
830
|
}
|
|
785
831
|
if (bounds.max !== void 0) {
|
|
786
832
|
output.dateMax = withMessage(
|
|
787
833
|
(value) => isWithinDateMax(value, bounds.max, dateField.type),
|
|
788
|
-
params.getDateMaxMessage?.(bounds.maxLabel ?? "") ?? `Must be on or before ${bounds.maxLabel}`
|
|
834
|
+
params.getDateMaxMessage?.(bounds.maxLabel ?? "", dateField.type) ?? `Must be on or before ${bounds.maxLabel}`
|
|
789
835
|
);
|
|
790
836
|
}
|
|
791
837
|
}
|
|
@@ -14,8 +14,7 @@ const model = computed({
|
|
|
14
14
|
get: () => form.getValue(props.path),
|
|
15
15
|
set: (value) => form.setValue(props.path, value)
|
|
16
16
|
});
|
|
17
|
-
const
|
|
18
|
-
const Rendered = () => rendered.value;
|
|
17
|
+
const Rendered = () => props.field.render?.(params.value);
|
|
19
18
|
</script>
|
|
20
19
|
|
|
21
20
|
<template>
|
|
@@ -28,6 +27,6 @@ const Rendered = () => rendered.value;
|
|
|
28
27
|
:disabled="disabled"
|
|
29
28
|
@blur="handleBlur"
|
|
30
29
|
/>
|
|
31
|
-
<Rendered v-else-if="
|
|
30
|
+
<Rendered v-else-if="field.render" />
|
|
32
31
|
</FormFieldShell>
|
|
33
32
|
</template>
|
|
@@ -11,6 +11,7 @@ import { isNullish } from "../../../shared/utils/predicate";
|
|
|
11
11
|
import FormFieldShell from "../../components/renderer/form-field-shell.vue";
|
|
12
12
|
import { useFieldControl } from "../../composables/use-field-control";
|
|
13
13
|
import { isNumber, isString } from "../../utils/predicate";
|
|
14
|
+
import { resolveFieldText } from "../../utils/text";
|
|
14
15
|
import {
|
|
15
16
|
applyDateManualMask,
|
|
16
17
|
calendarRangeFromCanonical,
|
|
@@ -43,6 +44,7 @@ const {
|
|
|
43
44
|
disabled,
|
|
44
45
|
handleBlur,
|
|
45
46
|
interactionOwnerClass,
|
|
47
|
+
params,
|
|
46
48
|
placeholder: fieldPlaceholder
|
|
47
49
|
} = useFieldControl(
|
|
48
50
|
() => props.field,
|
|
@@ -155,6 +157,9 @@ const minuteStep = computed(() => {
|
|
|
155
157
|
});
|
|
156
158
|
const timeStep = computed(() => ({ minute: minuteStep.value }));
|
|
157
159
|
const calendarUi = computed(() => ({ heading: "min-w-0", root: "p-2" }));
|
|
160
|
+
const calendarLabel = computed(
|
|
161
|
+
() => resolveFieldText(Object.getOwnPropertyDescriptor(props.field, "label")?.value, params.value) ?? t("form.fields.date.calendar")
|
|
162
|
+
);
|
|
158
163
|
watch(
|
|
159
164
|
selectedValues,
|
|
160
165
|
() => {
|
|
@@ -272,15 +277,9 @@ function commitManualInput(raw) {
|
|
|
272
277
|
return false;
|
|
273
278
|
}
|
|
274
279
|
if (!isString(parsed)) {
|
|
275
|
-
if (!isWithinBounds(parsed[0]) || !isWithinBounds(parsed[1])) {
|
|
276
|
-
return false;
|
|
277
|
-
}
|
|
278
280
|
setStoredRange(parsed[0], parsed[1]);
|
|
279
281
|
return true;
|
|
280
282
|
}
|
|
281
|
-
if (!isWithinBounds(parsed)) {
|
|
282
|
-
return false;
|
|
283
|
-
}
|
|
284
283
|
setStoredPart(0, parsed);
|
|
285
284
|
return true;
|
|
286
285
|
}
|
|
@@ -453,6 +452,7 @@ function isCalendarRangeValue(candidate) {
|
|
|
453
452
|
:week-numbers="fieldProps.calendar?.weekNumbers ?? false"
|
|
454
453
|
:number-of-months="fieldProps.calendar?.numberOfMonths"
|
|
455
454
|
:view-control="{ trailingIcon: 'i-lucide-chevron-down', class: 'font-medium' }"
|
|
455
|
+
:calendar-label="calendarLabel"
|
|
456
456
|
:ui="calendarUi"
|
|
457
457
|
@update:model-value="updateRangeCalendar"
|
|
458
458
|
/>
|
|
@@ -468,6 +468,7 @@ function isCalendarRangeValue(candidate) {
|
|
|
468
468
|
:week-numbers="fieldProps.calendar?.weekNumbers ?? false"
|
|
469
469
|
:number-of-months="fieldProps.calendar?.numberOfMonths"
|
|
470
470
|
:view-control="{ trailingIcon: 'i-lucide-chevron-down', class: 'font-medium' }"
|
|
471
|
+
:calendar-label="calendarLabel"
|
|
471
472
|
:ui="calendarUi"
|
|
472
473
|
@update:model-value="updateSingleCalendar"
|
|
473
474
|
/>
|
|
@@ -37,4 +37,9 @@ export type FormDateSeedValue = Date | string | number | null | undefined;
|
|
|
37
37
|
export declare function canonicalDateFromValue(value: FormDateSeedValue): string;
|
|
38
38
|
export declare function canonicalDateToJsDate(value: string): Date | undefined;
|
|
39
39
|
export declare function calendarSeedFromValue(value: FormDateSeedValue, type: FormDateFamilyType): string;
|
|
40
|
+
/**
|
|
41
|
+
* Writes a canonical bound (`2026`, `2026-10`, `2026-10-24`, `2026-10-24T09:30`) the way the
|
|
42
|
+
* locale reads it, such as « 24 oct. 2026 » or « octobre 2026 ».
|
|
43
|
+
*/
|
|
44
|
+
export declare function formatDateFamilyBound(value: string, locale: string): string;
|
|
40
45
|
export declare function calendarDateToJsDate(value: DateValue): Date;
|
|
@@ -193,6 +193,19 @@ export function calendarSeedFromValue(value, type) {
|
|
|
193
193
|
}
|
|
194
194
|
return "";
|
|
195
195
|
}
|
|
196
|
+
export function formatDateFamilyBound(value, locale) {
|
|
197
|
+
const date = /^\d{4}$/u.test(value) ? void 0 : canonicalDateToJsDate(value);
|
|
198
|
+
if (!date) {
|
|
199
|
+
return value;
|
|
200
|
+
}
|
|
201
|
+
if (/^\d{4}-\d{2}$/u.test(value)) {
|
|
202
|
+
return new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(date);
|
|
203
|
+
}
|
|
204
|
+
return new Intl.DateTimeFormat(
|
|
205
|
+
locale,
|
|
206
|
+
value.includes("T") ? { dateStyle: "medium", timeStyle: "short" } : { dateStyle: "medium" }
|
|
207
|
+
).format(date);
|
|
208
|
+
}
|
|
196
209
|
export function calendarDateToJsDate(value) {
|
|
197
210
|
return value.toDate(getLocalTimeZone());
|
|
198
211
|
}
|
|
@@ -16,7 +16,7 @@ const { code } = useUiToolsLocale();
|
|
|
16
16
|
const { fieldProps, form, controlProps, disabled, handleBlur, placeholder } = useFieldControl(
|
|
17
17
|
() => props.field,
|
|
18
18
|
() => props.path,
|
|
19
|
-
{ omit: ["prefix", "suffix", "format", "controls", "mono"] }
|
|
19
|
+
{ omit: ["prefix", "suffix", "format", "controls", "mono", "min", "max"] }
|
|
20
20
|
);
|
|
21
21
|
const model = computed({
|
|
22
22
|
get: () => {
|
|
@@ -25,6 +25,12 @@ const model = computed({
|
|
|
25
25
|
},
|
|
26
26
|
set: (value) => form.setValue(props.path, value ?? null)
|
|
27
27
|
});
|
|
28
|
+
const atMax = computed(
|
|
29
|
+
() => isNumber(model.value) && isNumber(fieldProps.value.max) && model.value >= fieldProps.value.max
|
|
30
|
+
);
|
|
31
|
+
const atMin = computed(
|
|
32
|
+
() => isNumber(model.value) && isNumber(fieldProps.value.min) && model.value <= fieldProps.value.min
|
|
33
|
+
);
|
|
28
34
|
const prefix = computed(() => resolveFormText(fieldProps.value.prefix));
|
|
29
35
|
const suffix = computed(() => resolveFormText(fieldProps.value.suffix));
|
|
30
36
|
const controls = computed(() => fieldProps.value.controls !== false);
|
|
@@ -76,13 +82,13 @@ const affixStyle = computed(() => ({
|
|
|
76
82
|
:ui="controlUi"
|
|
77
83
|
:placeholder="placeholder"
|
|
78
84
|
:disabled="disabled"
|
|
79
|
-
:min="fieldProps.min"
|
|
80
|
-
:max="fieldProps.max"
|
|
81
85
|
:step="fieldProps.step"
|
|
82
86
|
:locale="code"
|
|
83
87
|
:format-options="fieldProps.format"
|
|
84
88
|
:increment="controls"
|
|
85
89
|
:decrement="controls"
|
|
90
|
+
:increment-disabled="atMax"
|
|
91
|
+
:decrement-disabled="atMin"
|
|
86
92
|
@blur="handleBlur"
|
|
87
93
|
/>
|
|
88
94
|
<span
|
|
@@ -121,13 +127,13 @@ const affixStyle = computed(() => ({
|
|
|
121
127
|
:ui="controlUi"
|
|
122
128
|
:placeholder="placeholder"
|
|
123
129
|
:disabled="disabled"
|
|
124
|
-
:min="fieldProps.min"
|
|
125
|
-
:max="fieldProps.max"
|
|
126
130
|
:step="fieldProps.step"
|
|
127
131
|
:locale="code"
|
|
128
132
|
:format-options="fieldProps.format"
|
|
129
133
|
:increment="controls"
|
|
130
134
|
:decrement="controls"
|
|
135
|
+
:increment-disabled="atMax"
|
|
136
|
+
:decrement-disabled="atMin"
|
|
131
137
|
@blur="handleBlur"
|
|
132
138
|
/>
|
|
133
139
|
<span
|
|
@@ -4,7 +4,12 @@ import UFieldGroup from "@nuxt/ui/components/FieldGroup.vue";
|
|
|
4
4
|
import UIcon from "@nuxt/ui/components/Icon.vue";
|
|
5
5
|
import UInput from "@nuxt/ui/components/Input.vue";
|
|
6
6
|
import USelectMenu from "@nuxt/ui/components/SelectMenu.vue";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
getCountries,
|
|
9
|
+
getCountryCallingCode,
|
|
10
|
+
parsePhoneNumberFromString,
|
|
11
|
+
validatePhoneNumberLength
|
|
12
|
+
} from "libphonenumber-js";
|
|
8
13
|
import { computed, onMounted, ref, useId, watch } from "vue";
|
|
9
14
|
import { useUiToolsLocale } from "../../../i18n/use-locale";
|
|
10
15
|
import FormFieldShell from "../../components/renderer/form-field-shell.vue";
|
|
@@ -47,6 +52,20 @@ const processedValue = computed(() => {
|
|
|
47
52
|
}
|
|
48
53
|
return { valid: true, value: formatPhoneNumber(parsed) };
|
|
49
54
|
});
|
|
55
|
+
const inputError = computed(() => {
|
|
56
|
+
if (!phoneValue.value || !countryCode.value || processedValue.value.valid) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return validatePhoneNumberLength(phoneValue.value, countryCode.value) === "TOO_SHORT" ? t("form.validation.phoneIncomplete") : t("form.validation.phoneInvalid");
|
|
60
|
+
});
|
|
61
|
+
watch(
|
|
62
|
+
[() => props.path, inputError],
|
|
63
|
+
([path, message], _previous, onCleanup) => {
|
|
64
|
+
form.setInputError(path, message);
|
|
65
|
+
onCleanup(() => form.setInputError(path, null));
|
|
66
|
+
},
|
|
67
|
+
{ immediate: true }
|
|
68
|
+
);
|
|
50
69
|
onMounted(() => {
|
|
51
70
|
syncFromExternalValue(form.getValue(props.path));
|
|
52
71
|
if (!countryCode.value) {
|
|
@@ -61,8 +61,8 @@ export interface FormFieldFormApi {
|
|
|
61
61
|
submit: () => Promise<boolean>;
|
|
62
62
|
/**
|
|
63
63
|
* Closes the form the way its Cancel action does: asks to discard unsaved changes when the
|
|
64
|
-
* schema asks for it, then cancels (an overlay closes). Does
|
|
65
|
-
* a cancel handler.
|
|
64
|
+
* schema asks for it (an overlay form does by default), then cancels (an overlay closes). Does
|
|
65
|
+
* nothing for a form rendered without a cancel handler.
|
|
66
66
|
*/
|
|
67
67
|
close: () => Promise<void>;
|
|
68
68
|
/** Advances to the next step on stepped forms. */
|
|
@@ -206,6 +206,13 @@ export interface FormApi<TOutput = FormObject> {
|
|
|
206
206
|
submit: () => Promise<void>;
|
|
207
207
|
/** Resets the form and waits for dependency effects triggered by the reset. */
|
|
208
208
|
reset: () => Promise<void>;
|
|
209
|
+
/**
|
|
210
|
+
* Closes the form the way its Cancel action does: asks to discard unsaved changes when the
|
|
211
|
+
* schema asks for it (an overlay form does by default), then cancels, so an overlay closes and
|
|
212
|
+
* its `createForm` promise resolves with `isCompleted: false`. Does nothing for a form rendered
|
|
213
|
+
* without a cancel handler.
|
|
214
|
+
*/
|
|
215
|
+
close: () => Promise<void>;
|
|
209
216
|
}
|
|
210
217
|
export type FormSubmitAction = 'next' | 'previous' | 'submit' | 'reset';
|
|
211
218
|
export type FormSubmitResult<TSubmitData = FormValue> = boolean | void | {
|
|
@@ -257,7 +257,10 @@ export interface FormRemoteOptionConfig<TOption, TContext = NonNullable<unknown>
|
|
|
257
257
|
create?: FormCreateOption<TOption, TContext, TDeps, TValue>;
|
|
258
258
|
/** Enables the field-level refresh affordance. */
|
|
259
259
|
allowOptionsRefresh?: boolean;
|
|
260
|
-
/**
|
|
260
|
+
/**
|
|
261
|
+
* Ignored: remote options load once the menu opens and the menu shows the loading state, so the
|
|
262
|
+
* field and its search input stay usable while a page loads.
|
|
263
|
+
*/
|
|
261
264
|
disableOnLoading?: boolean;
|
|
262
265
|
}
|
|
263
266
|
/** Reuses one remote loader while keeping field-specific refresh and creation behavior local. */
|
|
@@ -80,6 +80,11 @@ export interface FormRuntime {
|
|
|
80
80
|
validate: (options?: FormValidationOptions) => Promise<boolean>;
|
|
81
81
|
validateCurrentStep: (options?: FormValidationOptions) => Promise<boolean>;
|
|
82
82
|
setError: (path: string | readonly string[], message: string, options?: FormErrorOptions) => void;
|
|
83
|
+
/**
|
|
84
|
+
* Reports input a control holds but cannot store as a value, such as an incomplete phone number,
|
|
85
|
+
* or clears it with `null`. It shows like a rule error once the field is touched and blocks submit.
|
|
86
|
+
*/
|
|
87
|
+
setInputError: (path: string | readonly string[], message: string | null) => void;
|
|
83
88
|
clearError: (path?: string | readonly string[]) => void;
|
|
84
89
|
focusRequest: Ref<FormFocusRequest | null>;
|
|
85
90
|
registerFieldElement: (path: string | readonly string[], element: HTMLElement) => () => void;
|
|
@@ -94,4 +99,9 @@ export interface FormRuntime {
|
|
|
94
99
|
goToStep: (index: number) => Promise<boolean>;
|
|
95
100
|
/** Cancels the rendered form, as its Cancel action does. Set by the form root that renders it. */
|
|
96
101
|
bindCancel: (cancel: (() => Promise<void>) | null) => void;
|
|
102
|
+
/**
|
|
103
|
+
* Closes the form the way its Cancel action does, asking first when it holds unsaved changes the
|
|
104
|
+
* schema protects. Resolves at once when no form root renders the runtime.
|
|
105
|
+
*/
|
|
106
|
+
close: () => Promise<void>;
|
|
97
107
|
}
|
|
@@ -18,7 +18,11 @@ export interface FormControlsConfig {
|
|
|
18
18
|
ignoreDirtyPaths?: readonly string[];
|
|
19
19
|
/** Focuses the first field after mount, or a specific raw field path. */
|
|
20
20
|
autoFocus?: boolean | string;
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Asks before a modified form is cancelled, closed, or left for another page. A form opened as a
|
|
23
|
+
* modal, drawer, or fullscreen overlay asks before closing (Escape, outside click, close button,
|
|
24
|
+
* Cancel, `api.close()`) unless this is `false`; leaving the page asks only when it is set.
|
|
25
|
+
*/
|
|
22
26
|
confirmNavOnDirty?: boolean | FormDirtyNavigationConfig;
|
|
23
27
|
/** Keeps external input changes synchronized after the initial mount. */
|
|
24
28
|
syncInput?: boolean | readonly string[];
|
|
@@ -2,6 +2,7 @@ import { pathSegments } from "./path.js";
|
|
|
2
2
|
export function createPublicFormApi(runtime) {
|
|
3
3
|
return {
|
|
4
4
|
clearError: (path) => runtime.clearError(path ? pathSegments(path) : void 0),
|
|
5
|
+
close: runtime.close,
|
|
5
6
|
focus: runtime.focusField,
|
|
6
7
|
get: runtime.getValue,
|
|
7
8
|
initial: runtime.getInitialValue,
|
|
@@ -269,6 +269,7 @@ export default defineUiToolsLocale({
|
|
|
269
269
|
open: "Open color picker"
|
|
270
270
|
},
|
|
271
271
|
date: {
|
|
272
|
+
calendar: "Calendar",
|
|
272
273
|
clear: "Clear value",
|
|
273
274
|
confirm: "Confirm",
|
|
274
275
|
end: "End",
|
|
@@ -355,9 +356,17 @@ export default defineUiToolsLocale({
|
|
|
355
356
|
fallback: "Step {number}"
|
|
356
357
|
},
|
|
357
358
|
validation: {
|
|
358
|
-
dateMax: "
|
|
359
|
-
dateMin: "
|
|
360
|
-
|
|
359
|
+
dateMax: "Date after {max}",
|
|
360
|
+
dateMin: "Date before {min}",
|
|
361
|
+
monthMax: "Month after {max}",
|
|
362
|
+
monthMin: "Month before {min}",
|
|
363
|
+
numberMax: "Maximum value: {bound}",
|
|
364
|
+
numberMin: "Minimum value: {bound}",
|
|
365
|
+
phoneIncomplete: "Incomplete phone number",
|
|
366
|
+
phoneInvalid: "Invalid phone number",
|
|
367
|
+
required: "This field is required",
|
|
368
|
+
yearMax: "Year after {max}",
|
|
369
|
+
yearMin: "Year before {min}"
|
|
361
370
|
}
|
|
362
371
|
},
|
|
363
372
|
spreadsheet: {
|
|
@@ -269,6 +269,7 @@ export default defineUiToolsLocale({
|
|
|
269
269
|
open: "Ouvrir le s\xE9lecteur de couleur"
|
|
270
270
|
},
|
|
271
271
|
date: {
|
|
272
|
+
calendar: "Calendrier",
|
|
272
273
|
clear: "Effacer la valeur",
|
|
273
274
|
confirm: "Confirmer",
|
|
274
275
|
end: "Fin",
|
|
@@ -355,9 +356,17 @@ export default defineUiToolsLocale({
|
|
|
355
356
|
fallback: "\xC9tape {number}"
|
|
356
357
|
},
|
|
357
358
|
validation: {
|
|
358
|
-
dateMax: "
|
|
359
|
-
dateMin: "
|
|
360
|
-
|
|
359
|
+
dateMax: "Date post\xE9rieure au {max}",
|
|
360
|
+
dateMin: "Date ant\xE9rieure au {min}",
|
|
361
|
+
monthMax: "Mois post\xE9rieur \xE0 {max}",
|
|
362
|
+
monthMin: "Mois ant\xE9rieur \xE0 {min}",
|
|
363
|
+
numberMax: "Valeur maximale : {bound}",
|
|
364
|
+
numberMin: "Valeur minimale : {bound}",
|
|
365
|
+
phoneIncomplete: "Num\xE9ro de t\xE9l\xE9phone incomplet",
|
|
366
|
+
phoneInvalid: "Num\xE9ro de t\xE9l\xE9phone invalide",
|
|
367
|
+
required: "Ce champ est requis",
|
|
368
|
+
yearMax: "Ann\xE9e post\xE9rieure \xE0 {max}",
|
|
369
|
+
yearMin: "Ann\xE9e ant\xE9rieure \xE0 {min}"
|
|
361
370
|
}
|
|
362
371
|
},
|
|
363
372
|
spreadsheet: {
|
|
@@ -504,8 +504,22 @@ export interface UiToolsFormMessages {
|
|
|
504
504
|
};
|
|
505
505
|
validation: {
|
|
506
506
|
required: string;
|
|
507
|
+
/** A date before the field's `min`: "Date before {min}". */
|
|
507
508
|
dateMin: string;
|
|
509
|
+
/** A date after the field's `max`: "Date after {max}". */
|
|
508
510
|
dateMax: string;
|
|
511
|
+
monthMin: string;
|
|
512
|
+
monthMax: string;
|
|
513
|
+
yearMin: string;
|
|
514
|
+
yearMax: string;
|
|
515
|
+
/** A number below the field's `min`: "Minimum value: {bound}". */
|
|
516
|
+
numberMin: string;
|
|
517
|
+
/** A number above the field's `max`: "Maximum value: {bound}". */
|
|
518
|
+
numberMax: string;
|
|
519
|
+
/** A phone number too short for its country. */
|
|
520
|
+
phoneIncomplete: string;
|
|
521
|
+
/** A phone number that is not valid for its country or allowed types. */
|
|
522
|
+
phoneInvalid: string;
|
|
509
523
|
};
|
|
510
524
|
fields: {
|
|
511
525
|
text: {
|
|
@@ -562,6 +576,8 @@ export interface UiToolsFormMessages {
|
|
|
562
576
|
end: string;
|
|
563
577
|
clear: string;
|
|
564
578
|
confirm: string;
|
|
579
|
+
/** Accessible name of the calendar when the field has no label, read before the month. */
|
|
580
|
+
calendar: string;
|
|
565
581
|
tokens: {
|
|
566
582
|
day: string;
|
|
567
583
|
month: string;
|
|
@@ -464,6 +464,9 @@ function areSameCalendarDay(left, right) {
|
|
|
464
464
|
<UCalendar
|
|
465
465
|
:model-value="calendarRange"
|
|
466
466
|
:size="size"
|
|
467
|
+
:calendar-label="
|
|
468
|
+
internals.filters.getFilterLabelText({ label: definition.label })
|
|
469
|
+
"
|
|
467
470
|
range
|
|
468
471
|
:number-of-months="rangeCalendarPanels"
|
|
469
472
|
:paged-navigation="
|
|
@@ -602,6 +605,7 @@ function areSameCalendarDay(left, right) {
|
|
|
602
605
|
"
|
|
603
606
|
:model-value="localDate"
|
|
604
607
|
:size="size"
|
|
608
|
+
:calendar-label="internals.filters.getFilterLabelText({ label: definition.label })"
|
|
605
609
|
:fixed-weeks="filterUi.scalar.calendar.fixedWeeks"
|
|
606
610
|
:ui="{
|
|
607
611
|
root: mergeDataListUiClass(
|
|
@@ -647,6 +647,23 @@ export interface TableOptionFilterDefinition<TRow extends GenericObject = Generi
|
|
|
647
647
|
*/
|
|
648
648
|
remote?: TableFilterRemoteOptions<TValue, TPresentation>;
|
|
649
649
|
facet?: TableFilterFacetSpec<TRow, TContext, TKey>;
|
|
650
|
+
/**
|
|
651
|
+
* Labels the values a facet lists when `options` is not set, such as provider statuses stored
|
|
652
|
+
* as `Approved` or `Security compromised`. The facet still decides which values show, with their
|
|
653
|
+
* counts; a value it returns `undefined` for shows as stored. Tags and previews of a committed
|
|
654
|
+
* value read the same label.
|
|
655
|
+
*
|
|
656
|
+
* @example
|
|
657
|
+
* ```ts
|
|
658
|
+
* source: {
|
|
659
|
+
* facet: true,
|
|
660
|
+
* label: (value) => ({ Approved: 'Validé', Done: 'Terminé' })[String(value)],
|
|
661
|
+
* }
|
|
662
|
+
* ```
|
|
663
|
+
*/
|
|
664
|
+
label?: {
|
|
665
|
+
bivarianceHack(value: TValue): TableTextValue | undefined;
|
|
666
|
+
}['bivarianceHack'];
|
|
650
667
|
sort?: 'alpha' | 'count';
|
|
651
668
|
};
|
|
652
669
|
editor?: TableOptionFilterEditorConfig<TPresentation>;
|
|
@@ -21,9 +21,10 @@ export function resolveFilterOptionEntries(options) {
|
|
|
21
21
|
return [];
|
|
22
22
|
}
|
|
23
23
|
const configuredOptions = options.options ?? options.definition.source?.options ?? [];
|
|
24
|
+
const facetLabel = options.definition.source?.label;
|
|
24
25
|
const sourceOptions = configuredOptions.length || options.authoritative ? configuredOptions : (options.facetCounts ?? []).map((entry) => ({
|
|
25
26
|
count: entry.count,
|
|
26
|
-
label: String(entry.value),
|
|
27
|
+
label: facetLabel?.(entry.value) ?? String(entry.value),
|
|
27
28
|
value: entry.value
|
|
28
29
|
}));
|
|
29
30
|
const countByValue = new Map(
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isBoolean, isNullish, isNumber, isString } from "../../../../shared/utils/predicate.js";
|
|
2
|
+
import { getFilterLabelText } from "../common.js";
|
|
1
3
|
import { resolveBooleanFilterUi, resolveOptionFilterUi } from "../ui.js";
|
|
2
4
|
export function buildOptionFilterPreview(options) {
|
|
3
5
|
const values = Array.isArray(options.rule.value) ? options.rule.value : [options.rule.value];
|
|
@@ -48,7 +50,8 @@ function resolveOptionPreviewLabel(options) {
|
|
|
48
50
|
);
|
|
49
51
|
return options.value ? filterUi.labels.true : filterUi.labels.false;
|
|
50
52
|
}
|
|
51
|
-
|
|
53
|
+
const label = isString(options.value) || isNumber(options.value) || isBoolean(options.value) ? options.definition.source?.label?.(options.value) : void 0;
|
|
54
|
+
return isNullish(label) ? String(options.value ?? "") : getFilterLabelText({ label });
|
|
52
55
|
}
|
|
53
56
|
function resolveOptionPreview(options) {
|
|
54
57
|
if (options.definition.kind === "boolean") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nuxt-ui-tools",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.26.0",
|
|
4
4
|
"description": "Nuxt module and runtime toolkit for UI-heavy Nuxt applications",
|
|
5
5
|
"homepage": "https://github.com/ChronicStone/nuxt-ui-tools#readme",
|
|
6
6
|
"bugs": "https://github.com/ChronicStone/nuxt-ui-tools/issues",
|