@octabits-io/nuxt-ui-kit 0.2.0 → 0.3.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.
@@ -0,0 +1,140 @@
1
+ import { computed, inject, provide, ref, toValue, watchEffect } from "vue";
2
+ import { baseLocaleOf, resolveLocale } from "@octabits-io/framework/utils";
3
+ //#region src/locale/pruneLocaleMap.ts
4
+ /**
5
+ * Drop empty-string leaves from a `LocaleMap<string>` so cleared tabs fall
6
+ * back to the default locale instead of shadowing it with `''`. Returns a new
7
+ * map; use `Object.keys(result).length` to decide between map and `null` when
8
+ * the API expects `null` for "unset".
9
+ */
10
+ function pruneLocaleMap(map) {
11
+ return Object.fromEntries(Object.entries(map ?? {}).filter(([, v]) => !!v));
12
+ }
13
+ //#endregion
14
+ //#region src/locale/index.ts
15
+ const LOCALE_FIELD_CONTEXT = Symbol("nuxt-ui-kit:locale-field-context");
16
+ /** Provide the locale-field context (call once, near the app root). */
17
+ function provideLocaleFieldContext(context) {
18
+ provide(LOCALE_FIELD_CONTEXT, context);
19
+ }
20
+ /** Resolve the locale-field context inside a component (throws when absent). */
21
+ function useLocaleFieldContext() {
22
+ const context = inject(LOCALE_FIELD_CONTEXT, null);
23
+ if (!context) throw new Error("[nuxt-ui-kit] LocaleInput/LocaleTextarea need provideLocaleFieldContext() near the app root.");
24
+ return context;
25
+ }
26
+ /**
27
+ * Tab plumbing shared by every per-locale field editor: one tab per content
28
+ * locale, an active-tab ref, and the completeness indicator. Value access
29
+ * stays with the caller — pass `hasValue` so the indicator works for any
30
+ * value type (strings, rich-text documents, …).
31
+ *
32
+ * **Register variants** (e.g. `de-formal`, whose base `de` is also supported)
33
+ * are a tone/overlay axis, not a real language: a label like "Hotel" is
34
+ * identical in both. So variant tabs are **hidden by default** and only shown
35
+ * when `registerOverride` is true (reader-addressing prose — descriptions,
36
+ * body copy). When shown, a blank variant value *inherits* its base locale
37
+ * (neutral hint, not a "missing" warning), and clearing the field should
38
+ * **delete the key** so the resolver falls through to the base
39
+ * (`de-formal → de`).
40
+ */
41
+ function useLocaleTabs(hasValue, source, registerOverride = false) {
42
+ const supportedLocales = computed(() => toValue(source.locales));
43
+ const defaultLocale = computed(() => toValue(source.defaultLocale));
44
+ /** A locale whose base language is itself also supported (e.g. `de-formal` ⊂ `de`). */
45
+ const isVariant = (loc) => baseLocaleOf(loc) !== loc && supportedLocales.value.includes(baseLocaleOf(loc));
46
+ const visibleLocales = computed(() => {
47
+ const filtered = toValue(registerOverride) ? supportedLocales.value : supportedLocales.value.filter((loc) => !isVariant(loc));
48
+ return filtered.includes(defaultLocale.value) ? filtered : [...filtered, defaultLocale.value];
49
+ });
50
+ const active = ref("");
51
+ watchEffect(() => {
52
+ if (!visibleLocales.value.includes(active.value)) active.value = visibleLocales.value.includes(defaultLocale.value) ? defaultLocale.value : visibleLocales.value[0] ?? "";
53
+ });
54
+ /**
55
+ * A register variant carries a lowercase BCP-47 *variant* subtag (e.g.
56
+ * `de-formal`) — as opposed to a region (`de-AT`) or script (`zh-Hans`).
57
+ * Formality is a tenant-wide choice, so the tab reads as the plain language
58
+ * ("DE"), not the internal `DE-FORMAL` token.
59
+ */
60
+ const REGISTER_VARIANT = /-[a-z]{4,8}$/;
61
+ const tabLabel = (loc) => (REGISTER_VARIANT.test(loc) ? baseLocaleOf(loc) : loc).toUpperCase();
62
+ const items = computed(() => visibleLocales.value.map((loc) => ({
63
+ label: tabLabel(loc),
64
+ value: loc
65
+ })));
66
+ const indicatorOf = (loc) => {
67
+ if (hasValue(loc)) return null;
68
+ if (isVariant(loc)) return { kind: "inherits" };
69
+ return loc === defaultLocale.value ? { kind: "error" } : { kind: "warning" };
70
+ };
71
+ /**
72
+ * Source locale for quick translate: the active tab when it has a value, so
73
+ * freshly edited text wins; otherwise the default locale, otherwise the
74
+ * first filled visible locale. `null` when every tab is empty. The fallback
75
+ * matters because the operator typically sits on the tab they want FILLED —
76
+ * translate must not go dead just because the active tab is the empty one.
77
+ */
78
+ const translateSource = computed(() => {
79
+ if (hasValue(active.value)) return active.value;
80
+ if (hasValue(defaultLocale.value)) return defaultLocale.value;
81
+ return visibleLocales.value.find(hasValue) ?? null;
82
+ });
83
+ return {
84
+ items,
85
+ active,
86
+ indicatorOf,
87
+ isVariant,
88
+ defaultLocale,
89
+ visibleLocales,
90
+ translateSource,
91
+ translateTargets: computed(() => visibleLocales.value.filter((loc) => loc !== translateSource.value && !hasValue(loc) && (!isVariant(loc) || loc === defaultLocale.value)))
92
+ };
93
+ }
94
+ /**
95
+ * String-valued locale field over a single `LocaleMap<string>` model — the
96
+ * composable behind `LocaleInput` / `LocaleTextarea`. See
97
+ * {@link useLocaleTabs} for the tab/indicator semantics.
98
+ */
99
+ function useLocaleField(model, source, registerOverride = false) {
100
+ const hasValue = (loc) => {
101
+ const v = model.value?.[loc];
102
+ return typeof v === "string" && v.length > 0;
103
+ };
104
+ const { items, active, indicatorOf, isVariant, defaultLocale, translateSource, translateTargets } = useLocaleTabs(hasValue, source, registerOverride);
105
+ return {
106
+ items,
107
+ active,
108
+ activeValue: computed({
109
+ get: () => model.value?.[active.value] ?? "",
110
+ set: (val) => {
111
+ const next = { ...model.value ?? {} };
112
+ if (val === "" && isVariant(active.value)) delete next[active.value];
113
+ else next[active.value] = val;
114
+ model.value = next;
115
+ }
116
+ }),
117
+ indicatorOf,
118
+ defaultLocale,
119
+ translateSource,
120
+ translateTargets
121
+ };
122
+ }
123
+ /**
124
+ * Resolve a `LocaleMap<string>` to a single display string for list / detail
125
+ * surfaces, using the **default content locale** — deliberately decoupled
126
+ * from the app's own UI language, which is unrelated chrome and would
127
+ * otherwise select content arbitrarily. Lists therefore always show the
128
+ * canonical value, consistently for every user; the per-locale values stay
129
+ * fully editable via `LocaleInput` / `LocaleTextarea`.
130
+ */
131
+ function createLocaleDisplay(source) {
132
+ /** Resolve a LocaleMap to its default-locale string, falling back when empty. */
133
+ function display(map, fallback = "") {
134
+ const defaultLocale = toValue(source.defaultLocale);
135
+ return resolveLocale(map, defaultLocale, defaultLocale) ?? fallback;
136
+ }
137
+ return { display };
138
+ }
139
+ //#endregion
140
+ export { LOCALE_FIELD_CONTEXT, createLocaleDisplay, provideLocaleFieldContext, pruneLocaleMap, useLocaleField, useLocaleFieldContext, useLocaleTabs };
@@ -1,3 +1,4 @@
1
+ import { Ref } from "vue";
1
2
  import * as z from "zod";
