@mapomodule/form 2.0.0-beta.10 → 2.0.0-beta.11

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 CHANGED
@@ -1,44 +1,28 @@
1
1
  # @mapomodule/form
2
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, media pickers
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
- - **Media pickers**: `media`, `media-m2m` and `enhanced-media` field types are built in; they drive the Media Manager from [`@mapomodule/uikit`](../uikit/README.md), resolved at runtime — install it to enable them
20
-
21
- ## Install
22
-
23
- `@mapomodule/form` is included automatically when you install `mapomodule` (recommended). No additional module entry is needed.
24
-
25
- ```ts
26
- // nuxt.config.ts
27
- export default defineNuxtConfig({
28
- modules: ["@nuxt/ui", "mapomodule"],
29
- mapo: {
30
- form: {
31
- // optional — global field registry defaults
32
- },
33
- },
34
- });
3
+ The form engine of Mapo. A form is an array of `FieldDescriptor` objects; the engine resolves each descriptor's `type` through a field registry to a lazily loaded component, and renders the result in `MapoDetail` (from `@mapomodule/uikit`), in `MapoForm`, or headless through `useMapoForm()`.
4
+
5
+ ## Installation
6
+
7
+ Installed by the meta-package [`mapomodule`](https://www.npmjs.com/package/mapomodule), which configures it under `mapo.form`. On its own it reads the `mapoForm` key:
8
+
9
+ ```bash
10
+ pnpm add @mapomodule/form@next
35
11
  ```
36
12
 
13
+ | Option | Default | Description |
14
+ | ----------------- | ------- | ---------------------------------------------------------------------------------------- |
15
+ | `fields.attrs` | `{}` | Default `attrs` per field type, merged into the registry defaults |
16
+ | `groups.expanded` | `true` | Whether collapsible field groups start open |
17
+ | `debounce` | `300` | Milliseconds before a field change reaches the model; `descriptor.debounce` overrides it |
18
+
19
+ Mapo v2 is published on the `next` dist-tag while it is in beta.
20
+
37
21
  ## Quick start
38
22
 
39
23
  ```vue
40
24
  <script setup lang="ts">
41
- import type { FieldDescriptor } from "@mapomodule/form";
25
+ import type { FieldDescriptor } from "mapomodule/types";
42
26
 
