@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.
Files changed (60) hide show
  1. package/README.md +155 -0
  2. package/dist/index.d.cts +1 -0
  3. package/dist/index.d.mts +1 -0
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.mjs +34 -0
  6. package/dist/module.d.cts +2 -0
  7. package/dist/module.d.mts +2 -0
  8. package/dist/module.d.ts +2 -0
  9. package/dist/module.json +9 -0
  10. package/dist/module.mjs +18 -0
  11. package/dist/types.d.mts +13 -0
  12. package/package.json +78 -0
  13. package/src/__tests__/__mocks__/nuxt-imports.ts +5 -0
  14. package/src/__tests__/registry.test.ts +105 -0
  15. package/src/__tests__/useFormFromSchema.test.ts +415 -0
  16. package/src/__tests__/useMapoForm.test.ts +319 -0
  17. package/src/index.ts +49 -0
  18. package/src/module.ts +213 -0
  19. package/src/runtime/components/MapoFocusPortal.vue +154 -0
  20. package/src/runtime/components/MapoForm.vue +247 -0
  21. package/src/runtime/components/MapoFormField.vue +350 -0
  22. package/src/runtime/components/MapoFormFlatSection.vue +163 -0
  23. package/src/runtime/components/MapoFormGroup.vue +283 -0
  24. package/src/runtime/components/MapoFormTabs.vue +116 -0
  25. package/src/runtime/components/MapoUnknownField.vue +74 -0
  26. package/src/runtime/components/fields/MapoDateField.vue +98 -0
  27. package/src/runtime/components/fields/MapoDateTimeField.vue +192 -0
  28. package/src/runtime/components/fields/MapoFileField.vue +106 -0
  29. package/src/runtime/components/fields/MapoFksField.vue +151 -0
  30. package/src/runtime/components/fields/MapoMapField.vue +117 -0
  31. package/src/runtime/components/fields/MapoMapFieldClient.vue +92 -0
  32. package/src/runtime/components/fields/MapoRepeater.vue +553 -0
  33. package/src/runtime/components/fields/MapoRepeaterItem.vue +409 -0
  34. package/src/runtime/components/fields/MapoSeoPreview.vue +137 -0
  35. package/src/runtime/components/fields/MapoTimeField.vue +59 -0
  36. package/src/runtime/components/fields/MapoWygEditor.vue +256 -0
  37. package/src/runtime/components/fields/nui/NuiCheckbox.vue +8 -0
  38. package/src/runtime/components/fields/nui/NuiInput.vue +8 -0
  39. package/src/runtime/components/fields/nui/NuiSelectMenu.vue +27 -0
  40. package/src/runtime/components/fields/nui/NuiSlider.vue +8 -0
  41. package/src/runtime/components/fields/nui/NuiSwitch.vue +8 -0
  42. package/src/runtime/components/fields/nui/NuiTextarea.vue +8 -0
  43. package/src/runtime/components/fields/nui/useNuiFieldAttrs.ts +30 -0
  44. package/src/runtime/composables/defineFormField.ts +62 -0
  45. package/src/runtime/composables/useCurrentLang.ts +16 -0
  46. package/src/runtime/composables/useFocusMode.ts +86 -0
  47. package/src/runtime/composables/useFormDraft.ts +119 -0
  48. package/src/runtime/composables/useFormFromSchema.ts +253 -0
  49. package/src/runtime/composables/useMapoForm.ts +484 -0
  50. package/src/runtime/composables/useProgressiveDisclosure.ts +101 -0
  51. package/src/runtime/composables/useUnsavedChangesGuard.ts +58 -0
  52. package/src/runtime/devtools/DevtoolsFormsPage.vue +256 -0
  53. package/src/runtime/plugins/form-registry.ts +43 -0
  54. package/src/runtime/registry/defaults.ts +97 -0
  55. package/src/runtime/types/descriptor.ts +411 -0
  56. package/src/runtime/types/index.ts +40 -0
  57. package/src/runtime/types/layout.ts +22 -0
  58. package/src/runtime/types/registry.ts +98 -0
  59. package/src/runtime/utils/flattenFieldGroups.ts +86 -0
  60. package/src/vue-shim.d.ts +12 -0