2
3
  //#region src/zod/index.d.ts
3
4
  type ZodLocaleFactory = () => Parameters<typeof z.config>[0];
@@ -20,5 +21,26 @@ interface ZodLocaleSyncOptions {
20
21
  * change. Call once from an app plugin.
21
22
  */
22
23
  declare function setupZodLocaleSync(options: ZodLocaleSyncOptions): void;
24
+ interface UseWizardStepValidationOptions<TState> {
25
+ form: Ref<any>;
26
+ stepper: Ref<any>;
27
+ activeStep: Ref<number>;
28
+ state: TState;
29
+ schema: z.ZodObject<z.ZodRawShape> | Ref<z.ZodObject<z.ZodRawShape>>;
30
+ stepFields: Partial<Record<number, readonly (keyof TState)[]>>;
31
+ }
32
+ /**
33
+ * Gates a stepper + form multi-step wizard by validating only the current
34
+ * step's fields via `schema.pick(...)`: `currentStepValid` drives the Next
35
+ * button's enabled state reactively, `goNext` runs the form-level validation
36
+ * for the step's fields (surfacing messages) before advancing, `goPrev` just
37
+ * steps back. Works with any form/stepper exposing the structural `validate`
38
+ * / `next` / `prev` surface (e.g. Nuxt UI's UForm + UStepper).
39
+ */
40
+ declare function useWizardStepValidation<TState extends object>(options: UseWizardStepValidationOptions<TState>): {
41
+ currentStepValid: import("vue").ComputedRef<boolean>;
42
+ goNext: () => Promise<void>;
43
+ goPrev: () => void;
44
+ };
23
45
  //#endregion
24
- export { ZodLocaleSyncOptions, setupZodLocaleSync };
46
+ export { UseWizardStepValidationOptions, ZodLocaleSyncOptions, setupZodLocaleSync, useWizardStepValidation };
package/dist/zod/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { computed, isRef } from "vue";
1
2
  import * as z from "zod";
2
3
  //#region src/zod/index.ts
3
4
  /**
@@ -13,5 +14,47 @@ function setupZodLocaleSync(options) {
13
14
  apply(options.getLocale());
14
15
  options.onLocaleChange(apply);
15
16
  }
17
+ /**
18
+ * Gates a stepper + form multi-step wizard by validating only the current
19
+ * step's fields via `schema.pick(...)`: `currentStepValid` drives the Next
20
+ * button's enabled state reactively, `goNext` runs the form-level validation
21
+ * for the step's fields (surfacing messages) before advancing, `goPrev` just
22
+ * steps back. Works with any form/stepper exposing the structural `validate`
23
+ * / `next` / `prev` surface (e.g. Nuxt UI's UForm + UStepper).
24
+ */
25
+ function useWizardStepValidation(options) {
26
+ const { form, stepper, activeStep, state, schema, stepFields } = options;
27
+ function getSchema() {
28
+ return isRef(schema) ? schema.value : schema;
29
+ }
30
+ const currentStepValid = computed(() => {
31
+ const fields = stepFields[activeStep.value];
32
+ if (!fields || fields.length === 0) return true;
33
+ const partial = {};
34
+ const pickShape = {};
35
+ for (const f of fields) {
36
+ partial[f] = state[f];
37
+ pickShape[f] = true;
38
+ }
39
+ return getSchema().pick(pickShape).safeParse(partial).success;
40
+ });
41
+ async function goNext() {
42
+ const fields = stepFields[activeStep.value];
43
+ if (fields && fields.length > 0) try {
44
+ await form.value?.validate({ name: fields.map(String) });
45
+ } catch {
46
+ return;
47
+ }
48
+ stepper.value?.next();
49
+ }
50
+ function goPrev() {
51
+ stepper.value?.prev();
52
+ }
53
+ return {
54
+ currentStepValid,
55
+ goNext,
56
+ goPrev
57
+ };
58
+ }
16
59
  //#endregion
17
- export { setupZodLocaleSync };
60
+ export { setupZodLocaleSync, useWizardStepValidation };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octabits-io/nuxt-ui-kit",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Frontend kit for Nuxt/Vue admin SPAs: OIDC session harness (oidc-client-ts), Eden Treaty client factory, auth/org store cores, and a route-guard builder — factory-style seams the app wires into its own plugins, stores, and middleware",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -10,6 +10,26 @@
10
10
  "import": "./dist/index.js",
11
11
  "default": "./dist/index.js"
12
12
  },
