@mapomodule/form 2.0.0-beta.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/README.md +155 -0
- package/dist/index.d.cts +1 -0
- package/dist/index.d.mts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.mjs +34 -0
- package/dist/module.d.cts +2 -0
- package/dist/module.d.mts +2 -0
- package/dist/module.d.ts +2 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +18 -0
- package/dist/types.d.mts +13 -0
- package/package.json +78 -0
- package/src/__tests__/__mocks__/nuxt-imports.ts +5 -0
- package/src/__tests__/registry.test.ts +105 -0
- package/src/__tests__/useFormFromSchema.test.ts +415 -0
- package/src/__tests__/useMapoForm.test.ts +319 -0
- package/src/index.ts +49 -0
- package/src/module.ts +213 -0
- package/src/runtime/components/MapoFocusPortal.vue +154 -0
- package/src/runtime/components/MapoForm.vue +247 -0
- package/src/runtime/components/MapoFormField.vue +350 -0
- package/src/runtime/components/MapoFormFlatSection.vue +163 -0
- package/src/runtime/components/MapoFormGroup.vue +283 -0
- package/src/runtime/components/MapoFormTabs.vue +116 -0
- package/src/runtime/components/MapoUnknownField.vue +74 -0
- package/src/runtime/components/fields/MapoDateField.vue +98 -0
- package/src/runtime/components/fields/MapoDateTimeField.vue +192 -0
- package/src/runtime/components/fields/MapoFileField.vue +106 -0
- package/src/runtime/components/fields/MapoFksField.vue +151 -0
- package/src/runtime/components/fields/MapoMapField.vue +117 -0
- package/src/runtime/components/fields/MapoMapFieldClient.vue +92 -0
- package/src/runtime/components/fields/MapoRepeater.vue +553 -0
- package/src/runtime/components/fields/MapoRepeaterItem.vue +409 -0
- package/src/runtime/components/fields/MapoSeoPreview.vue +137 -0
- package/src/runtime/components/fields/MapoTimeField.vue +59 -0
- package/src/runtime/components/fields/MapoWygEditor.vue +256 -0
- package/src/runtime/components/fields/nui/NuiCheckbox.vue +8 -0
- package/src/runtime/components/fields/nui/NuiInput.vue +8 -0
- package/src/runtime/components/fields/nui/NuiSelectMenu.vue +27 -0
- package/src/runtime/components/fields/nui/NuiSlider.vue +8 -0
- package/src/runtime/components/fields/nui/NuiSwitch.vue +8 -0
- package/src/runtime/components/fields/nui/NuiTextarea.vue +8 -0
- package/src/runtime/components/fields/nui/useNuiFieldAttrs.ts +30 -0
- package/src/runtime/composables/defineFormField.ts +62 -0
- package/src/runtime/composables/useCurrentLang.ts +16 -0
- package/src/runtime/composables/useFocusMode.ts +86 -0
- package/src/runtime/composables/useFormDraft.ts +119 -0
- package/src/runtime/composables/useFormFromSchema.ts +253 -0
- package/src/runtime/composables/useMapoForm.ts +484 -0
- package/src/runtime/composables/useProgressiveDisclosure.ts +101 -0
- package/src/runtime/composables/useUnsavedChangesGuard.ts +58 -0
- package/src/runtime/devtools/DevtoolsFormsPage.vue +256 -0
- package/src/runtime/plugins/form-registry.ts +43 -0
- package/src/runtime/registry/defaults.ts +97 -0
- package/src/runtime/types/descriptor.ts +411 -0
- package/src/runtime/types/index.ts +40 -0
- package/src/runtime/types/layout.ts +22 -0
- package/src/runtime/types/registry.ts +98 -0
- package/src/runtime/utils/flattenFieldGroups.ts +86 -0
- package/src/vue-shim.d.ts +12 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { watch, onBeforeUnmount } from "vue";
|
|
3
|
+
import { useEditor, EditorContent } from "@tiptap/vue-3";
|
|
4
|
+
import type { AnyExtension } from "@tiptap/vue-3";
|
|
5
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
6
|
+
import Link from "@tiptap/extension-link";
|
|
7
|
+
import Underline from "@tiptap/extension-underline";
|
|
8
|
+
import type { EditorDescriptor } from "../../types/index.js";
|
|
9
|
+
|
|
10
|
+
const props = defineProps<{
|
|
11
|
+
modelValue: unknown;
|
|
12
|
+
descriptor: EditorDescriptor;
|
|
13
|
+
errors?: string[];
|
|
14
|
+
readonly?: boolean;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
}>();
|
|
17
|
+
|
|
18
|
+
const emit = defineEmits<{ "update:modelValue": [value: string] }>();
|
|
19
|
+
|
|
20
|
+
const extraExtensions =
|
|
21
|
+
(props.descriptor.attrs?.extensions as AnyExtension[] | undefined) ?? [];
|
|
22
|
+
|
|
23
|
+
// Protocol allowlist for user-entered links. Without this,
|
|
24
|
+
// `<a href="javascript:...">` would be accepted, leading to XSS when content is
|
|
25
|
+
// rendered elsewhere (preview, server-side, other editors).
|
|
26
|
+
const ALLOWED_LINK_PROTOCOLS = ["http", "https", "mailto", "tel"];
|
|
27
|
+
|
|
28
|
+
function isSafeUrl(url: string): boolean {
|
|
29
|
+
try {
|
|
30
|
+
// URL relativi (es. "/page") non hanno protocol — li accettiamo.
|
|
31
|
+
if (!/^[a-z][a-z0-9+.-]*:/i.test(url)) return true;
|
|
32
|
+
const proto = new URL(url, "https://placeholder.local").protocol
|
|
33
|
+
.replace(":", "")
|
|
34
|
+
.toLowerCase();
|
|
35
|
+
return ALLOWED_LINK_PROTOCOLS.includes(proto);
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Sanitise incoming HTML from external sources (initial set, server sync)
|
|
42
|
+
// by removing <script>, inline handlers (on*), and href: javascript:. This is
|
|
43
|
+
// intentionally minimal (no DOMPurify) to avoid a heavy dependency: it covers the
|
|
44
|
+
// most common vectors; for truly untrusted HTML the consumer should use DOMPurify
|
|
45
|
+
// at the app level.
|
|
46
|
+
function sanitizeHtml(html: string): string {
|
|
47
|
+
if (typeof window === "undefined" || !html) return html;
|
|
48
|
+
const tpl = document.createElement("template");
|
|
49
|
+
tpl.innerHTML = html;
|
|
50
|
+
const walker = document.createTreeWalker(
|
|
51
|
+
tpl.content,
|
|
52
|
+
NodeFilter.SHOW_ELEMENT,
|
|
53
|
+
);
|
|
54
|
+
const toRemove: Element[] = [];
|
|
55
|
+
let node = walker.nextNode() as Element | null;
|
|
56
|
+
while (node) {
|
|
57
|
+
if (node.tagName === "SCRIPT" || node.tagName === "STYLE") {
|
|
58
|
+
toRemove.push(node);
|
|
59
|
+
} else {
|
|
60
|
+
for (const attr of Array.from(node.attributes)) {
|
|
61
|
+
const name = attr.name.toLowerCase();
|
|
62
|
+
if (name.startsWith("on")) node.removeAttribute(attr.name);
|
|
63
|
+
if (
|
|
64
|
+
(name === "href" || name === "src" || name === "xlink:href") &&
|
|
65
|
+
!isSafeUrl(attr.value)
|
|
66
|
+
) {
|
|
67
|
+
node.removeAttribute(attr.name);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
node = walker.nextNode() as Element | null;
|
|
72
|
+
}
|
|
73
|
+
toRemove.forEach((el) => el.remove());
|
|
74
|
+
return tpl.innerHTML;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const editor = useEditor({
|
|
78
|
+
content: sanitizeHtml((props.modelValue as string) ?? ""),
|
|
79
|
+
editable: !props.readonly && !props.disabled,
|
|
80
|
+
extensions: [
|
|
81
|
+
StarterKit,
|
|
82
|
+
Link.configure({
|
|
83
|
+
openOnClick: false,
|
|
84
|
+
protocols: ALLOWED_LINK_PROTOCOLS,
|
|
85
|
+
validate: (href: string) => isSafeUrl(href),
|
|
86
|
+
}),
|
|
87
|
+
Underline,
|
|
88
|
+
...extraExtensions,
|
|
89
|
+
] as AnyExtension[],
|
|
90
|
+
onUpdate: ({ editor: e }) => {
|
|
91
|
+
emit("update:modelValue", e.getHTML());
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Sync readonly prop → editor editable
|
|
96
|
+
watch(
|
|
97
|
+
() => [props.readonly, props.disabled],
|
|
98
|
+
([ro, dis]) => editor.value?.setEditable(!ro && !dis),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// Sync modelValue esterno → editor (es. reset dopo save)
|
|
102
|
+
watch(
|
|
103
|
+
() => props.modelValue,
|
|
104
|
+
(val) => {
|
|
105
|
+
if (editor.value && val !== editor.value.getHTML()) {
|
|
106
|
+
editor.value.commands.setContent(
|
|
107
|
+
sanitizeHtml((val as string) ?? ""),
|
|
108
|
+
false,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
onBeforeUnmount(() => editor.value?.destroy());
|
|
115
|
+
</script>
|
|
116
|
+
|
|
117
|
+
<template>
|
|
118
|
+
<div
|
|
119
|
+
class="mapo-wyg-editor rounded border"
|
|
120
|
+
:class="[
|
|
121
|
+
errors?.length ? 'border-red-300' : 'border-gray-200',
|
|
122
|
+
readonly || disabled ? 'opacity-60' : '',
|
|
123
|
+
]"
|
|
124
|
+
>
|
|
125
|
+
<!-- Toolbar -->
|
|
126
|
+
<div
|
|
127
|
+
v-if="editor"
|
|
128
|
+
class="flex flex-wrap gap-1 border-b border-gray-200 bg-gray-50 px-2 py-1.5"
|
|
129
|
+
>
|
|
130
|
+
<UButton
|
|
131
|
+
size="xs"
|
|
132
|
+
variant="ghost"
|
|
133
|
+
color="neutral"
|
|
134
|
+
:disabled="readonly || disabled"
|
|
135
|
+
:class="{ 'bg-gray-200': editor.isActive('bold') }"
|
|
136
|
+
@click="editor.chain().focus().toggleBold().run()"
|
|
137
|
+
>
|
|
138
|
+
<strong>B</strong>
|
|
139
|
+
</UButton>
|
|
140
|
+
<UButton
|
|
141
|
+
size="xs"
|
|
142
|
+
variant="ghost"
|
|
143
|
+
color="neutral"
|
|
144
|
+
:disabled="readonly || disabled"
|
|
145
|
+
:class="{ 'bg-gray-200': editor.isActive('italic') }"
|
|
146
|
+
@click="editor.chain().focus().toggleItalic().run()"
|
|
147
|
+
>
|
|
148
|
+
<em>I</em>
|
|
149
|
+
</UButton>
|
|
150
|
+
<UButton
|
|
151
|
+
size="xs"
|
|
152
|
+
variant="ghost"
|
|
153
|
+
color="neutral"
|
|
154
|
+
:disabled="readonly || disabled"
|
|
155
|
+
:class="{ 'bg-gray-200': editor.isActive('underline') }"
|
|
156
|
+
@click="editor.chain().focus().toggleUnderline().run()"
|
|
157
|
+
>
|
|
158
|
+
<span class="underline">U</span>
|
|
159
|
+
</UButton>
|
|
160
|
+
|
|
161
|
+
<USeparator orientation="vertical" class="mx-1 h-5" />
|
|
162
|
+
|
|
163
|
+
<UButton
|
|
164
|
+
size="xs"
|
|
165
|
+
variant="ghost"
|
|
166
|
+
color="neutral"
|
|
167
|
+
:disabled="readonly || disabled"
|
|
168
|
+
:class="{ 'bg-gray-200': editor.isActive('heading', { level: 2 }) }"
|
|
169
|
+
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
|
|
170
|
+
>
|
|
171
|
+
H2
|
|
172
|
+
</UButton>
|
|
173
|
+
<UButton
|
|
174
|
+
size="xs"
|
|
175
|
+
variant="ghost"
|
|
176
|
+
color="neutral"
|
|
177
|
+
:disabled="readonly || disabled"
|
|
178
|
+
:class="{ 'bg-gray-200': editor.isActive('heading', { level: 3 }) }"
|
|
179
|
+
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
|
|
180
|
+
>
|
|
181
|
+
H3
|
|
182
|
+
</UButton>
|
|
183
|
+
|
|
184
|
+
<USeparator orientation="vertical" class="mx-1 h-5" />
|
|
185
|
+
|
|
186
|
+
<UButton
|
|
187
|
+
size="xs"
|
|
188
|
+
variant="ghost"
|
|
189
|
+
color="neutral"
|
|
190
|
+
:disabled="readonly || disabled"
|
|
191
|
+
:class="{ 'bg-gray-200': editor.isActive('bulletList') }"
|
|
192
|
+
icon="i-lucide-list"
|
|
193
|
+
@click="editor.chain().focus().toggleBulletList().run()"
|
|
194
|
+
/>
|
|
195
|
+
<UButton
|
|
196
|
+
size="xs"
|
|
197
|
+
variant="ghost"
|
|
198
|
+
color="neutral"
|
|
199
|
+
:disabled="readonly || disabled"
|
|
200
|
+
:class="{ 'bg-gray-200': editor.isActive('orderedList') }"
|
|
201
|
+
icon="i-lucide-list-ordered"
|
|
202
|
+
@click="editor.chain().focus().toggleOrderedList().run()"
|
|
203
|
+
/>
|
|
204
|
+
|
|
205
|
+
<USeparator orientation="vertical" class="mx-1 h-5" />
|
|
206
|
+
|
|
207
|
+
<UButton
|
|
208
|
+
size="xs"
|
|
209
|
+
variant="ghost"
|
|
210
|
+
color="neutral"
|
|
211
|
+
:disabled="readonly || disabled"
|
|
212
|
+
icon="i-lucide-undo"
|
|
213
|
+
@click="editor.chain().focus().undo().run()"
|
|
214
|
+
/>
|
|
215
|
+
<UButton
|
|
216
|
+
size="xs"
|
|
217
|
+
variant="ghost"
|
|
218
|
+
color="neutral"
|
|
219
|
+
:disabled="readonly || disabled"
|
|
220
|
+
icon="i-lucide-redo"
|
|
221
|
+
@click="editor.chain().focus().redo().run()"
|
|
222
|
+
/>
|
|
223
|
+
|
|
224
|
+
<USeparator orientation="vertical" class="mx-1 h-5" />
|
|
225
|
+
|
|
226
|
+
<!-- Insert Image — enabled in Phase 6 when Media Manager is available -->
|
|
227
|
+
<UTooltip
|
|
228
|
+
text="Insert image (requires Media Manager — Phase 6)"
|
|
229
|
+
:delay-open="300"
|
|
230
|
+
>
|
|
231
|
+
<UButton
|
|
232
|
+
size="xs"
|
|
233
|
+
variant="ghost"
|
|
234
|
+
color="neutral"
|
|
235
|
+
icon="i-lucide-image"
|
|
236
|
+
disabled
|
|
237
|
+
/>
|
|
238
|
+
</UTooltip>
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<!-- Contenuto editor -->
|
|
242
|
+
<EditorContent
|
|
243
|
+
:editor="editor"
|
|
244
|
+
class="prose prose-sm max-w-none px-3 py-2 focus-within:outline-none"
|
|
245
|
+
/>
|
|
246
|
+
|
|
247
|
+
<p
|
|
248
|
+
v-for="err in errors"
|
|
249
|
+
:key="err"
|
|
250
|
+
class="px-3 pb-2 text-sm text-red-500"
|
|
251
|
+
role="alert"
|
|
252
|
+
>
|
|
253
|
+
{{ err }}
|
|
254
|
+
</p>
|
|
255
|
+
</div>
|
|
256
|
+
</template>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from "vue";
|
|
3
|
+
import { useNuiFieldAttrs } from "./useNuiFieldAttrs";
|
|
4
|
+
defineOptions({ inheritAttrs: false });
|
|
5
|
+
const rawAttrs = useNuiFieldAttrs();
|
|
6
|
+
// Accepts both `items` (NUI shape `{ label, value }` or plain strings) and the
|
|
7
|
+
// legacy `options` (`{ text, value }`). The registry sets `labelKey: "text"`,
|
|
8
|
+
// so mirror `label` into `text` when missing — otherwise NUI-shaped items
|
|
9
|
+
// would render with empty labels.
|
|
10
|
+
const attrs = computed(() => {
|
|
11
|
+
const { options, items, ...rest } = rawAttrs.value as Record<string, unknown>;
|
|
12
|
+
const list = (items ?? options) as
|
|
13
|
+
| Array<string | Record<string, unknown>>
|
|
14
|
+
| undefined;
|
|
15
|
+
const normalised = list?.map((it) =>
|
|
16
|
+
typeof it === "object" && it !== null
|
|
17
|
+
? { ...it, text: it.text ?? it.label ?? String(it.value) }
|
|
18
|
+
: it,
|
|
19
|
+
);
|
|
20
|
+
// This wrapper is an untyped passthrough: cast so the dynamic items array
|
|
21
|
+
// satisfies USelectMenu's generic `items` prop.
|
|
22
|
+
return { ...rest, items: normalised as never[] | undefined };
|
|
23
|
+
});
|
|
24
|
+
</script>
|
|
25
|
+
<template>
|
|
26
|
+
<USelectMenu v-bind="attrs" />
|
|
27
|
+
</template>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { computed, useAttrs, type ComputedRef } from "vue";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared logic for NUI wrappers: strips internal props (`descriptor`, `errors`)
|
|
5
|
+
* from the binding passed to the underlying NUI component and derives visual
|
|
6
|
+
* error flags (`color: 'error'` + `aria-invalid`).
|
|
7
|
+
*
|
|
8
|
+
* Note: NUI v3 form components accept `color="error"` for the red border.
|
|
9
|
+
* We apply it here as a fallback when the caller has not set it already,
|
|
10
|
+
* so consumers that do not go through `MapoFormField` still get the correct
|
|
11
|
+
* color when `errors` is non-empty.
|
|
12
|
+
*/
|
|
13
|
+
export function useNuiFieldAttrs(): ComputedRef<Record<string, unknown>> {
|
|
14
|
+
const rawAttrs = useAttrs();
|
|
15
|
+
return computed(() => {
|
|
16
|
+
const {
|
|
17
|
+
descriptor: _d,
|
|
18
|
+
errors,
|
|
19
|
+
color,
|
|
20
|
+
...rest
|
|
21
|
+
} = rawAttrs as Record<string, unknown>;
|
|
22
|
+
const errs = (errors as string[] | undefined) ?? [];
|
|
23
|
+
const hasError = errs.length > 0;
|
|
24
|
+
return {
|
|
25
|
+
...rest,
|
|
26
|
+
color: color ?? (hasError ? "error" : undefined),
|
|
27
|
+
"aria-invalid": hasError ? "true" : undefined,
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useNuxtApp } from "#app";
|
|
2
|
+
import type {
|
|
3
|
+
FieldComponentEntry,
|
|
4
|
+
FieldAccessor,
|
|
5
|
+
FieldRegistry,
|
|
6
|
+
} from "../types/index.js";
|
|
7
|
+
|
|
8
|
+
/** Options for registering or overriding a custom field type. */
|
|
9
|
+
export interface DefineFormFieldOptions {
|
|
10
|
+
/** Default attrs applied to all descriptors of this `type`. */
|
|
11
|
+
attrs?: Record<string, unknown>;
|
|
12
|
+
/** Default get/set accessor for this `type`. */
|
|
13
|
+
accessor?: FieldAccessor;
|
|
14
|
+
/** Replaces an existing registration without warning. */
|
|
15
|
+
override?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Typed API for registering a custom field component.
|
|
20
|
+
* Must be called from a Nuxt plugin (the load order guarantees that `$mapoFormRegistry`
|
|
21
|
+
* is already present — the `@mapomodule/form` plugin is loaded first).
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* // app/plugins/my-fields.ts
|
|
25
|
+
* export default defineNuxtPlugin(() => {
|
|
26
|
+
* defineFormField('rich-text', () => import('~/components/RichText.vue'), {
|
|
27
|
+
* attrs: { rows: 6 },
|
|
28
|
+
* })
|
|
29
|
+
* })
|
|
30
|
+
*/
|
|
31
|
+
export function defineFormField(
|
|
32
|
+
type: string,
|
|
33
|
+
entry: FieldComponentEntry,
|
|
34
|
+
opts: DefineFormFieldOptions = {},
|
|
35
|
+
): void {
|
|
36
|
+
const nuxt = useNuxtApp() as unknown as { $mapoFormRegistry?: FieldRegistry };
|
|
37
|
+
const registry = nuxt.$mapoFormRegistry;
|
|
38
|
+
if (!registry) {
|
|
39
|
+
if (import.meta.dev) {
|
|
40
|
+
console.warn(
|
|
41
|
+
`[Mapo] defineFormField('${type}') called before $mapoFormRegistry is available. ` +
|
|
42
|
+
`Make sure the plugin is loaded after @mapomodule/form.`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (registry.mapping[type] && !opts.override) {
|
|
48
|
+
if (import.meta.dev) {
|
|
49
|
+
console.warn(
|
|
50
|
+
`[Mapo] defineFormField: type "${type}" already registered. ` +
|
|
51
|
+
`Pass { override: true } to replace it intentionally.`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
registry.mapping[type] = entry;
|
|
56
|
+
if (opts.attrs) {
|
|
57
|
+
registry.attrs[type] = { ...(registry.attrs[type] ?? {}), ...opts.attrs };
|
|
58
|
+
}
|
|
59
|
+
if (opts.accessor) {
|
|
60
|
+
registry.accessor[type] = opts.accessor;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { getCurrentInstance, inject, provide, ref, type Ref } from "vue";
|
|
2
|
+
|
|
3
|
+
const LANG_KEY = Symbol("mapoCurrentLang");
|
|
4
|
+
|
|
5
|
+
/** Provides the current form language to descendant components. */
|
|
6
|
+
export function provideCurrentLang(lang: Ref<string>) {
|
|
7
|
+
provide(LANG_KEY, lang);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Returns the current form language, falling back to the provided ref when needed. */
|
|
11
|
+
export function useCurrentLang(fallback?: Ref<string>): Ref<string> {
|
|
12
|
+
// Outside setup() (for example in tests), Vue 3 returns `undefined` from inject()
|
|
13
|
+
// and ignores the default value, so the "no instance" case must be handled explicitly.
|
|
14
|
+
if (!getCurrentInstance()) return fallback ?? ref("");
|
|
15
|
+
return inject<Ref<string>>(LANG_KEY, fallback ?? ref(""));
|
|
16
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computed,
|
|
3
|
+
readonly,
|
|
4
|
+
shallowRef,
|
|
5
|
+
type Ref,
|
|
6
|
+
type DeepReadonly,
|
|
7
|
+
type ShallowRef,
|
|
8
|
+
} from "vue";
|
|
9
|
+
import type { AnyFieldDescriptor, FieldRegistry } from "../types/index.js";
|
|
10
|
+
|
|
11
|
+
/** Describes the currently focused repeater item in focus mode. */
|
|
12
|
+
export interface FocusTarget {
|
|
13
|
+
/** Descriptor of the repeater field that contains the focused item. */
|
|
14
|
+
descriptor: AnyFieldDescriptor;
|
|
15
|
+
/** Fields for the focused item, resolved from the template when needed. */
|
|
16
|
+
fields: AnyFieldDescriptor[];
|
|
17
|
+
/**
|
|
18
|
+
* Initial snapshot of the item model at the time the portal was opened.
|
|
19
|
+
* Stored as a plain object (not a Ref) to avoid Vue's auto-unwrap behaviour
|
|
20
|
+
* when placed inside Nuxt's useState reactive container.
|
|
21
|
+
*/
|
|
22
|
+
model: Record<string, unknown>;
|
|
23
|
+
/** Errors for this item (already filtered). */
|
|
24
|
+
errors: Ref<Record<string, string[]>>;
|
|
25
|
+
/** Callback invoked when the item model is updated. */
|
|
26
|
+
onUpdate: (val: Record<string, unknown>) => void;
|
|
27
|
+
/** Field component registry passed to the sub-form. */
|
|
28
|
+
registry: FieldRegistry;
|
|
29
|
+
/** Available language codes. */
|
|
30
|
+
languages: string[];
|
|
31
|
+
/** Currently active language code. */
|
|
32
|
+
currentLang: string;
|
|
33
|
+
/** Visual breadcrumb trail: ['Article', 'Blocks', 'Block 3'] */
|
|
34
|
+
breadcrumb: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Shared focus-mode API used by nested repeater editors. */
|
|
38
|
+
export interface FocusModeReturn {
|
|
39
|
+
focusTarget: DeepReadonly<ShallowRef<FocusTarget | null>>;
|
|
40
|
+
isActive: Ref<boolean>;
|
|
41
|
+
enter: (target: FocusTarget) => void;
|
|
42
|
+
exit: () => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Fallback for non-Nuxt environments (tests, Storybook): a client-only singleton is
|
|
46
|
+
// acceptable. In Nuxt we use `useState` to guarantee per-request isolation in SSR
|
|
47
|
+
// and avoid cross-tenant state leaks.
|
|
48
|
+
const _clientFallback = shallowRef<FocusTarget | null>(null);
|
|
49
|
+
|
|
50
|
+
function getStateRef(): Ref<FocusTarget | null> {
|
|
51
|
+
// `useState` is auto-imported by Nuxt; in vanilla environments we fall back to the singleton.
|
|
52
|
+
const nuxtUseState = (
|
|
53
|
+
globalThis as { useState?: <X>(key: string, init: () => X) => Ref<X> }
|
|
54
|
+
).useState;
|
|
55
|
+
if (typeof nuxtUseState === "function") {
|
|
56
|
+
return nuxtUseState<FocusTarget | null>(
|
|
57
|
+
"mapo:form:focusTarget",
|
|
58
|
+
() => null,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return _clientFallback;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Exposes a shared focus-mode state for nested repeater items.
|
|
66
|
+
* In Nuxt it uses `useState()` for SSR-safe request isolation.
|
|
67
|
+
*/
|
|
68
|
+
export function useFocusMode(): FocusModeReturn {
|
|
69
|
+
const state = getStateRef();
|
|
70
|
+
|
|
71
|
+
function enter(target: FocusTarget): void {
|
|
72
|
+
state.value = target;
|
|
73
|
+
}
|
|
74
|
+
function exit(): void {
|
|
75
|
+
state.value = null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
focusTarget: readonly(state) as DeepReadonly<
|
|
80
|
+
ShallowRef<FocusTarget | null>
|
|
81
|
+
>,
|
|
82
|
+
isActive: computed(() => state.value !== null),
|
|
83
|
+
enter,
|
|
84
|
+
exit,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { watch, onMounted, onUnmounted, type Ref } from "vue";
|
|
2
|
+
import { debounce } from "@mapomodule/utils";
|
|
3
|
+
|
|
4
|
+
interface DraftEntry<T> {
|
|
5
|
+
model: T;
|
|
6
|
+
savedAt: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Options for draft persistence and restoration. */
|
|
10
|
+
export interface UseFormDraftOptions<T> {
|
|
11
|
+
model: Ref<T>;
|
|
12
|
+
isDirty: Ref<boolean>;
|
|
13
|
+
/**
|
|
14
|
+
* Unique draft key. It is prefixed with `mapo:draft:{userId}:` when `userId` is provided,
|
|
15
|
+
* otherwise `mapo:draft:`. Example: `article:42` → `mapo:draft:1:article:42`.
|
|
16
|
+
*/
|
|
17
|
+
key: string;
|
|
18
|
+
/**
|
|
19
|
+
* User identifier used to scope the draft so it is never shared across accounts.
|
|
20
|
+
* Without this, a draft survives logout and may be seen by the next user on the same browser.
|
|
21
|
+
* Pass `useAuthStore().user?.id` (or equivalent) from the calling composable.
|
|
22
|
+
*/
|
|
23
|
+
userId?: string | number;
|
|
24
|
+
/** Milliseconds between debounced writes to localStorage. Default: `2000`. */
|
|
25
|
+
debounce?: number;
|
|
26
|
+
/** TTL in milliseconds before the draft is discarded. Default: `86_400_000` (24h). */
|
|
27
|
+
ttl?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Called on mount when a valid, non-expired draft exists.
|
|
30
|
+
* Show a notification here and decide whether to restore it.
|
|
31
|
+
*/
|
|
32
|
+
onRestore?: (draft: T, savedAt: Date) => void;
|
|
33
|
+
/** Called after each successful write to localStorage. */
|
|
34
|
+
onSave?: (savedAt: Date) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Persists form drafts to `localStorage` and restores them on mount when available.
|
|
39
|
+
*
|
|
40
|
+
* The draft is written debounced while `isDirty` is `true`. On mount, if a valid
|
|
41
|
+
* non-expired draft is found, `onRestore` is called so the caller can decide
|
|
42
|
+
* whether to apply it (e.g. show a snack notification first).
|
|
43
|
+
*
|
|
44
|
+
* @param options - Configuration for key, TTL, debounce interval and lifecycle callbacks.
|
|
45
|
+
* @returns An object with `{ clearDraft, getDraft, hasDraft }` for manual control.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* useFormDraft({
|
|
49
|
+
* model,
|
|
50
|
+
* isDirty,
|
|
51
|
+
* key: `article:${route.params.id}`,
|
|
52
|
+
* onRestore: (draft, savedAt) => {
|
|
53
|
+
* snack.show(`Draft from ${savedAt.toLocaleString()} restored`)
|
|
54
|
+
* Object.assign(model.value, draft)
|
|
55
|
+
* },
|
|
56
|
+
* })
|
|
57
|
+
*/
|
|
58
|
+
export function useFormDraft<T>(options: UseFormDraftOptions<T>) {
|
|
59
|
+
const storageKey =
|
|
60
|
+
options.userId != null
|
|
61
|
+
? `mapo:draft:${options.userId}:${options.key}`
|
|
62
|
+
: `mapo:draft:${options.key}`;
|
|
63
|
+
const ttl = options.ttl ?? 86_400_000;
|
|
64
|
+
const canUseStorage = typeof localStorage !== "undefined";
|
|
65
|
+
|
|
66
|
+
const debouncedSave = debounce(() => {
|
|
67
|
+
if (!canUseStorage) return;
|
|
68
|
+
if (!options.isDirty.value) return;
|
|
69
|
+
const entry: DraftEntry<T> = {
|
|
70
|
+
model: options.model.value,
|
|
71
|
+
savedAt: Date.now(),
|
|
72
|
+
};
|
|
73
|
+
try {
|
|
74
|
+
localStorage.setItem(storageKey, JSON.stringify(entry));
|
|
75
|
+
options.onSave?.(new Date(entry.savedAt));
|
|
76
|
+
} catch {
|
|
77
|
+
// quota exceeded — silently ignore
|
|
78
|
+
}
|
|
79
|
+
}, options.debounce ?? 2000);
|
|
80
|
+
|
|
81
|
+
const stopWatch = watch(options.model, debouncedSave, { deep: true });
|
|
82
|
+
|
|
83
|
+
onMounted(() => {
|
|
84
|
+
const draft = getDraft();
|
|
85
|
+
if (!draft) return;
|
|
86
|
+
options.onRestore?.(draft.model, new Date(draft.savedAt));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
onUnmounted(() => {
|
|
90
|
+
stopWatch();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
function getDraft(): DraftEntry<T> | null {
|
|
94
|
+
if (!canUseStorage) return null;
|
|
95
|
+
try {
|
|
96
|
+
const raw = localStorage.getItem(storageKey);
|
|
97
|
+
if (!raw) return null;
|
|
98
|
+
const entry = JSON.parse(raw) as DraftEntry<T>;
|
|
99
|
+
if (Date.now() - entry.savedAt > ttl) {
|
|
100
|
+
localStorage.removeItem(storageKey);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return entry;
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function clearDraft() {
|
|
110
|
+
if (!canUseStorage) return;
|
|
111
|
+
localStorage.removeItem(storageKey);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function hasDraft(): boolean {
|
|
115
|
+
return getDraft() !== null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { clearDraft, getDraft, hasDraft };
|
|
119
|
+
}
|