43
27
  interface Article {
44
28
  title: string;
@@ -61,96 +45,30 @@ const fields: FieldDescriptor<Article>[] = [
61
45
  </template>
62
46
  ```
63
47
 
64
- 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.
65
-
66
- ## Field types
67
-
68
- | Type | Component | Notes |
69
- | ---------------------------- | --------------------- | ----------------------------------------------------------- |
70
- | `text` | `UInput` | |
71
- | `textarea` | `UTextarea` | |
72
- | `number` | `UInput[type=number]` | |
73
- | `boolean` | `UCheckbox` | |
74
- | `switch` | `USwitch` | |
75
- | `color` | `UInput[type=color]` | |
76
- | `slider` | `USlider` | |
77
- | `file` | `MapoFileField` | Current-file preview, image thumbnail, remove button |
78
- | `select` | `USelectMenu` | requires `attrs.options` |
79
- | `date` / `datetime` / `time` | `MapoDateField` etc. | ISO string in model; `tz: 'naive' \| 'utc'` for `datetime` |
80
- | `fks` | `MapoFksField` | FK autocomplete; `attrs.endpoint` required |
81
- | `m2m` | `MapoFksField` | Multi-select autocomplete, removable chips |
82
- | `editor` | `MapoWygEditor` | Tiptap v2; sanitized output, safe `Link` validator |
83
- | `seo` | `MapoSeoPreview` | Title / description with live SERP preview |
84
- | `map` | `MapoMapField` | Leaflet, `{ lat, lng }` in model, SSR-safe via `ClientOnly` |
85
- | `repeater` | `MapoRepeater` | Nested fields, drag-and-drop, undo stack, templates |
86
-
87
- ## JSON Schema → Form
88
-
89
- ```ts
90
- const schema = await $fetch("/api/schema/");
91
- const fields = useFormFromSchema(schema, {
92
- exclude: ["id", "created_at"],
93
- overrides: {
94
- body: { type: "editor", translatable: true },
95
- },
96
- });
97
- ```
48
+ The built-in types cover text and numbers, choices, dates and times, files, relations (`fks`, `m2m`), rich text, SEO preview, maps, repeaters and media pickers. The media pickers drive the Media Manager of `@mapomodule/uikit` and show a notice when it is not installed. A `type` the registry does not know renders `MapoUnknownField` instead of breaking the form.
98
49
 
99
- ## Headless
50
+ ## What you get
100
51
 
101
- ```ts
102
- const form = useMapoForm({ model, fields, registry: $mapoFormRegistry });
52
+ Components and composables are auto-imported.
103
53
 
104
- // form.isDirty, form.isLoading, form.getPatch(), form.submit(handler)
105
- ```
106
-
107
- 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).
108
-
109
- ## Draft persistence (headless)
54
+ | API | Purpose |
55
+ | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
56
+ | `MapoForm` | Renders a `FieldDescriptor[]` on your model, with tabs, collapsible groups, a responsive grid and slots |
57
+ | `useMapoForm(options)` | The same engine headless: dirty state, client validation, `getPatch()`, `submit()`, drafts. Pass `registry` explicitly |
58
+ | `defineFormField(type, component, opts?)` | Registers a custom field type from a plugin: a component or a lazy `import()`, with optional default `attrs` and accessor. Re-registering a type warns unless you pass `{ override: true }` |
59
+ | `useFormFromSchema(schema, opts?)` | Builds descriptors from a JSON Schema, such as a DRF-spectacular or Pydantic schema |
60
+ | `when`, `whenAny`, `whenNot`, `matchesField`, `isOneOf`, … | Build `visible` conditions declaratively |
61
+ | `useFormDraft`, `useFocusMode`, `useCurrentLang` | Draft autosave, focus mode, the language being edited |
62
+ | `$mapoFormRegistry` | The global registry: type → component, default `attrs`, accessor |
110
63
 
111
- ```ts
112
- const form = useMapoForm({
113
- model,
114
- fields,
115
- registry: $mapoFormRegistry,
116
- draftKey: `article:${route.params.id}`,
117
- });
118
-
119
- // After fetch:
120
- form.checkDraft(); // populates form.draftBanner if a draft is found
121
- ```
122
-
123
- `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.
124
-
125
- ## Custom fields
126
-
127
- The recommended way to register a custom field is the typed `defineFormField()` helper, called from a Nuxt plugin:
128
-
129
- ```ts
130
- // app/plugins/my-fields.ts
131
- export default defineNuxtPlugin(() => {
132
- defineFormField("rich-text", () => import("~/components/RichText.vue"), {
133
- attrs: { rows: 6 },
134
- });
135
- });
136
- ```
137
-
138
- 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.
139
-
140
- Any component with `modelValue` + `descriptor` + `errors` + `readonly` props works as a field.
64
+ A custom field is any component that accepts the `modelValue`, `descriptor`, `errors`, `readonly` and `disabled` props and emits `update:modelValue`.
141
65
 
142
66
  ## Documentation
143
67
 
144
- Full docs: [Form Engine — Quickstart](../../../docs/uikit/form/index.md)
145
-
146
- - [All field types](../../../docs/uikit/form/add-fields.md)
147
- - [Validation](../../../docs/uikit/form/validation.md)
148
- - [i18n / multilingual fields](../../../docs/uikit/form/i18n.md)
149
- - [`useMapoForm()` headless](../../../docs/uikit/form/composable.md)
150
- - [Custom fields & registry](../../../docs/uikit/form/custom-fields.md)
151
- - [Slot system](../../../docs/uikit/form/slots.md)
152
- - [JSON Schema bridge](../../../docs/uikit/form/schema-to-form.md)
153
-
154
- ## License
155
-
156
- MIT
68
+ - [Form Engine](https://lotrekagency.github.io/mapo-new/uikit/form/): your first form, tabs and groups, how the engine works
69
+ - [All field types](https://lotrekagency.github.io/mapo-new/uikit/form/add-fields)
70
+ - [Field registry](https://lotrekagency.github.io/mapo-new/uikit/form/registry) and [Custom fields](https://lotrekagency.github.io/mapo-new/uikit/form/custom-fields)
71
+ - [Validation](https://lotrekagency.github.io/mapo-new/uikit/form/validation), [Multilingual fields](https://lotrekagency.github.io/mapo-new/uikit/form/i18n), [Draft autosave](https://lotrekagency.github.io/mapo-new/uikit/form/draft), [Progressive Disclosure](https://lotrekagency.github.io/mapo-new/uikit/form/progressive-disclosure)
72
+ - [`useMapoForm()` reference](https://lotrekagency.github.io/mapo-new/uikit/form/composable)
73
+ - [From JSON Schema to form](https://lotrekagency.github.io/mapo-new/uikit/form/schema-to-form)
74
+ - [Form Engine — Component API Reference](https://lotrekagency.github.io/mapo-new/uikit/form/api)
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=tabVisibility.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tabVisibility.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/tabVisibility.test.ts"],"names":[],"mappings":""}
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mapomodule/form",
3
3
  "configKey": "mapoForm",
4
- "version": "2.0.0-beta.10",
4
+ "version": "2.0.0-beta.11",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
@@ -1,6 +1,7 @@
1
1
  <script setup>
2
2
  import { computed, useSlots, watchEffect, inject, onUnmounted } from "vue";
3
3
  import { useNuxtApp, useRuntimeConfig } from "#app";
4
+ import { useI18n } from "vue-i18n";
4
5
  import { useMapoForm, injectMapoForm } from "../composables/useMapoForm.js";
5
6
  import { provideCurrentLang } from "../composables/useCurrentLang.js";
6
7
  import MapoFormGroup from "./MapoFormGroup.vue";
@@ -73,6 +74,8 @@ const fieldSlotNames = computed(
73
74
  )
74
75
  );
75
76
  provideCurrentLang(currentLangRef);
77
+ const { t } = useI18n();
78
+ const DEFAULT_TAB = "__default__";
76
79
  function getOrCreateTab(map, name) {
77
80
  if (!map.has(name)) {
78
81
  map.set(name, { name, groups: /* @__PURE__ */ new Map(), children: /* @__PURE__ */ new Map() });
@@ -83,7 +86,7 @@ const grouped = computed(() => {
83
86
  const tabs = /* @__PURE__ */ new Map();
84
87
  for (const field of props.fields) {
85
88
  const rawTab = field.tab;
86
- const tabPath = !rawTab ? ["__default__"] : Array.isArray(rawTab) ? rawTab : rawTab.split("/");
89
+ const tabPath = !rawTab ? [DEFAULT_TAB] : Array.isArray(rawTab) ? rawTab : rawTab.split("/");
87
90
  const groupName = field.group ?? "__flat__";
88
91
  let currentMap = tabs;
89
92
  for (let i = 0; i < tabPath.length - 1; i++) {
@@ -104,14 +107,20 @@ const grouped = computed(() => {
104
107
  grp.fields.push(field);
105
108
  }
106
109
  }
110
+ const defaultTab = tabs.get(DEFAULT_TAB);
111
+ if (defaultTab) defaultTab.label = t("mapo.form.defaultTab");
107
112
  return tabs;
108
113
  });
109
114
  const hasTabs = computed(() => {
110
115
  const keys = [...grouped.value.keys()];
111
- return keys.length > 1 || keys.length === 1 && keys[0] !== "__default__";
116
+ const hasFields = keys.length > 0;
117
+ if (!hasFields) return false;
118
+ const hasSeveralTabs = keys.length > 1;
119
+ const hasNamedTab = keys[0] !== DEFAULT_TAB;
120
+ return hasSeveralTabs || hasNamedTab;
112
121
  });
113
122
  const defaultGroups = computed(
114
- () => grouped.value.get("__default__")?.groups ?? /* @__PURE__ */ new Map()
123
+ () => grouped.value.get(DEFAULT_TAB)?.groups ?? /* @__PURE__ */ new Map()
115
124
  );
116
125
  const tabList = computed(() => [...grouped.value.values()]);
117
126
  </script>
@@ -1 +1 @@
1
- {"version":3,"file":"MapoForm.vue.d.ts","sourceRoot":"","sources":["../../../src/runtime/components/MapoForm.vue"],"names":[],"mappings":"AA2PA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,KAAK,CAAC;AAE/B,OAAO,KAAK,EACV,kBAAkB,EAClB,aAAa,EAEd,MAAM,mBAAmB,CAAC;AAQ3B,QAAA,MAAM,YAAY,GAAK,CAAC,SAAS,MAAM,EACtC,aAAa,WAAW,CAAC,OAAO,CAAC,OAAO,WAAW,CAAC,CAAC,CAAC,OAAO,CAAC,EAC9D,YAAY,mBAAmB,CAAC,IAAI,CAAC,WAAW,CAAC,OAAO,CAAC,OAAO,WAAW,CAAC,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,OAAO,CAAC,CAAC,EAC3G,gBAAgB,WAAW,CAAC,OAAO,CAAC,OAAO,WAAW,CAAC,CAAC,CAAC,QAAQ,CAAC,EAClE;WAmUO,OAAO,KAAK,EAAE,WAAW,GAAG,mBAAmB,CAAC;oBAjUxC,CAAC;gBACL,kBAAkB,CAAC,CAAC,CAAC,EAAE;iBACtB,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC;oBACrB,MAAM,EAAE;sBACN,MAAM;mBACT,OAAO;oBACN,OAAO;QACnB;;;;WAIG;mBACQ,aAAa;;;KAqT+D,CAAC,GAAG,CAAC,OAAO,UAAU,SAAS;QAAE,oBAAoB,EAAE,MAAM,CAAC,CAAA;KAAE,GAAG,CAAC,GAAG,EAAE,CAAC;YAC5J,CAAC,OAAO,EAAE,OAAO,KAAK,EAAE,gBAAgB;;;;;;;;;;MAAsB,KAAK,IAAI;WACxE,GAAG;kCAnR+B,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,OAAO;;EAuRrE,KACQ,OAAO,KAAK,EAAE,KAAK,GAAG;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC,OAAO,WAAW,CAAC,CAAA;CAAI,CAAC;wBACvD,OAAO,YAAY;AAAxC,wBAAyC;AACzC,KAAK,mBAAmB,CAAC,CAAC,IAAI,CAAC,CAAC,SAAS,GAAG,GAAG;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;CAAG,GAAG;KAAG,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;CAAG,CAAC,GAAG,EAAE,CAAC"}
1
+ {"version":3,"file":"MapoForm.vue.d.ts","sourceRoot":"","sources":["../../../src/runtime/components/MapoForm.vue"],"names":[],"mappings":"AAwQA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,KAAK,CAAC;AAG/B,OAAO,KAAK,EACV,kBAAkB,EAClB,aAAa,EAEd,MAAM,mBAAmB,CAAC;AAQ3B,QAAA,MAAM,YAAY,GAAK,CAAC,SAAS,MAAM,EACtC,aAAa,WAAW,CAAC,OAAO,CAAC,OAAO,WAAW,CAAC,CAAC,CAAC,OAAO,CAAC,EAC9D,YAAY,mBAAmB,CAAC,IAAI,CAAC,WAAW,CAAC,OAAO,CAAC,OAAO,WAAW,CAAC,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,OAAO,CAAC,CAAC,EAC3G,gBAAgB,WAAW,CAAC,OAAO,CAAC,OAAO,WAAW,CAAC,CAAC,CAAC,QAAQ,CAAC,EAClE;WA+UO,OAAO,KAAK,EAAE,WAAW,GAAG,mBAAmB,CAAC;oBA7UxC,CAAC;gBACL,kBAAkB,CAAC,CAAC,CAAC,EAAE;iBACtB,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC;oBACrB,MAAM,EAAE;sBACN,MAAM;mBACT,OAAO;oBACN,OAAO;QACnB;;;;WAIG;mBACQ,aAAa;;;KAiU+D,CAAC,GAAG,CAAC,OAAO,UAAU,SAAS;QAAE,oBAAoB,EAAE,MAAM,CAAC,CAAA;KAAE,GAAG,CAAC,GAAG,EAAE,CAAC;YAC5J,CAAC,OAAO,EAAE,OAAO,KAAK,EAAE,gBAAgB;;;;;;;;;;MAAsB,KAAK,IAAI;WACxE,GAAG;kCA/R+B,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,OAAO;;EAmSrE,KACQ,OAAO,KAAK,EAAE,KAAK,GAAG;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC,OAAO,WAAW,CAAC,CAAA;CAAI,CAAC;wBACvD,OAAO,YAAY;AAAxC,wBAAyC;AACzC,KAAK,mBAAmB,CAAC,CAAC,IAAI,CAAC,CAAC,SAAS,GAAG,GAAG;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;CAAG,GAAG;KAAG,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;CAAG,CAAC,GAAG,EAAE,CAAC"}
@@ -2,14 +2,27 @@
2
2
  import { ref, computed } from "vue";
3
3
  import { useI18n } from "vue-i18n";
4
4
  import { injectMapoForm } from "../composables/useMapoForm.js";
5
+ import { pickActiveTab, tabHasShownFields } from "../utils/tabVisibility.js";
5
6
  import MapoFormGroup from "./MapoFormGroup.vue";
6
7
  import MapoFormFlatSection from "./MapoFormFlatSection.vue";
7
8
  const { t } = useI18n();
8
9
  const props = defineProps({
9
10
  tabs: { type: Array, required: true }
10
11
  });
11
- defineSlots();
12
- const activeTab = ref(props.tabs[0]?.name ?? "");
12
+ const slots = defineSlots();
13
+ const form = injectMapoForm();
14
+ function tabHasContent(tab) {
15
+ const hasPanelSlot = Boolean(slots[`tab.${tab.name}`]);
16
+ const hasBeforeSlot = Boolean(slots[`tab.${tab.name}.before`]);
17
+ const hasAfterSlot = Boolean(slots[`tab.${tab.name}.after`]);
18
+ if (hasPanelSlot || hasBeforeSlot || hasAfterSlot) return true;
19
+ return tabHasShownFields(tab, form.model.value);
20
+ }
21
+ const shownTabs = computed(() => props.tabs.filter(tabHasContent));
22
+ const pickedTab = ref(null);
23
+ const activeTab = computed(
24
+ () => pickActiveTab(shownTabs.value, pickedTab.value)
25
+ );
13
26
  function tabHasErrors(tab, errorKeys) {
14
27
  const directError = [...tab.groups.values()].some(
15
28
  (g) => g.fields.some((f) => errorKeys.has(f.key))
@@ -19,7 +32,6 @@ function tabHasErrors(tab, errorKeys) {
19
32
  (child) => tabHasErrors(child, errorKeys)
20
33
  );
21
34
  }
22
- const form = injectMapoForm();
23
35
  const tabsWithErrors = computed(() => {
24
36
  const errorKeys = new Set(Object.keys(form.errors.value));
25
37
  return new Set(
@@ -33,14 +45,14 @@ const tabsWithErrors = computed(() => {
33
45
  <!-- Tab bar -->
34
46
  <div class="flex gap-1 border-b border-gray-200 pb-px">
35
47
  <button
36
- v-for="tab in tabs"
48
+ v-for="tab in shownTabs"
37
49
  :key="tab.name"
38
50
  type="button"
39
51
  class="relative flex items-center gap-1.5 rounded-t px-4 py-2 text-sm font-medium transition-colors"
40
52
  :class="[
41
53
  activeTab === tab.name ? 'border-b-2 border-primary-500 text-primary-600' : 'text-gray-500 hover:bg-gray-50 hover:text-gray-700'
42
54
  ]"
43
- @click="activeTab = tab.name"
55
+ @click="pickedTab = tab.name"
44
56
  >
45
57
  <slot :name="`tab.${tab.name}.label`" :tab="tab">
46
58
  {{ tab.label ?? tab.name }}
@@ -1 +1 @@
1
- {"version":3,"file":"MapoFormTabs.vue.d.ts","sourceRoot":"","sources":["../../../src/runtime/components/MapoFormTabs.vue"],"names":[],"mappings":"AA4HA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAOnD,KAAK,WAAW,GAAG;IAAE,IAAI,EAAE,QAAQ,EAAE,CAAA;CAAE,CAAC;AAGxC,KAAK,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAsO3C,QAAA,MAAM,UAAU,kSAEd,CAAC;AACH,QAAA,MAAM,YAAY,EAAS,eAAe,CAAC,OAAO,UAAU,EAAE,WAAW,CAAC,CAAC;wBACtD,OAAO,YAAY;AAAxC,wBAAyC;AACzC,KAAK,eAAe,CAAC,CAAC,EAAE,CAAC,IAAI,CAAC,GAAG;IAChC,QAAO;QACN,MAAM,EAAE,CAAC,CAAC;KACV,CAAA;CACD,CAAC"}
1
+ {"version":3,"file":"MapoFormTabs.vue.d.ts","sourceRoot":"","sources":["../../../src/runtime/components/MapoFormTabs.vue"],"names":[],"mappings":"AAgJA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAQnD,KAAK,WAAW,GAAG;IAAE,IAAI,EAAE,QAAQ,EAAE,CAAA;CAAE,CAAC;AAGxC,KAAK,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AA2P3C,QAAA,MAAM,UAAU,kSAEd,CAAC;AACH,QAAA,MAAM,YAAY,EAAS,eAAe,CAAC,OAAO,UAAU,EAAE,WAAW,CAAC,CAAC;wBACtD,OAAO,YAAY;AAAxC,wBAAyC;AACzC,KAAK,eAAe,CAAC,CAAC,EAAE,CAAC,IAAI,CAAC,GAAG;IAChC,QAAO;QACN,MAAM,EAAE,CAAC,CAAC;KACV,CAAA;CACD,CAAC"}
@@ -35,10 +35,12 @@ function clear() {
35
35
  <MapoMediaUnavailable v-if="!available" />
36
36
 
37
37
  <template v-else>
38
- <!-- Selected media preview -->
38
+ <!-- Selected media preview. It and the trigger below paint their own
39
+ surface, like an input: `soft` buttons and transparent cards take
40
+ the page background and vanish on a tinted page. -->
39
41
  <div
40
42
  v-if="current"
41
- class="flex items-center gap-3 rounded-lg border border-default p-2"
43
+ class="flex items-center gap-3 rounded-lg border border-default bg-default p-2"
42
44
  >
43
45
  <MapoMediaPreview :media="current" size="sm" />
44
46
  <div class="flex-1 min-w-0">
@@ -64,7 +66,7 @@ function clear() {
64
66
  v-if="!readonly && !disabled"
65
67
  :icon="current ? 'i-lucide-refresh-cw' : 'i-lucide-image-plus'"
66
68
  size="sm"
67
- variant="soft"
69
+ variant="outline"
68
70
  color="neutral"
69
71
  :block="!current"
70
72
  @click="dialogOpen = true"
@@ -1 +1 @@
1
- {"version":3,"file":"MapoMediaField.vue.d.ts","sourceRoot":"","sources":["../../../../src/runtime/components/fields/MapoMediaField.vue"],"names":[],"mappings":"AA6GA,OAAO,KAAK,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAGvE,KAAK,WAAW,GAAG;IACjB,UAAU,EAAE,SAAS,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IAClD,UAAU,EAAE,eAAe,CAAC;IAC5B,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAgOF,QAAA,MAAM,YAAY;;;;kFAGhB,CAAC;wBACkB,OAAO,YAAY;AAAxC,wBAAyC"}
1
+ {"version":3,"file":"MapoMediaField.vue.d.ts","sourceRoot":"","sources":["../../../../src/runtime/components/fields/MapoMediaField.vue"],"names":[],"mappings":"AA+GA,OAAO,KAAK,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAGvE,KAAK,WAAW,GAAG;IACjB,UAAU,EAAE,SAAS,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IAClD,UAAU,EAAE,eAAe,CAAC;IAC5B,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAiOF,QAAA,MAAM,YAAY;;;;kFAGhB,CAAC;wBACkB,OAAO,YAAY;AAAxC,wBAAyC"}
@@ -77,12 +77,13 @@ function onDragEnd() {
77
77
  </div>
78
78
  </div>
79
79
 
80
- <!-- Add button -->
80
+ <!-- Add button: outlined like MapoMediaField's trigger, so it keeps its
81
+ own surface on a tinted page. -->
81
82
  <UButton
82
83
  v-if="!readonly && !disabled"
83
84
  icon="i-lucide-plus"
84
85
  size="sm"
85
- variant="soft"
86
+ variant="outline"
86
87
  color="neutral"
87
88
  @click="dialogOpen = true"
88
89
  >
@@ -1 +1 @@
1
- {"version":3,"file":"MapoMediaM2mField.vue.d.ts","sourceRoot":"","sources":["../../../../src/runtime/components/fields/MapoMediaM2mField.vue"],"names":[],"mappings":"AAuIA,OAAO,KAAK,EAAE,kBAAkB,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAG1E,KAAK,WAAW,GAAG;IACjB,UAAU,EAAE,SAAS,EAAE,GAAG,MAAM,EAAE,GAAG,IAAI,GAAG,SAAS,CAAC;IACtD,UAAU,EAAE,kBAAkB,CAAC;IAC/B,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AA+QF,QAAA,MAAM,YAAY;;;;kFAGhB,CAAC;wBACkB,OAAO,YAAY;AAAxC,wBAAyC"}
1
+ {"version":3,"file":"MapoMediaM2mField.vue.d.ts","sourceRoot":"","sources":["../../../../src/runtime/components/fields/MapoMediaM2mField.vue"],"names":[],"mappings":"AAwIA,OAAO,KAAK,EAAE,kBAAkB,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAG1E,KAAK,WAAW,GAAG;IACjB,UAAU,EAAE,SAAS,EAAE,GAAG,MAAM,EAAE,GAAG,IAAI,GAAG,SAAS,CAAC;IACtD,UAAU,EAAE,kBAAkB,CAAC;IAC/B,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AA+QF,QAAA,MAAM,YAAY;;;;kFAGhB,CAAC;wBACkB,OAAO,YAAY;AAAxC,wBAAyC"}
@@ -98,10 +98,13 @@ function onImageConfirm(selection) {
98
98
  </script>
99
99
 
100
100
  <template>
101
+ <!-- An input surface of its own, like the Nuxt UI inputs next to it
102
+ (`bg-default`, `accented` border): without it the text area takes the
103
+ page background and disappears on a tinted page. -->
101
104
  <div
102
- class="mapo-wyg-editor rounded border"
105
+ class="mapo-wyg-editor rounded-md border bg-default"
103
106
  :class="[
104
- errors?.length ? 'border-red-300' : 'border-gray-200',
107
+ errors?.length ? 'border-red-300' : 'border-accented',
105
108
  readonly || disabled ? 'opacity-60' : ''
106
109
  ]"
107
110
  >
@@ -238,15 +241,18 @@ function onImageConfirm(selection) {
238
241
  >
239
242
  {{ err }}
240
243
  </p>
241
- </div>
242
244
 
243
- <!-- Image picker dialog — only rendered when MapoMediaManagerDialog is globally available -->
244
- <component
245
- :is="MediaManagerDialog"
246
- v-if="hasMediaManager"
247
- v-model="imagePickerOpen"
248
- selection-mode="single"
249
- mime="image/*"
250
- @confirm="onImageConfirm"
251
- />
245
+ <!-- Image picker dialog — only rendered when MapoMediaManagerDialog is
246
+ globally available. Inside the root on purpose: a second root would
247
+ drop what MapoFormField passes down (`flex-1`, `id`, `style`), and the
248
+ editor would shrink to its toolbar. As a modal it renders in <body>. -->
249
+ <component
250
+ :is="MediaManagerDialog"
251
+ v-if="hasMediaManager"
252
+ v-model="imagePickerOpen"
253
+ selection-mode="single"
254
+ mime="image/*"
255
+ @confirm="onImageConfirm"
256
+ />
257
+ </div>
252
258
  </template>
@@ -1 +1 @@
1
- {"version":3,"file":"MapoWygEditor.vue.d.ts","sourceRoot":"","sources":["../../../../src/runtime/components/fields/MapoWygEditor.vue"],"names":[],"mappings":"AAqTA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAE7D,KAAK,WAAW,GAAG;IACjB,UAAU,EAAE,OAAO,CAAC;IACpB,UAAU,EAAE,gBAAgB,CAAC;IAC7B,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AA0pBF,QAAA,MAAM,YAAY;;;;kFAGhB,CAAC;wBACkB,OAAO,YAAY;AAAxC,wBAAyC"}
1
+ {"version":3,"file":"MapoWygEditor.vue.d.ts","sourceRoot":"","sources":["../../../../src/runtime/components/fields/MapoWygEditor.vue"],"names":[],"mappings":"AA2TA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAE7D,KAAK,WAAW,GAAG;IACjB,UAAU,EAAE,OAAO,CAAC;IACpB,UAAU,EAAE,gBAAgB,CAAC;IAC7B,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AA6pBF,QAAA,MAAM,YAAY;;;;kFAGhB,CAAC;wBACkB,OAAO,YAAY;AAAxC,wBAAyC"}
@@ -0,0 +1,18 @@
1
+ import type { AnyFieldDescriptor } from "../types/index.js";
2
+ import type { TabEntry } from "../types/layout.js";
3
+ /**
4
+ * Whether a field shows for this model: neither its `visible` predicate, which
5
+ * removes it, nor its `show` predicate, which hides it, keeps it out.
6
+ */
7
+ export declare function isFieldShown(field: AnyFieldDescriptor, model: Record<string, unknown>): boolean;
8
+ /**
9
+ * Whether a tab has a field to show for this model, in one of its groups (sub-tabs
10
+ * included) or in one of its child tabs.
11
+ */
12
+ export declare function tabHasShownFields(tab: TabEntry, model: Record<string, unknown>): boolean;
13
+ /**
14
+ * The tab to open among the ones shown: the tab the user picked, while it is
15
+ * shown; otherwise the first one. Empty when no tab is shown.
16
+ */
17
+ export declare function pickActiveTab(shownTabs: TabEntry[], picked: string | null): string;
18
+ //# sourceMappingURL=tabVisibility.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tabVisibility.d.ts","sourceRoot":"","sources":["../../../src/runtime/utils/tabVisibility.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAC5D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAEnD;;;GAGG;AACH,wBAAgB,YAAY,CAC1B,KAAK,EAAE,kBAAkB,EACzB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,OAAO,CAIT;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,QAAQ,EACb,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC7B,OAAO,CAQT;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,SAAS,EAAE,QAAQ,EAAE,EACrB,MAAM,EAAE,MAAM,GAAG,IAAI,GACpB,MAAM,CAGR"}
@@ -0,0 +1,18 @@
1
+ export function isFieldShown(field, model) {
2
+ const removed = field.visible ? !field.visible({ model }) : false;
3
+ const hidden = field.show ? !field.show({ model }) : false;
4
+ return !removed && !hidden;
5
+ }
6
+ export function tabHasShownFields(tab, model) {
7
+ for (const group of tab.groups.values()) {
8
+ const fields = [...group.fields, ...[...group.subtabs.values()].flat()];
9
+ if (fields.some((field) => isFieldShown(field, model))) return true;
10
+ }
11
+ return [...tab.children.values()].some(
12
+ (child) => tabHasShownFields(child, model)
13
+ );
14
+ }
15
+ export function pickActiveTab(shownTabs, picked) {
16
+ const pickedTab = shownTabs.find((tab) => tab.name === picked);
17
+ return (pickedTab ?? shownTabs[0])?.name ?? "";
18
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mapomodule/form",
3
- "version": "2.0.0-beta.10",
3
+ "version": "2.0.0-beta.11",
4
4
  "description": "Mapo declarative typed form engine and field components",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -47,9 +47,9 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@internationalized/date": "^3.7.0",
50
- "@mapomodule/i18n": "2.0.0-beta.2",
50
+ "@mapomodule/i18n": "2.0.0-beta.3",
51
51
  "@mapomodule/store": "2.0.0-beta.6",
52
- "@mapomodule/utils": "2.0.0-beta.5",
52
+ "@mapomodule/utils": "2.0.0-beta.6",
53
53
  "@tiptap/extension-image": "^2.11.5",
54
54
  "@tiptap/extension-link": "^2.11.5",
55
55
  "@tiptap/extension-underline": "^2.11.5",
@@ -57,7 +57,7 @@
57
57
  "@tiptap/vue-3": "^2.11.5",
58
58
  "leaflet": "^1.9.4",
59
59
  "vue-draggable-plus": "^0.6.0",
60
- "vue-i18n": "^11.4.0"
60
+ "vue-i18n": "^11.4.8"
61
61
  },
62
62
  "optionalDependencies": {
63
63
  "@types/leaflet": "^1.9.18"
@@ -0,0 +1,102 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { AnyFieldDescriptor } from "../runtime/types/index.js";
3
+ import type { TabEntry } from "../runtime/types/layout.js";
4
+ import {
5
+ isFieldShown,
6
+ pickActiveTab,
7
+ tabHasShownFields,
8
+ } from "../runtime/utils/tabVisibility.js";
9
+
10
+ type Predicates = Pick<AnyFieldDescriptor, "visible" | "show">;
11
+
12
+ const field = (key: string, predicates: Predicates = {}) =>
13
+ ({ key, type: "text", ...predicates }) as AnyFieldDescriptor;
14
+
15
+ /** A tab holding its fields in one flat group, as MapoForm builds it. */
16
+ const tab = (
17
+ name: string,
18
+ fields: AnyFieldDescriptor[],
19
+ children: TabEntry[] = [],
20
+ ): TabEntry => ({
21
+ name,
22
+ groups: new Map([["__flat__", { fields, subtabs: new Map() }]]),
23
+ children: new Map(children.map((child) => [child.name, child])),
24
+ });
25
+
26
+ // Like a page whose content fields depend on the template picked in the sidebar.
27
+ const hasTemplate = ({ model }: { model: Record<string, unknown> }) =>
28
+ Boolean(model.template);
29
+
30
+ describe("isFieldShown", () => {
31
+ it("shows a field without predicates", () => {
32
+ expect(isFieldShown(field("title"), {})).toBe(true);
33
+ });
34
+
35
+ it("hides a field that `visible` removes", () => {
36
+ const body = field("body", { visible: hasTemplate });
37
+ expect(isFieldShown(body, { template: "" })).toBe(false);
38
+ expect(isFieldShown(body, { template: "Home" })).toBe(true);
39
+ });
40
+
41
+ it("hides a field that `show` hides", () => {
42
+ const body = field("body", { show: hasTemplate });
43
+ expect(isFieldShown(body, { template: "" })).toBe(false);
44
+ });
45
+ });
46
+
47
+ describe("tabHasShownFields", () => {
48
+ it("is false while every field is hidden and true once one shows", () => {
49
+ const general = tab("__default__", [
50
+ field("body", { visible: hasTemplate }),
51
+ field("intro", { visible: hasTemplate }),
52
+ ]);
53
+ expect(tabHasShownFields(general, { template: "" })).toBe(false);
54
+ expect(tabHasShownFields(general, { template: "Home" })).toBe(true);
55
+ });
56
+
57
+ it("counts the fields of a sub-tab", () => {
58
+ const seo: TabEntry = {
59
+ name: "SEO",
60
+ groups: new Map([
61
+ [
62
+ "meta",
63
+ { fields: [], subtabs: new Map([["basic", [field("meta_title")]]]) },
64
+ ],
65
+ ]),
66
+ children: new Map(),
67
+ };
68
+ expect(tabHasShownFields(seo, {})).toBe(true);
69
+ });
70
+
71
+ it("counts the fields of a child tab", () => {
72
+ const settings = tab(
73
+ "Settings",
74
+ [field("legacy", { visible: () => false })],
75
+ [tab("SEO", [field("meta_title")])],
76
+ );
77
+ expect(tabHasShownFields(settings, {})).toBe(true);
78
+ });
79
+ });
80
+
81
+ describe("pickActiveTab", () => {
82
+ const general = tab("__default__", []);
83
+ const seo = tab("SEO", []);
84
+
85
+ it("opens the first shown tab until the user picks one", () => {
86
+ expect(pickActiveTab([general, seo], null)).toBe("__default__");
87
+ // The first tab is hidden: the next one opens, not an empty panel.
88
+ expect(pickActiveTab([seo], null)).toBe("SEO");
89
+ });
90
+
91
+ it("keeps the picked tab while it is shown", () => {
92
+ expect(pickActiveTab([general, seo], "SEO")).toBe("SEO");
93
+ });
94
+
95
+ it("falls back to the first shown tab while the picked one is hidden", () => {
96
+ expect(pickActiveTab([seo], "__default__")).toBe("SEO");
97
+ });
98
+
99
+ it("is empty when no tab is shown", () => {
100
+ expect(pickActiveTab([], null)).toBe("");
101
+ });
102
+ });
@@ -2,6 +2,7 @@
2
2
  import { computed, useSlots, watchEffect, inject, onUnmounted } from "vue";
3
3
  import type { Ref } from "vue";
4
4
  import { useNuxtApp, useRuntimeConfig } from "#app";
5
+ import { useI18n } from "vue-i18n";
5
6
  import type {
6
7
  AnyFieldDescriptor,
7
8
  FieldRegistry,
@@ -135,6 +136,12 @@ provideCurrentLang(currentLangRef);
135
136
 
136
137
  // ─── Group fields by tab/group ───────────────────────────────────────────────
137
138
 
139
+ const { t } = useI18n();
140
+
141
+ // Fields without a `tab` go here. It becomes a tab of its own only when other
142
+ // fields have one, and then it shows as "General" (`mapo.form.defaultTab`).
143
+ const DEFAULT_TAB = "__default__";
144
+
138
145
  function getOrCreateTab(map: Map<string, TabEntry>, name: string): TabEntry {
139
146
  if (!map.has(name)) {
140
147
  map.set(name, { name, groups: new Map(), children: new Map() });
@@ -151,7 +158,7 @@ const grouped = computed<Map<string, TabEntry>>(() => {
151
158
  // Normalise: 'a/b', ['a','b'], or 'a' all become a string[]
152
159
  const rawTab = field.tab;
153
160
  const tabPath: string[] = !rawTab
154
- ? ["__default__"]
161
+ ? [DEFAULT_TAB]
155
162
  : Array.isArray(rawTab)
156
163
  ? rawTab
157
164
  : rawTab.split("/");
@@ -179,17 +186,23 @@ const grouped = computed<Map<string, TabEntry>>(() => {
179
186
  }
180
187
  }
181
188
 
189
+ const defaultTab = tabs.get(DEFAULT_TAB);
190
+ if (defaultTab) defaultTab.label = t("mapo.form.defaultTab");
191
+
182
192
  return tabs;
183
193
  });
184
194
 
185
195
  const hasTabs = computed(() => {
186
196
  const keys = [...grouped.value.keys()];
187
- return keys.length > 1 || (keys.length === 1 && keys[0] !== "__default__");
197
+ const hasFields = keys.length > 0;
198
+ if (!hasFields) return false;
199
+ const hasSeveralTabs = keys.length > 1;
200
+ const hasNamedTab = keys[0] !== DEFAULT_TAB;
201
+ return hasSeveralTabs || hasNamedTab;
188
202
  });
189
203
 
190
204
  const defaultGroups = computed(
191
- () =>
192
- grouped.value.get("__default__")?.groups ?? new Map<string, GroupEntry>(),
205
+ () => grouped.value.get(DEFAULT_TAB)?.groups ?? new Map<string, GroupEntry>(),
193
206
  );
194
207
 
195
208
  const tabList = computed<TabEntry[]>(() => [...grouped.value.values()]);
@@ -3,6 +3,7 @@ import { ref, computed } from "vue";
3
3
  import { useI18n } from "vue-i18n";
4
4
  import type { TabEntry } from "../types/layout.js";
5
5
  import { injectMapoForm } from "../composables/useMapoForm.js";
6
+ import { pickActiveTab, tabHasShownFields } from "../utils/tabVisibility.js";
6
7
  import MapoFormGroup from "./MapoFormGroup.vue";
7
8
  import MapoFormFlatSection from "./MapoFormFlatSection.vue";
8
9
 
@@ -10,9 +11,29 @@ const { t } = useI18n();
10
11
 
11
12
  const props = defineProps<{ tabs: TabEntry[] }>();
12
13
 
13
- defineSlots<Record<string, unknown>>();
14
+ const slots = defineSlots<Record<string, unknown>>();
14
15
 
15
- const activeTab = ref(props.tabs[0]?.name ?? "");
16
+ const form = injectMapoForm<Record<string, unknown>>()!;
17
+
18
+ // A tab whose fields are all hidden by `visible` or `show` would open on an
19
+ // empty panel, so it leaves the tab bar until one of them shows. Content passed
20
+ // through the tab's own slots counts as something to show.
21
+ function tabHasContent(tab: TabEntry): boolean {
22
+ const hasPanelSlot = Boolean(slots[`tab.${tab.name}`]);
23
+ const hasBeforeSlot = Boolean(slots[`tab.${tab.name}.before`]);
24
+ const hasAfterSlot = Boolean(slots[`tab.${tab.name}.after`]);
25
+ if (hasPanelSlot || hasBeforeSlot || hasAfterSlot) return true;
26
+ return tabHasShownFields(tab, form.model.value);
27
+ }
28
+
29
+ const shownTabs = computed(() => props.tabs.filter(tabHasContent));
30
+
31
+ // The tab the user clicked; until then, or while it is hidden, the first shown
32
+ // tab is open.
33
+ const pickedTab = ref<string | null>(null);
34
+ const activeTab = computed(() =>
35
+ pickActiveTab(shownTabs.value, pickedTab.value),
36
+ );
16
37
 
17
38
  // Recursively check if a tab (or any of its descendants) contains errors.
18
39
  function tabHasErrors(tab: TabEntry, errorKeys: Set<string>): boolean {
@@ -26,7 +47,6 @@ function tabHasErrors(tab: TabEntry, errorKeys: Set<string>): boolean {
26
47
  }
27
48
 
28
49
  // Highlight tabs containing errors.
29
- const form = injectMapoForm()!;
30
50
  const tabsWithErrors = computed(() => {
31
51
  const errorKeys = new Set(Object.keys(form.errors.value));
32
52
  return new Set(
@@ -40,7 +60,7 @@ const tabsWithErrors = computed(() => {
40
60
  <!-- Tab bar -->
41
61
  <div class="flex gap-1 border-b border-gray-200 pb-px">
42
62
  <button
43
- v-for="tab in tabs"
63
+ v-for="tab in shownTabs"
44
64
  :key="tab.name"
45
65
  type="button"
46
66
  class="relative flex items-center gap-1.5 rounded-t px-4 py-2 text-sm font-medium transition-colors"
@@ -49,7 +69,7 @@ const tabsWithErrors = computed(() => {
49
69
  ? 'border-b-2 border-primary-500 text-primary-600'
50
70
  : 'text-gray-500 hover:bg-gray-50 hover:text-gray-700',
51
71
  ]"
52
- @click="activeTab = tab.name"
72
+ @click="pickedTab = tab.name"
53
73
  >
54
74
  <slot :name="`tab.${tab.name}.label`" :tab="tab">
55
75
  {{ tab.label ?? tab.name }}
@@ -48,10 +48,12 @@ function clear() {
48
48
  <MapoMediaUnavailable v-if="!available" />
49
49
 
50
50
  <template v-else>
51
- <!-- Selected media preview -->
51
+ <!-- Selected media preview. It and the trigger below paint their own
52
+ surface, like an input: `soft` buttons and transparent cards take
53
+ the page background and vanish on a tinted page. -->
52
54
  <div
53
55
  v-if="current"
54
- class="flex items-center gap-3 rounded-lg border border-default p-2"
56
+ class="flex items-center gap-3 rounded-lg border border-default bg-default p-2"
55
57
  >
56
58
  <MapoMediaPreview :media="current" size="sm" />
57
59
  <div class="flex-1 min-w-0">
@@ -77,7 +79,7 @@ function clear() {
77
79
  v-if="!readonly && !disabled"
78
80
  :icon="current ? 'i-lucide-refresh-cw' : 'i-lucide-image-plus'"
79
81
  size="sm"
80
- variant="soft"
82
+ variant="outline"
81
83
  color="neutral"
82
84
  :block="!current"
83
85
  @click="dialogOpen = true"
@@ -95,12 +95,13 @@ function onDragEnd() {
95
95
  </div>
96
96
  </div>
97
97
 
98
- <!-- Add button -->
98
+ <!-- Add button: outlined like MapoMediaField's trigger, so it keeps its
99
+ own surface on a tinted page. -->
99
100
  <UButton
100
101
  v-if="!readonly && !disabled"
101
102
  icon="i-lucide-plus"
102
103
  size="sm"
103
- variant="soft"
104
+ variant="outline"
104
105
  color="neutral"
105
106
  @click="dialogOpen = true"
106
107
  >
@@ -142,10 +142,13 @@ function onImageConfirm(
142
142
  </script>
143
143
 
144
144
  <template>
145
+ <!-- An input surface of its own, like the Nuxt UI inputs next to it
146
+ (`bg-default`, `accented` border): without it the text area takes the
147
+ page background and disappears on a tinted page. -->
145
148
  <div
146
- class="mapo-wyg-editor rounded border"
149
+ class="mapo-wyg-editor rounded-md border bg-default"
147
150
  :class="[
148
- errors?.length ? 'border-red-300' : 'border-gray-200',
151
+ errors?.length ? 'border-red-300' : 'border-accented',
149
152
  readonly || disabled ? 'opacity-60' : '',
150
153
  ]"
151
154
  >
@@ -284,15 +287,18 @@ function onImageConfirm(
284
287
  >
285
288
  {{ err }}
286
289
  </p>
287
- </div>
288
290
 
289
- <!-- Image picker dialog — only rendered when MapoMediaManagerDialog is globally available -->
290
- <component
291
- :is="MediaManagerDialog"
292
- v-if="hasMediaManager"
293
- v-model="imagePickerOpen"
294
- selection-mode="single"
295
- mime="image/*"
296
- @confirm="onImageConfirm"
297
- />
291
+ <!-- Image picker dialog — only rendered when MapoMediaManagerDialog is
292
+ globally available. Inside the root on purpose: a second root would
293
+ drop what MapoFormField passes down (`flex-1`, `id`, `style`), and the
294
+ editor would shrink to its toolbar. As a modal it renders in <body>. -->
295
+ <component
296
+ :is="MediaManagerDialog"
297
+ v-if="hasMediaManager"
298
+ v-model="imagePickerOpen"
299
+ selection-mode="single"
300
+ mime="image/*"
301
+ @confirm="onImageConfirm"
302
+ />
303
+ </div>
298
304
  </template>
@@ -0,0 +1,44 @@
1
+ import type { AnyFieldDescriptor } from "../types/index.js";
2
+ import type { TabEntry } from "../types/layout.js";
3
+
4
+ /**
5
+ * Whether a field shows for this model: neither its `visible` predicate, which
6
+ * removes it, nor its `show` predicate, which hides it, keeps it out.
7
+ */
8
+ export function isFieldShown(
9
+ field: AnyFieldDescriptor,
10
+ model: Record<string, unknown>,
11
+ ): boolean {
12
+ const removed = field.visible ? !field.visible({ model }) : false;
13
+ const hidden = field.show ? !field.show({ model }) : false;
14
+ return !removed && !hidden;
15
+ }
16
+
17
+ /**
18
+ * Whether a tab has a field to show for this model, in one of its groups (sub-tabs
19
+ * included) or in one of its child tabs.
20
+ */
21
+ export function tabHasShownFields(
22
+ tab: TabEntry,
23
+ model: Record<string, unknown>,
24
+ ): boolean {
25
+ for (const group of tab.groups.values()) {
26
+ const fields = [...group.fields, ...[...group.subtabs.values()].flat()];
27
+ if (fields.some((field) => isFieldShown(field, model))) return true;
28
+ }
29
+ return [...tab.children.values()].some((child) =>
30
+ tabHasShownFields(child, model),
31
+ );
32
+ }
33
+
34
+ /**
35
+ * The tab to open among the ones shown: the tab the user picked, while it is
36
+ * shown; otherwise the first one. Empty when no tab is shown.
37
+ */
38
+ export function pickActiveTab(
39
+ shownTabs: TabEntry[],
40
+ picked: string | null,
41
+ ): string {
42
+ const pickedTab = shownTabs.find((tab) => tab.name === picked);
43
+ return (pickedTab ?? shownTabs[0])?.name ?? "";
44
+ }