13
+ "./auth": {
14
+ "types": "./dist/auth/index.d.ts",
15
+ "import": "./dist/auth/index.js",
16
+ "default": "./dist/auth/index.js"
17
+ },
18
+ "./api": {
19
+ "types": "./dist/api/index.d.ts",
20
+ "import": "./dist/api/index.js",
21
+ "default": "./dist/api/index.js"
22
+ },
23
+ "./i18n": {
24
+ "types": "./dist/i18n/index.d.ts",
25
+ "import": "./dist/i18n/index.js",
26
+ "default": "./dist/i18n/index.js"
27
+ },
28
+ "./locale": {
29
+ "types": "./dist/locale/index.d.ts",
30
+ "import": "./dist/locale/index.js",
31
+ "default": "./dist/locale/index.js"
32
+ },
13
33
  "./zod": {
14
34
  "types": "./dist/zod/index.d.ts",
15
35
  "import": "./dist/zod/index.js",
@@ -52,10 +72,12 @@
52
72
  "oidc-client-ts": "^3.5.0",
53
73
  "vitest": "^4.1.10",
54
74
  "vue": "^3.5.39",
55
- "zod": "^4.4.3"
75
+ "zod": "^4.4.3",
76
+ "@octabits-io/framework": "^0.3.0"
56
77
  },