package/README.md ADDED
@@ -0,0 +1,155 @@
1
+ # @mapomodule/form
2
+
3
+ Form engine for Mapo v2 — descriptor-driven, typesafe, headless-ready admin forms for Nuxt 4 + Django/DRF.
4
+
5
+ ## Features
6
+
7
+ - **Descriptor-driven**: define fields as `FieldDescriptor[]` — layout, validation, i18n in one object
8
+ - **All field types built-in**: text, select, date/time, FK/M2M autocomplete, WYSIWYG editor, SEO preview, map, repeater
9
+ - **Headless composable**: `useMapoForm()` for fully custom layouts; `<MapoForm>` for the default shell
10
+ - **Registry auto-injection**: `<MapoForm>` and `<MapoDetail>` resolve `$mapoFormRegistry` automatically — no boilerplate
11
+ - **Typed extension API**: `defineFormField(type, entry, opts)` to register custom fields with collision detection
12
+ - **JSON Schema bridge**: `useFormFromSchema()` generates descriptors from Pydantic v2 / DRF-spectacular schemas
13
+ - **Multilingual fields**: `translatable: true` writes to `model.translations[lang].field` automatically
14
+ - **Client validation**: `validate(value, ctx)` per descriptor + `required` shorthand
15
+ - **Tab/group layout**: declare `tab` and `group` per field — tabs render automatically (incl. nested tabs), groups are collapsible. Tab panels expose `tab.<name>.before/after`, full override, `.label` and `tab-bar-end` slots
16
+ - **Consistent repeater items**: `MapoRepeater` drives each item through `useMapoForm`, so nested-path keys, `translatable`, accessors, `descriptor.is` overrides and **client validation** behave identically inside a repeater
17
+ - **Draft persistence**: `draftKey` option on `useMapoForm()` auto-saves to localStorage every 2 s; `checkDraft()` / `draftBanner` offer restore/discard; clears on successful `submit()`
18
+ - **Fail-soft**: unknown field types render a non-destructive placeholder, never crash the form
19
+
20
+ ## Install
21
+
22
+ `@mapomodule/form` is included automatically when you install `mapomodule` (recommended). No additional module entry is needed.
23
+
24
+ ```ts
25
+ // nuxt.config.ts
26
+ export default defineNuxtConfig({
27
+ modules: ["@nuxt/ui", "mapomodule"],
28
+ mapo: {
29
+ form: {
30
+ // optional — global field registry defaults
31
+ },
32
+ },
33
+ });
34
+ ```
35
+
36
+ ## Quick start
37
+
38
+ ```vue
39
+ <script setup lang="ts">
40
+ import type { FieldDescriptor } from "@mapomodule/form";
41
+
42
+ interface Article {
43
+ title: string;
44
+ slug: string;
45
+ is_draft: boolean;
46
+ }
47
+
48
+ const article = ref<Article>({ title: "", slug: "", is_draft: true });
49
+ const errors = ref<Record<string, string[]>>({});
50
+
51
+ const fields: FieldDescriptor<Article>[] = [
52
+ { key: "title", type: "text", label: "Title", required: true },
53
+ { key: "slug", type: "text", label: "Slug" },
54
+ { key: "is_draft", type: "switch", label: "Draft" },
55
+ ];
56
+ </script>
57
+
58
+ <template>
59
+ <MapoForm v-model="article" :fields="fields" :errors="errors" />
60
+ </template>
61
+ ```
62
+
63
+ The `registry` prop is optional — `<MapoForm>` falls back to the global `$mapoFormRegistry` provided by this module's plugin. Pass it explicitly only when you want to swap the registry for a single form.
64
+
65
+ ## Field types
66
+
67
+ | Type | Component | Notes |
68
+ | ---------------------------- | --------------------- | ----------------------------------------------------------- |
69
+ | `text` | `UInput` | |
70
+ | `textarea` | `UTextarea` | |
71
+ | `number` | `UInput[type=number]` | |
72
+ | `boolean` | `UCheckbox` | |
73
+ | `switch` | `USwitch` | |
74
+ | `color` | `UInput[type=color]` | |
75
+ | `slider` | `USlider` | |
76
+ | `file` | `MapoFileField` | Current-file preview, image thumbnail, remove button |
77
+ | `select` | `USelectMenu` | requires `attrs.options` |
78
+ | `date` / `datetime` / `time` | `MapoDateField` etc. | ISO string in model; `tz: 'naive' \| 'utc'` for `datetime` |
79
+ | `fks` | `MapoFksField` | FK autocomplete; `attrs.endpoint` required |
80
+ | `m2m` | `MapoFksField` | Multi-select autocomplete, removable chips |
81
+ | `editor` | `MapoWygEditor` | Tiptap v2; sanitized output, safe `Link` validator |
82
+ | `seo` | `MapoSeoPreview` | Title / description with live SERP preview |
83
+ | `map` | `MapoMapField` | Leaflet, `{ lat, lng }` in model, SSR-safe via `ClientOnly` |
84
+ | `repeater` | `MapoRepeater` | Nested fields, drag-and-drop, undo stack, templates |
85
+
86
+ ## JSON Schema → Form
87
+
88
+ ```ts
89
+ const schema = await $fetch("/api/schema/");
90
+ const fields = useFormFromSchema(schema, {
91
+ exclude: ["id", "created_at"],
92
+ overrides: {
93
+ body: { type: "editor", translatable: true },
94
+ },
95
+ });
96
+ ```
97
+
98
+ ## Headless
99
+
100
+ ```ts
101
+ const form = useMapoForm({ model, fields, registry: $mapoFormRegistry });
102
+
103
+ // form.isDirty, form.isLoading, form.getPatch(), form.submit(handler)
104
+ ```
105
+
106
+ The headless composable still requires the registry explicitly — `useNuxtApp()` is not available in every context where you might want to drive a form (tests, storybook, server-side preview).
107
+
108
+ ## Draft persistence (headless)
109
+
110
+ ```ts
111
+ const form = useMapoForm({
112
+ model,
113
+ fields,
114
+ registry: $mapoFormRegistry,
115
+ draftKey: `article:${route.params.id}`,
116
+ });
117
+
118
+ // After fetch:
119
+ form.checkDraft(); // populates form.draftBanner if a draft is found
120
+ ```
121
+
122
+ `form.draftBanner.value` exposes `{ savedAt, restore(), discard() }`. On `submit()` success the draft is cleared automatically. When using `<MapoDetail :draft="true">`, all of this is handled for you.
123
+
124
+ ## Custom fields
125
+
126
+ The recommended way to register a custom field is the typed `defineFormField()` helper, called from a Nuxt plugin:
127
+
128
+ ```ts
129
+ // app/plugins/my-fields.ts
130
+ export default defineNuxtPlugin(() => {
131
+ defineFormField("rich-text", () => import("~/components/RichText.vue"), {
132
+ attrs: { rows: 6 },
133
+ });
134
+ });
135
+ ```
136
+
137
+ Plugins in `app/plugins/` run after module plugins, so `$mapoFormRegistry` is always available. Re-registering an existing type prints a development warning unless you pass `{ override: true }` explicitly.
138
+
139
+ Any component with `modelValue` + `descriptor` + `errors` + `readonly` props works as a field.
140
+
141
+ ## Documentation
142
+
143
+ Full docs: [Form Engine — Quickstart](../../../docs/uikit/form/index.md)
144
+
145
+ - [All field types](../../../docs/uikit/form/add-fields.md)
146
+ - [Validation](../../../docs/uikit/form/validation.md)
147
+ - [i18n / multilingual fields](../../../docs/uikit/form/i18n.md)
148
+ - [`useMapoForm()` headless](../../../docs/uikit/form/composable.md)
149
+ - [Custom fields & registry](../../../docs/uikit/form/custom-fields.md)
150
+ - [Slot system](../../../docs/uikit/form/slots.md)
151
+ - [JSON Schema bridge](../../../docs/uikit/form/schema-to-form.md)
152
+
153
+ ## License
154
+
155
+ MIT
@@ -0,0 +1 @@
1
+ export * from "/Volumes/Development/Lotrek/Projects/mapo-new/packages/@mapomodule/form/src/index.js";
@@ -0,0 +1 @@
1
+ export * from "/Volumes/Development/Lotrek/Projects/mapo-new/packages/@mapomodule/form/src/index.js";
@@ -0,0 +1 @@
1
+ export * from "/Volumes/Development/Lotrek/Projects/mapo-new/packages/@mapomodule/form/src/index.js";
package/dist/index.mjs ADDED
@@ -0,0 +1,34 @@
1
+ import { createJiti } from "file:///Volumes/Development/Lotrek/Projects/mapo-new/node_modules/.pnpm/jiti@2.6.1/node_modules/jiti/lib/jiti.mjs";
2
+
3
+ const jiti = createJiti(import.meta.url, {
4
+ "interopDefault": true,
5
+ "alias": {
6
+ "@mapomodule/form": "/Volumes/Development/Lotrek/Projects/mapo-new/packages/@mapomodule/form"
7
+ },
8
+ "transformOptions": {
9
+ "babel": {
10
+ "plugins": []
11
+ }
12
+ }
13
+ })
14
+
15
+ /** @type {import("/Volumes/Development/Lotrek/Projects/mapo-new/packages/@mapomodule/form/src/index.js")} */
16
+ const _module = await jiti.import("/Volumes/Development/Lotrek/Projects/mapo-new/packages/@mapomodule/form/src/index.ts");
17
+
18
+ export const useFormFromSchema = _module.useFormFromSchema;
19
+ export const useFormDraft = _module.useFormDraft;
20
+ export const useFocusMode = _module.useFocusMode;
21
+ export const useUnsavedChangesGuard = _module.useUnsavedChangesGuard;
22
+ export const when = _module.when;
23
+ export const whenAny = _module.whenAny;
24
+ export const whenNot = _module.whenNot;
25
+ export const matchesField = _module.matchesField;
26
+ export const isOneOf = _module.isOneOf;
27
+ export const isNoneOf = _module.isNoneOf;
28
+ export const isNotEmpty = _module.isNotEmpty;
29
+ export const isEmpty = _module.isEmpty;
30
+ export const greaterThan = _module.greaterThan;
31
+ export const lessThan = _module.lessThan;
32
+ export const matches = _module.matches;
33
+ export const flattenFieldGroups = _module.flattenFieldGroups;
34
+ export const KnownFieldType = _module.KnownFieldType;
@@ -0,0 +1,2 @@
1
+ export * from "/Volumes/Development/Lotrek/Projects/mapo-new/packages/@mapomodule/form/src/module.js";
2
+ export { default } from "/Volumes/Development/Lotrek/Projects/mapo-new/packages/@mapomodule/form/src/module.js";
@@ -0,0 +1,2 @@
1
+ export * from "/Volumes/Development/Lotrek/Projects/mapo-new/packages/@mapomodule/form/src/module.js";
2
+ export { default } from "/Volumes/Development/Lotrek/Projects/mapo-new/packages/@mapomodule/form/src/module.js";
@@ -0,0 +1,2 @@
1
+ export * from "/Volumes/Development/Lotrek/Projects/mapo-new/packages/@mapomodule/form/src/module.js";
2
+ export { default } from "/Volumes/Development/Lotrek/Projects/mapo-new/packages/@mapomodule/form/src/module.js";
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "@mapomodule/form",
3
+ "configKey": "mapoForm",
4
+ "version": "2.0.0-beta.0",
5
+ "builder": {
6
+ "@nuxt/module-builder": "1.0.2",
7
+ "unbuild": "unknown"
8
+ }
9
+ }
@@ -0,0 +1,18 @@
1
+ import { createJiti } from "file:///Volumes/Development/Lotrek/Projects/mapo-new/node_modules/.pnpm/jiti@2.6.1/node_modules/jiti/lib/jiti.mjs";
2
+
3
+ const jiti = createJiti(import.meta.url, {
4
+ "interopDefault": true,
5
+ "alias": {
6
+ "@mapomodule/form": "/Volumes/Development/Lotrek/Projects/mapo-new/packages/@mapomodule/form"
7
+ },
8
+ "transformOptions": {
9
+ "babel": {
10
+ "plugins": []
11
+ }
12
+ }
13
+ })
14
+
15
+ /** @type {import("/Volumes/Development/Lotrek/Projects/mapo-new/packages/@mapomodule/form/src/module.js")} */
16
+ const _module = await jiti.import("/Volumes/Development/Lotrek/Projects/mapo-new/packages/@mapomodule/form/src/module.ts");
17
+
18
+ export default _module?.default ?? _module;
@@ -0,0 +1,13 @@
1
+ import type { ModuleHooks, ModuleRuntimeHooks, ModuleRuntimeConfig, ModulePublicRuntimeConfig } from './module.mjs'
2
+
3
+ declare module '#app' {
4
+ interface RuntimeNuxtHooks extends ModuleRuntimeHooks {}
5
+ }
6
+
7
+ declare module '@nuxt/schema' {
8
+ interface NuxtHooks extends ModuleHooks {}
9
+ interface RuntimeConfig extends ModuleRuntimeConfig {}
10
+ interface PublicRuntimeConfig extends ModulePublicRuntimeConfig {}
11
+ }
12
+
13
+ export * from "./module.mjs"
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "@mapomodule/form",
3
+ "version": "2.0.0-beta.0",
4
+ "description": "Mapo declarative typed form engine and field components",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Lotrek Agency (https://lotrek.it)",
8
+ "homepage": "https://lotrekagency.github.io/mapo-new/",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/lotrekagency/mapo-new.git",
12
+ "directory": "packages/@mapomodule/form"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/lotrekagency/mapo-new/issues"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public",
19
+ "tag": "next"
20
+ },
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/module.d.mts",
24
+ "import": "./dist/module.mjs"
25
+ },
26
+ "./types": {
27
+ "types": "./dist/index.d.mts",
28
+ "import": "./dist/index.mjs"
29
+ },
30
+ "./runtime/*": {
31
+ "types": "./src/runtime/*.ts",
32
+ "import": "./dist/runtime/*.js",
33
+ "default": "./src/runtime/*"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "src"
39
+ ],
40
+ "scripts": {
41
+ "build": "nuxt-module-build build && vue-tsc -p tsconfig.build-types.json && node ../../../scripts/clean-empty-vue-dts.mjs dist",
42
+ "dev": "nuxt-module-build build --stub --watch",
43
+ "prepare": "nuxt-module-build build --stub",
44
+ "dev:prepare": "nuxt-module-build build --stub",
45
+ "typecheck": "vue-tsc --noEmit",
46
+ "lint": "eslint src",
47
+ "test": "vitest run --passWithNoTests"
48
+ },
49
+ "dependencies": {
50
+ "@internationalized/date": "^3.7.0",
51
+ "@mapomodule/store": "workspace:*",
52
+ "@mapomodule/utils": "workspace:*",
53
+ "@tiptap/extension-link": "^2.11.5",
54
+ "@tiptap/extension-underline": "^2.11.5",
55
+ "@tiptap/starter-kit": "^2.11.5",
56
+ "@tiptap/vue-3": "^2.11.5",
57
+ "leaflet": "^1.9.4",
58
+ "vue-draggable-plus": "^0.6.0"
59
+ },
60
+ "optionalDependencies": {
61
+ "@types/leaflet": "^1.9.18"
62
+ },
63
+ "peerDependencies": {
64
+ "nuxt": ">=4.0.0",
65
+ "vue": ">=3.5.0"
66
+ },
67
+ "devDependencies": {
68
+ "@nuxt/kit": "4.4.2",
69
+ "@nuxt/module-builder": "^1.0.2",
70
+ "@nuxt/schema": "4.4.2",
71
+ "nuxt": "^4.4.2",
72
+ "vitest": "^3.2.0",
73
+ "vue": "^3.5.32",
74
+ "vue-router": "^5.0.4",
75
+ "vue-tsc": "^3.3.4",
76
+ "zod": "^3.23.8"
77
+ }
78
+ }
@@ -0,0 +1,5 @@
1
+ import { vi } from "vitest";
2
+
3
+ export const useSnackStore = vi.fn(() => ({
4
+ show: vi.fn(),
5
+ }));
@@ -0,0 +1,105 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ resolveFieldComponent,
4
+ resolveFieldAttrs,
5
+ resolveFieldAccessor,
6
+ } from "../runtime/types/registry.js";
7
+ import { defaultRegistry } from "../runtime/registry/defaults.js";
8
+ import type {
9
+ AnyFieldDescriptor,
10
+ FieldDescriptor,
11
+ } from "../runtime/types/descriptor.js";
12
+
13
+ describe("resolveFieldComponent", () => {
14
+ it("returns a lazy function for text type", () => {
15
+ const d = { key: "title", type: "text" } as FieldDescriptor;
16
+ expect(typeof resolveFieldComponent(d, defaultRegistry)).toBe("function");
17
+ });
18
+
19
+ it("returns a lazy function for select type", () => {
20
+ const d = {
21
+ key: "cat",
22
+ type: "select",
23
+ attrs: { options: [] },
24
+ } as FieldDescriptor;
25
+ expect(typeof resolveFieldComponent(d, defaultRegistry)).toBe("function");
26
+ });
27
+
28
+ it("returns a lazy function for date type", () => {
29
+ const d = { key: "pub", type: "date" } as FieldDescriptor;
30
+ const entry = resolveFieldComponent(d, defaultRegistry);
31
+ expect(typeof entry).toBe("function");
32
+ });
33
+
34
+ it("descriptor.is overrides registry", () => {
35
+ const CustomComp = {} as any;
36
+ const d = { key: "x", type: "text", is: CustomComp } as FieldDescriptor;
37
+ expect(resolveFieldComponent(d, defaultRegistry)).toBe(CustomComp);
38
+ });
39
+
40
+ it("returns null for unknown type", () => {
41
+ const d = { key: "x", type: "unknown-xyz" } as AnyFieldDescriptor;
42
+ expect(resolveFieldComponent(d, defaultRegistry)).toBeNull();
43
+ });
44
+
45
+ it("user registry override wins over default", () => {
46
+ const CustomFn = () => Promise.resolve({ default: {} as any });
47
+ const registry = {
48
+ ...defaultRegistry,
49
+ mapping: { ...defaultRegistry.mapping, text: CustomFn },
50
+ };
51
+ const d = { key: "title", type: "text" } as FieldDescriptor;
52
+ expect(resolveFieldComponent(d, registry)).toBe(CustomFn);
53
+ });
54
+ });
55
+
56
+ describe("resolveFieldAttrs", () => {
57
+ it("applies All attrs to every field", () => {
58
+ const d = { key: "x", type: "text" } as FieldDescriptor;
59
+ const attrs = resolveFieldAttrs(d, defaultRegistry);
60
+ expect(attrs.class).toBe("w-full");
61
+ });
62
+
63
+ it("merges type-specific attrs", () => {
64
+ const d = { key: "n", type: "number" } as FieldDescriptor;
65
+ const attrs = resolveFieldAttrs(d, defaultRegistry);
66
+ expect(attrs.type).toBe("number");
67
+ expect(attrs.class).toBe("w-full");
68
+ });
69
+
70
+ it("descriptor.attrs wins over registry", () => {
71
+ const d = {
72
+ key: "n",
73
+ type: "text",
74
+ attrs: { placeholder: "Hello" },
75
+ } as FieldDescriptor;
76
+ const attrs = resolveFieldAttrs(d, defaultRegistry);
77
+ expect(attrs.placeholder).toBe("Hello");
78
+ });
79
+ });
80
+
81
+ describe("resolveFieldAccessor", () => {
82
+ it("returns empty accessor for types without registry accessor", () => {
83
+ const d = { key: "title", type: "text" } as FieldDescriptor;
84
+ const acc = resolveFieldAccessor(d, defaultRegistry);
85
+ expect(acc.get).toBeUndefined();
86
+ expect(acc.set).toBeUndefined();
87
+ });
88
+
89
+ it("returns accessor for seo type", () => {
90
+ const d = { key: "seo", type: "seo" } as FieldDescriptor;
91
+ const acc = resolveFieldAccessor(d, defaultRegistry);
92
+ expect(typeof acc.get).toBe("function");
93
+ });
94
+
95
+ it("descriptor.accessor.get overrides registry", () => {
96
+ const customGet = () => "custom";
97
+ const d = {
98
+ key: "x",
99
+ type: "seo",
100
+ accessor: { get: customGet },
101
+ } as FieldDescriptor;
102
+ const acc = resolveFieldAccessor(d, defaultRegistry);
103
+ expect(acc.get).toBe(customGet);
104
+ });
105
+ });