57
78
  "peerDependencies": {
58
79
  "@elysiajs/eden": "^1.4.0",
80
+ "@octabits-io/framework": "^0.3.0",
59
81
  "@internationalized/date": "^3",
60
82
  "@nuxt/ui": "^4",
61
83
  "date-fns": "^3 || ^4",
@@ -68,6 +90,12 @@
68
90
  "zod": "^4"
69
91
  },
70
92
  "peerDependenciesMeta": {
93
+ "@elysiajs/eden": {
94
+ "optional": true
95
+ },
96
+ "@octabits-io/framework": {
97
+ "optional": true
98
+ },
71
99
  "@internationalized/date": {
72
100
  "optional": true
73
101
  },
@@ -80,6 +108,9 @@
80
108
  "elysia": {
81
109
  "optional": true
82
110
  },
111
+ "oidc-client-ts": {
112
+ "optional": true
113
+ },
83
114
  "typescript": {
84
115
  "optional": true
85
116
  },
@@ -0,0 +1,132 @@
1
+ <script setup lang="ts">
2
+ // Shipped as source: the consumer's Vite compiles this SFC. All imports are
3
+ // explicit — no reliance on the consumer's auto-import configuration.
4
+ // i18n key contract: localeField.translate (+ LocaleTab's key).
5
+ // Requires provideLocaleFieldContext() near the app root.
6
+ import { computed } from 'vue'
7
+ import { useI18n } from 'vue-i18n'
8
+ import type { LocaleMap } from '@octabits-io/framework/utils'
9
+ import UFormField from '@nuxt/ui/components/FormField.vue'
10
+ import UTabs from '@nuxt/ui/components/Tabs.vue'
11
+ import UInput from '@nuxt/ui/components/Input.vue'
12
+ import UTooltip from '@nuxt/ui/components/Tooltip.vue'
13
+ import UButton from '@nuxt/ui/components/Button.vue'
14
+ import {
15
+ useLocaleField,
16
+ useLocaleFieldContext,
17
+ type LocaleFieldTranslateScope,
18
+ } from '@octabits-io/nuxt-ui-kit/locale'
19
+ import LocaleTab from './LocaleTab.vue'
20
+
21
+ /**
22
+ * Edits a `LocaleMap<string>` field (one value per content locale) as a single
23
+ * input with a per-locale tab bar. The tab for a locale missing a value shows a
24
+ * dot — orange for a normal locale, red for the default locale (which should
25
+ * always be filled). The full map is the single source of truth, so switching
26
+ * tabs never loses an in-progress edit.
27
+ *
28
+ * Register-variant locales (e.g. `de-formal`) are hidden by default — a label is
29
+ * register-invariant ("Hotel" is the same formal or informal). Set
30
+ * `register-override` for reader-addressing prose where formal/informal differs;
31
+ * see `LocaleTextarea`, which is the usual home for that.
32
+ */
33
+ const model = defineModel<LocaleMap<string>>({ default: () => ({}) })
34
+
35
+ const props = defineProps<{
36
+ label?: string
37
+ description?: string
38
+ help?: string
39
+ placeholder?: string
40
+ /** Form field path — wires validation errors to this field. */
41
+ name?: string
42
+ /** Marks the default-locale value as required (visual only). */
43
+ required?: boolean
44
+ maxlength?: number
45
+ /** Surface register-variant locales (e.g. `de-formal`) as optional override tabs. */
46
+ registerOverride?: boolean
47
+ /** AI-translate context describing what the field holds. Defaults to `label`. */
48
+ translateContext?: string
49
+ /** Hide the AI-translate button (slugs, codes — values that must not be translated). */
50
+ noTranslate?: boolean
51
+ }>()
52
+
53
+ defineSlots<{
54
+ /**
55
+ * Replaces the default AI-translate button with custom field-level AI
56
+ * actions (e.g. a menu merging generate + translate). The scope carries the
57
+ * field's translate machinery so the override can still offer it.
58
+ */
59
+ ai?: (scope: LocaleFieldTranslateScope) => unknown
60
+ }>()
61
+
62
+ const { t } = useI18n()
63
+ const { useSource, useTranslate } = useLocaleFieldContext()
64
+
65
+ const { items, active, activeValue, indicatorOf, translateSource, translateTargets } = useLocaleField(
66
+ model,
67
+ useSource(),
68
+ () => props.registerOverride ?? false,
69
+ )
70
+
71
+ // The translate provider is optional app wiring — without it the sparkle
72
+ // button never renders and the #ai slot scope reports unavailable.
73
+ const translator = useTranslate?.({
74
+ model,
75
+ context: () => props.translateContext ?? props.label,
76
+ source: translateSource,
77
+ targetLocales: translateTargets,
78
+ })
79
+
80
+ const aiScope = computed<LocaleFieldTranslateScope>(() => ({
81
+ available: !!translator && items.value.length > 1 && !props.noTranslate,
82
+ canTranslate: translator?.canTranslate.value ?? false,
83
+ translating: translator?.translating.value ?? false,
84
+ translate: () => translator?.translate(),
85
+ }))
86
+ </script>
87
+
88
+ <template>
89
+ <UFormField :label="label" :description="description" :help="help" :name="name" :required="required">
90
+ <div class="flex flex-col gap-2">
91
+ <!-- A single effective locale needs no tab chrome — degrade to a plain input
92
+ (the row still renders when a page slots in field-level AI actions). -->
93
+ <div v-if="items.length > 1 || !!$slots.ai" class="flex items-center justify-between gap-2">
94
+ <UTabs
95
+ v-if="items.length > 1"
96
+ v-model="active"
97
+ :items="items"
98
+ :content="false"
99
+ size="sm"
100
+ color="neutral"
101
+ variant="link"
102
+ :ui="{ list: 'gap-2' }"
103
+ >
104
+ <template #default="{ item }">
105
+ <LocaleTab :label="item.label as string" :indicator="indicatorOf(item.value as string)" />
106
+ </template>
107
+ </UTabs>
108
+ <span v-else />
109
+ <slot name="ai" v-bind="aiScope">
110
+ <UTooltip v-if="aiScope.available" :text="t('localeField.translate')">
111
+ <UButton
112
+ icon="i-lucide-languages"
113
+ size="xs"
114
+ variant="ghost"
115
+ color="primary"
116
+ :loading="aiScope.translating"
117
+ :disabled="!aiScope.canTranslate"
118
+ :aria-label="t('localeField.translate')"
119
+ @click.prevent="aiScope.translate()"
120
+ />
121
+ </UTooltip>
122
+ </slot>
123
+ </div>
124
+ <UInput
125
+ v-model="activeValue"
126
+ :placeholder="placeholder"
127
+ :maxlength="maxlength"
128
+ class="w-full"
129
+ />
130
+ </div>
131
+ </UFormField>
132
+ </template>
@@ -0,0 +1,44 @@
1
+ <script setup lang="ts">
2
+ // Shipped as source: the consumer's Vite compiles this SFC. All imports are
3
+ // explicit — no reliance on the consumer's auto-import configuration.
4
+ // i18n key contract: localeField.inheritsBaseLocale.
5
+ import { computed } from 'vue'
6
+ import { useI18n } from 'vue-i18n'
7
+ import type { LocaleTabIndicator } from '@octabits-io/nuxt-ui-kit/locale'
8
+
9
+ /**
10
+ * Renders a locale tab label with its completeness indicator:
11
+ * red dot = default locale empty, orange = a normal locale empty,
12
+ * neutral dot = a register-variant override empty (inherits its base locale).
13
+ */
14
+ const props = defineProps<{
15
+ label: string
16
+ indicator: LocaleTabIndicator
17
+ }>()
18
+
19
+ const { t } = useI18n()
20
+
21
+ const dotClass = computed(() => {
22
+ switch (props.indicator?.kind) {
23
+ case 'error':
24
+ return 'bg-error'
25
+ case 'warning':
26
+ return 'bg-warning'
27
+ case 'inherits':
28
+ return 'bg-muted'
29
+ default:
30
+ return null
31
+ }
32
+ })
33
+
34
+ const title = computed(() =>
35
+ props.indicator?.kind === 'inherits' ? t('localeField.inheritsBaseLocale') : undefined,
36
+ )
37
+ </script>
38
+
39
+ <template>
40
+ <span class="flex items-center gap-1.5" :title="title">
41
+ {{ label }}
42
+ <span v-if="dotClass" class="size-1.5 shrink-0 rounded-full" :class="dotClass" />
43
+ </span>
44
+ </template>
@@ -0,0 +1,131 @@
1
+ <script setup lang="ts">
2
+ // Shipped as source: the consumer's Vite compiles this SFC. All imports are
3
+ // explicit — no reliance on the consumer's auto-import configuration.
4
+ // i18n key contract: localeField.translate (+ LocaleTab's key).
5
+ // Requires provideLocaleFieldContext() near the app root.
6
+ import { computed } from 'vue'
7
+ import { useI18n } from 'vue-i18n'
8
+ import type { LocaleMap } from '@octabits-io/framework/utils'
9
+ import UFormField from '@nuxt/ui/components/FormField.vue'
10
+ import UTabs from '@nuxt/ui/components/Tabs.vue'
11
+ import UTextarea from '@nuxt/ui/components/Textarea.vue'
12
+ import UTooltip from '@nuxt/ui/components/Tooltip.vue'
13
+ import UButton from '@nuxt/ui/components/Button.vue'
14
+ import {
15
+ useLocaleField,
16
+ useLocaleFieldContext,
17
+ type LocaleFieldTranslateScope,
18
+ } from '@octabits-io/nuxt-ui-kit/locale'
19
+ import LocaleTab from './LocaleTab.vue'
20
+
21
+ /**
22
+ * Multi-line variant of `LocaleInput` — edits a `LocaleMap<string>` field
23
+ * (descriptions, body copy, …) with a per-locale tab bar. See `LocaleInput`
24
+ * for the completeness-dot semantics.
25
+ *
26
+ * Reader-addressing prose is where formal/informal registers actually differ, so
27
+ * set `register-override` here to expose e.g. `de-formal` as an optional override
28
+ * tab (blank = inherits `de`). Leave it off for register-invariant copy.
29
+ */
30
+ const model = defineModel<LocaleMap<string>>({ default: () => ({}) })
31
+
32
+ const props = defineProps<{
33
+ label?: string
34
+ description?: string
35
+ help?: string
36
+ placeholder?: string
37
+ /** Form field path — wires validation errors to this field. */
38
+ name?: string
39
+ required?: boolean
40
+ maxlength?: number
41
+ rows?: number
42
+ /** Surface register-variant locales (e.g. `de-formal`) as optional override tabs. */
43
+ registerOverride?: boolean
44
+ /** AI-translate context describing what the field holds. Defaults to `label`. */
45
+ translateContext?: string
46
+ /** Hide the AI-translate button (slugs, codes — values that must not be translated). */
47
+ noTranslate?: boolean
48
+ }>()
49
+
50
+ defineSlots<{
51
+ /**
52
+ * Replaces the default AI-translate button with custom field-level AI
53
+ * actions (e.g. a menu merging generate + translate). The scope carries the
54
+ * field's translate machinery so the override can still offer it.
55
+ */
56
+ ai?: (scope: LocaleFieldTranslateScope) => unknown
57
+ }>()
58
+
59
+ const { t } = useI18n()
60
+ const { useSource, useTranslate } = useLocaleFieldContext()
61
+
62
+ const { items, active, activeValue, indicatorOf, translateSource, translateTargets } = useLocaleField(
63
+ model,
64
+ useSource(),
65
+ () => props.registerOverride ?? false,
66
+ )
67
+
68
+ // The translate provider is optional app wiring — without it the sparkle
69
+ // button never renders and the #ai slot scope reports unavailable.
70
+ const translator = useTranslate?.({
71
+ model,
72
+ context: () => props.translateContext ?? props.label,
73
+ source: translateSource,
74
+ targetLocales: translateTargets,
75
+ })
76
+
77
+ const aiScope = computed<LocaleFieldTranslateScope>(() => ({
78
+ available: !!translator && items.value.length > 1 && !props.noTranslate,
79
+ canTranslate: translator?.canTranslate.value ?? false,
80
+ translating: translator?.translating.value ?? false,
81
+ translate: () => translator?.translate(),
82
+ }))
83
+ </script>
84
+
85
+ <template>
86
+ <UFormField :label="label" :description="description" :help="help" :name="name" :required="required">
87
+ <div class="flex flex-col gap-2">
88
+ <!-- A single effective locale needs no tab chrome — degrade to a plain textarea
89
+ (the row still renders when a page slots in field-level AI actions). -->
90
+ <div v-if="items.length > 1 || !!$slots.ai" class="flex items-center justify-between gap-2">
91
+ <UTabs
92
+ v-if="items.length > 1"
93
+ v-model="active"
94
+ :items="items"
95
+ :content="false"
96
+ size="sm"
97
+ color="neutral"
98
+ variant="link"
99
+ :ui="{ list: 'gap-2' }"
100
+ >
101
+ <template #default="{ item }">
102
+ <LocaleTab :label="item.label as string" :indicator="indicatorOf(item.value as string)" />
103
+ </template>
104
+ </UTabs>
105
+ <span v-else />
106
+ <slot name="ai" v-bind="aiScope">
107
+ <UTooltip v-if="aiScope.available" :text="t('localeField.translate')">
108
+ <UButton
109
+ icon="i-lucide-languages"
110
+ size="xs"
111
+ variant="ghost"
112
+ color="primary"
113
+ :loading="aiScope.translating"
114
+ :disabled="!aiScope.canTranslate"
115
+ :aria-label="t('localeField.translate')"
116
+ @click.prevent="aiScope.translate()"
117
+ />
118
+ </UTooltip>
119
+ </slot>
120
+ </div>
121
+ <UTextarea
122
+ v-model="activeValue"
123
+ :placeholder="placeholder"
124
+ :maxlength="maxlength"
125
+ :rows="rows ?? 4"
126
+ autoresize
127
+ class="w-full"
128
+ />
129
+ </div>
130
+ </UFormField>
131
+ </template>