@octabits-io/nuxt-ui-kit 0.2.1 → 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.
package/dist/index.js CHANGED
@@ -1,323 +1,4 @@
1
- import { UserManager, WebStorageStateStore } from "oidc-client-ts";
2
- import { computed, ref, watch } from "vue";
3
- import { treaty } from "@elysiajs/eden";
4
- //#region src/auth/oidc.ts
5
- /**
6
- * Lazily-created `UserManager` singleton bound to `window.localStorage`.
7
- * Call the returned getter from plugins/stores/composables — the manager is
8
- * constructed on first call (client-side only; requires `window`).
9
- */
10
- function createUserManagerFactory(options) {
11
- let userManager = null;
12
- return function getUserManager() {
13
- if (userManager) return userManager;
14
- const { issuerUrl, clientId } = options.getConfig();
15
- if (!issuerUrl || !clientId) (options.onMissingConfig ?? console.error)("Missing OIDC issuer URL or client id in runtime config");
16
- userManager = new UserManager({
17
- authority: issuerUrl,
18
- client_id: clientId,
19
- redirect_uri: `${window.location.origin}${options.redirectPath ?? "/auth/callback"}`,
20
- post_logout_redirect_uri: `${window.location.origin}${options.postLogoutRedirectPath ?? "/login"}`,
21
- response_type: "code",
22
- scope: options.scope,
23
- automaticSilentRenew: options.automaticSilentRenew ?? true,
24
- ...options.refreshTokenAllowedScope ? { refreshTokenAllowedScope: options.refreshTokenAllowedScope } : {},
25
- userStore: new WebStorageStateStore({ store: window.localStorage }),
26
- ...options.settings
27
- });
28
- return userManager;
29
- };
30
- }
31
- /**
32
- * Remove any `oidc.user:` storage keys that don't belong to the current
33
- * authority+clientId — leftovers from environment switches would otherwise
34
- * shadow or bloat the session storage.
35
- */
36
- function removeStaleOidcKeys(authority, clientId, storage = globalThis.localStorage) {
37
- const currentKey = `oidc.user:${authority}:${clientId}`;
38
- for (let i = storage.length - 1; i >= 0; i--) {
39
- const key = storage.key(i);
40
- if (key && key.startsWith("oidc.user:") && key !== currentKey) storage.removeItem(key);
41
- }
42
- }
43
- /** Refresh tokens fail unrecoverably with these OIDC error codes — user must re-auth. */
44
- function isUnrecoverableRenewError(message) {
45
- return message.includes("login_required") || message.includes("invalid_grant") || message.includes("interaction_required") || message.includes("consent_required");
46
- }
47
- /**
48
- * Build a `redirectToLogin()` that starts an OIDC signin redirect carrying the
49
- * current path as returnUrl state, with a plain `/login?redirect=` navigation
50
- * fallback when the IdP redirect cannot even be started. No-ops on auth routes.
51
- */
52
- function createLoginRedirector(options) {
53
- const loginPath = options.loginPath ?? "/login";
54
- const isAuthRoute = options.isAuthRoute ?? ((path) => path === loginPath || path.startsWith("/auth/"));
55
- return async function redirectToLogin() {
56
- const path = window.location.pathname;
57
- if (isAuthRoute(path)) return;
58
- const returnUrl = path + window.location.search;
59
- try {
60
- await options.getUserManager().signinRedirect({ state: returnUrl });
61
- } catch (err) {
62
- (options.log ?? console.error)("[oidc] signinRedirect failed, falling back to login navigation", err);
63
- window.location.href = `${loginPath}?redirect=${encodeURIComponent(returnUrl)}`;
64
- }
65
- };
66
- }
67
- /**
68
- * Wire oidc-client-ts session events to app callbacks:
69
- *
70
- * - silent-renew error → `notify` (`renew-failed`, or `session-expired` when the
71
- * error is unrecoverable — see {@link isUnrecoverableRenewError}); an
72
- * unrecoverable error also triggers the login redirect
73
- * - access token expired without renewal → `notify(session-expired)` +
74
- * `onSessionLost` + login redirect
75
- * - back-channel signout at the IdP → `onSessionLost` + login redirect (no notice
76
- * — the user initiated it elsewhere)
77
- *
78
- * Returns a detach function.
79
- */
80
- function attachSessionLifecycleHandlers(userManager, handlers) {
81
- const log = handlers.log ?? console.warn;
82
- const onSilentRenewError = (error) => {
83
- log("[oidc] silent token renew failed:", error);
84
- const unrecoverable = isUnrecoverableRenewError(error.message);
85
- handlers.notify?.(unrecoverable ? {
86
- kind: "session-expired",
87
- error
88
- } : {
89
- kind: "renew-failed",
90
- error
91
- });
92
- if (unrecoverable) handlers.redirectToLogin();
93
- };
94
- const onAccessTokenExpired = () => {
95
- log("[oidc] access token expired without silent renewal");
96
- handlers.notify?.({ kind: "session-expired" });
97
- handlers.onSessionLost?.();
98
- handlers.redirectToLogin();
99
- };
100
- const onUserSignedOut = () => {
101
- log("[oidc] user signed out at IdP (back-channel)");
102
- handlers.onSessionLost?.();
103
- handlers.redirectToLogin();
104
- };
105
- userManager.events.addSilentRenewError(onSilentRenewError);
106
- userManager.events.addAccessTokenExpired(onAccessTokenExpired);
107
- userManager.events.addUserSignedOut(onUserSignedOut);
108
- return () => {
109
- userManager.events.removeSilentRenewError(onSilentRenewError);
110
- userManager.events.removeAccessTokenExpired(onAccessTokenExpired);
111
- userManager.events.removeUserSignedOut(onUserSignedOut);
112
- };
113
- }
114
- //#endregion
115
- //#region src/auth/zitadel.ts
116
- /**
117
- * Zitadel scope presets for {@link createUserManagerFactory}.
118
- *
119
- * The URN scopes request the resource-owner (organization) claim and the
120
- * project role grants; `offline_access` requests a refresh token.
121
- */
122
- const ZITADEL_ORG_PROJECT_SCOPE = "openid profile email urn:zitadel:iam:user:resourceowner urn:zitadel:iam:org:project:roles offline_access";
123
- /**
124
- * Zitadel only accepts standard OIDC scopes on the refresh-token grant —
125
- * sending `offline_access` or the `urn:zitadel:*` scopes returns
126
- * `invalid_scope` even though they were granted at the initial auth. The
127
- * URN-based claims still need to land in the refreshed access token; that
128
- * depends on "Assert Roles on Authentication" being enabled at the Zitadel
129
- * project level.
130
- */
131
- const ZITADEL_REFRESH_TOKEN_ALLOWED_SCOPE = "openid profile email";
132
- //#endregion
133
- //#region src/auth/bypass.ts
134
- /**
135
- * Dev/E2E auth bypass: seed storage with a fake oidc-client-ts user whose
136
- * `access_token` is the bypass secret, so the app considers the session
137
- * authenticated and the API client sends the secret as Bearer.
138
- *
139
- * Call from a plugin that runs before the OIDC plugin. Skips seeding when a
140
- * valid (non-expired) session already exists; overwrites corrupt entries.
141
- * Returns whether a session was seeded.
142
- */
143
- function seedAuthBypassSession(options) {
144
- if (options.isProductionBuild) return false;
145
- if (!options.bypassSecret) return false;
146
- const storage = options.storage ?? globalThis.localStorage;
147
- const storageKey = `oidc.user:${options.issuerUrl}:${options.clientId}`;
148
- const existing = storage.getItem(storageKey);
149
- if (existing) try {
150
- if ((JSON.parse(existing).expires_at ?? 0) > Date.now() / 1e3) return false;
151
- } catch {}
152
- (options.warn ?? console.warn)("[auth-bypass] Seeding storage with bypass token for dev/E2E testing");
153
- storage.setItem(storageKey, JSON.stringify({
154
- access_token: options.bypassSecret,
155
- token_type: "Bearer",
156
- expires_at: Math.floor(Date.now() / 1e3) + (options.sessionTtlSeconds ?? 86400),
157
- profile: options.profile ?? {
158
- sub: "e2e-test-user",
159
- email: "e2e@example.test",
160
- name: "E2E Test User"
161
- },
162
- scope: "openid profile email"
163
- }));
164
- return true;
165
- }
166
- //#endregion
167
- //#region src/auth/session.ts
168
- function defaultAuthUserMapper(profile) {
169
- return {
170
- id: profile.sub,
171
- email: profile.email ?? "",
172
- name: profile.name ?? null,
173
- picture: profile.picture ?? null
174
- };
175
- }
176
- /**
177
- * Reactive OIDC session state + actions — the setup body of an auth store.
178
- * Wrap it in the app's own store so naming and registration stay app-owned:
179
- *
180
- * ```ts
181
- * export const useAuthStore = defineStore('auth', () =>
182
- * createAuthSessionCore({ getUserManager, mapUser: defaultAuthUserMapper }),
183
- * )
184
- * ```
185
- */
186
- function createAuthSessionCore(options) {
187
- const log = options.log ?? console.warn;
188
- const user = ref(null);
189
- const initialized = ref(false);
190
- const loading = ref(false);
191
- const isAuthenticated = computed(() => !!user.value);
192
- async function checkAuth() {
193
- loading.value = true;
194
- try {
195
- const um = options.getUserManager();
196
- let oidcUser = await um.getUser();
197
- if (oidcUser && oidcUser.expired && oidcUser.refresh_token) try {
198
- oidcUser = await um.signinSilent();
199
- } catch (err) {
200
- log("[auth] signinSilent failed during checkAuth", err);
201
- oidcUser = null;
202
- }
203
- if (oidcUser && !oidcUser.expired) user.value = options.mapUser(oidcUser.profile);
204
- else user.value = null;
205
- } catch {
206
- user.value = null;
207
- } finally {
208
- initialized.value = true;
209
- loading.value = false;
210
- }
211
- }
212
- async function login(returnUrl) {
213
- await options.getUserManager().signinRedirect({ state: returnUrl ?? "/" });
214
- }
215
- async function handleCallback() {
216
- loading.value = true;
217
- try {
218
- const oidcUser = await options.getUserManager().signinRedirectCallback();
219
- user.value = options.mapUser(oidcUser.profile);
220
- return oidcUser.state || "/";
221
- } finally {
222
- initialized.value = true;
223
- loading.value = false;
224
- }
225
- }
226
- async function logout() {
227
- loading.value = true;
228
- try {
229
- const manager = options.getUserManager();
230
- const idTokenHint = (await manager.getUser())?.id_token;
231
- await manager.removeUser();
232
- user.value = null;
233
- await manager.signoutRedirect(idTokenHint ? { id_token_hint: idTokenHint } : void 0);
234
- } finally {
235
- loading.value = false;
236
- }
237
- }
238
- return {
239
- user,
240
- initialized,
241
- loading,
242
- isAuthenticated,
243
- checkAuth,
244
- login,
245
- handleCallback,
246
- logout
247
- };
248
- }
249
- //#endregion
250
- //#region src/auth/guard.ts
251
- /**
252
- * Build the body of a global auth route-middleware. The returned handler
253
- * yields a redirect target path or `undefined` to allow navigation; the app's
254
- * middleware maps that onto its router:
255
- *
256
- * ```ts
257
- * export default defineNuxtRouteMiddleware(async (to) => {
258
- * const target = await guard(to)
259
- * if (target) return navigateTo(target)
260
- * })
261
- * ```
262
- */
263
- function createAuthGuard(options) {
264
- const isPublicRoute = options.isPublicRoute ?? ((to) => to.path === "/login" || to.path.startsWith("/auth/"));
265
- const loginRedirect = options.loginRedirect ?? ((returnTo) => `/login?redirect=${encodeURIComponent(returnTo)}`);
266
- return async function guard(to) {
267
- if (isPublicRoute(to)) return void 0;
268
- if (!await options.ensureAuthenticated()) return loginRedirect(to.fullPath);
269
- const target = await options.afterAuthenticated?.(to);
270
- return typeof target === "string" ? target : void 0;
271
- };
272
- }
273
- //#endregion
274
- //#region src/api/client.ts
275
- /**
276
- * Resolve the API base URL: configured value, else the page origin in
277
- * production builds (same-host ingress), else a localhost dev port.
278
- */
279
- function resolveApiBaseUrl(options) {
280
- if (options.configuredUrl) return options.configuredUrl;
281
- return options.isProductionBuild ? options.origin ?? window.location.origin : `http://localhost:${options.devFallbackPort}`;
282
- }
283
- /**
284
- * Bearer-token provider backed by the OIDC session: resolves to the current
285
- * access token, or `null` when there is no non-expired session.
286
- */
287
- function createAccessTokenProvider(getUserManager) {
288
- return async function getAccessToken() {
289
- const user = await getUserManager().getUser();
290
- if (!user || user.expired) return null;
291
- return user.access_token;
292
- };
293
- }
294
- /**
295
- * Lazily-created Eden Treaty client singleton with OIDC bearer injection.
296
- *
297
- * ```ts
298
- * const getClient = createTreatyClientFactory<App>({ getBaseUrl, getAccessToken })
299
- * export function useApi() {
300
- * const client = getClient()
301
- * return { api: client.api, client }
302
- * }
303
- * ```
304
- */
305
- function createTreatyClientFactory(options) {
306
- let client = null;
307
- return function getClient() {
308
- if (client) return client;
309
- client = treaty(options.getBaseUrl(), {
310
- parseDate: options.parseDate ?? false,
311
- headers: async () => {
312
- const token = await options.getAccessToken();
313
- if (token) return { authorization: `Bearer ${token}` };
314
- },
315
- ...options.treatyConfig
316
- });
317
- return client;
318
- };
319
- }
320
- //#endregion
1
+ import { computed, reactive, ref, watch } from "vue";
321
2
  //#region src/org/orgStore.ts
322
3
  /**
323
4
  * Reactive granted-organizations state + switching — the setup body of an
@@ -379,6 +60,11 @@ function createOrgStoreCore(options) {
379
60
  };
380
61
  }
381
62
  //#endregion
63
+ //#region src/runtimeConfig.ts
64
+ function resolveRuntimeConfigValue(appConfigKey, fallback) {
65
+ return (typeof window === "undefined" ? void 0 : window.__APP_CONFIG__)?.[appConfigKey] || fallback;
66
+ }
67
+ //#endregion
382
68
  //#region src/composables/useConfirm.ts
383
69
  const isOpen = ref(false);
384
70
  const currentOptions = ref({ title: "" });
@@ -470,6 +156,53 @@ function createApiErrorMessenger(options) {
470
156
  };
471
157
  }
472
158
  //#endregion
159
+ //#region src/composables/useHelpPanel.ts
160
+ const HELP_PANEL_KEY = Symbol("help-panel");
161
+ /**
162
+ * Provide/inject registry for a per-tab contextual help panel: pages register
163
+ * help actions keyed by tab, `PageUtilityActions` renders the toggle, and a
164
+ * panel component renders `currentActions`. Open state persists to
165
+ * localStorage; switching to a tab without actions auto-closes the panel.
166
+ *
167
+ * Provide it per page: `provide(HELP_PANEL_KEY, useHelpPanel())`.
168
+ */
169
+ function useHelpPanel(options = {}) {
170
+ const storageKey = options.storageKey ?? "help-panel-open";
171
+ const storage = options.storage ?? globalThis.localStorage;
172
+ const isOpen = ref(storage?.getItem(storageKey) === "true");
173
+ watch(isOpen, (open) => storage?.setItem(storageKey, String(open)));
174
+ const registrations = reactive(/* @__PURE__ */ new Map());
175
+ const activeTabValue = ref("");
176
+ const currentActions = computed(() => {
177
+ return registrations.get(activeTabValue.value)?.actions ?? [];
178
+ });
179
+ const hasActions = computed(() => currentActions.value.length > 0);
180
+ function register(tabValue, actions) {
181
+ registrations.set(tabValue, { actions });
182
+ }
183
+ function unregister(tabValue) {
184
+ registrations.delete(tabValue);
185
+ }
186
+ function setActiveTab(tabValue) {
187
+ activeTabValue.value = tabValue;
188
+ if (!registrations.has(tabValue)) isOpen.value = false;
189
+ }
190
+ function toggle() {
191
+ isOpen.value = !isOpen.value;
192
+ }
193
+ return {
194
+ registrations,
195
+ isOpen,
196
+ activeTabValue,
197
+ currentActions,
198
+ hasActions,
199
+ register,
200
+ unregister,
201
+ setActiveTab,
202
+ toggle
203
+ };
204
+ }
205
+ //#endregion
473
206
  //#region src/composables/useDirtyTracking.ts
474
207
  function deepClone(obj) {
475
208
  return JSON.parse(JSON.stringify(obj));
@@ -535,4 +268,4 @@ function usePagination(options = {}) {
535
268
  };
536
269
  }
537
270
  //#endregion
538
- export { ZITADEL_ORG_PROJECT_SCOPE, ZITADEL_REFRESH_TOKEN_ALLOWED_SCOPE, attachSessionLifecycleHandlers, createAccessTokenProvider, createApiErrorMessenger, createAuthGuard, createAuthSessionCore, createLoginRedirector, createOrgStoreCore, createTreatyClientFactory, createUserManagerFactory, defaultAuthUserMapper, isUnrecoverableRenewError, removeStaleOidcKeys, resolveApiBaseUrl, seedAuthBypassSession, useConfirm, useConfirmState, useDirtyTracking, usePagination };
271
+ export { HELP_PANEL_KEY, createApiErrorMessenger, createOrgStoreCore, resolveRuntimeConfigValue, useConfirm, useConfirmState, useDirtyTracking, useHelpPanel, usePagination };
@@ -0,0 +1,140 @@
1
+ import { ComputedRef, InjectionKey, MaybeRefOrGetter, ModelRef, Ref } from "vue";
2
+ import { LocaleMap } from "@octabits-io/framework/utils";
3
+ //#region src/locale/pruneLocaleMap.d.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
+ declare function pruneLocaleMap(map: LocaleMap<string> | null | undefined): LocaleMap<string>;
11
+ //#endregion
12
+ //#region src/locale/index.d.ts
13
+ /** Reactive source of the content-locale set a locale field edits against. */
14
+ interface LocaleFieldSource {
15
+ /** Supported content locales (BCP-47 tags). */
16
+ locales: MaybeRefOrGetter<string[]>;
17
+ /** The locale whose value is required (completeness "error" dot). */
18
+ defaultLocale: MaybeRefOrGetter<string>;
19
+ }
20
+ type LocaleTabIndicator = {
21
+ kind: 'error';
22
+ } | {
23
+ kind: 'warning';
24
+ } | {
25
+ kind: 'inherits';
26
+ } | null;
27
+ /**
28
+ * Scope the locale field editors expose through their `#ai` slot, so a page
29
+ * can replace the default translate button with a combined AI menu without
30
+ * re-implementing the translate machinery.
31
+ */
32
+ interface LocaleFieldTranslateScope {
33
+ /** Translate exists for this field at all (multiple locales, not `no-translate`). */
34
+ available: boolean;
35
+ /** Translate is currently actionable (some tab has text, empty targets exist). */
36
+ canTranslate: boolean;
37
+ translating: boolean;
38
+ translate: () => void;
39
+ }
40
+ /** What a quick-translate provider returns — drives the sparkle button. */
41
+ interface LocaleFieldTranslator {
42
+ translating: Ref<boolean>;
43
+ canTranslate: Ref<boolean> | ComputedRef<boolean>;
44
+ translate: () => void;
45
+ }
46
+ interface UseTranslateOptions {
47
+ model: ModelRef<LocaleMap<string> | undefined>;
48
+ /** What the field is about (e.g. "Listing title") — passed as AI context. */
49
+ context: MaybeRefOrGetter<string | undefined>;
50
+ /** Source locale (from `useLocaleField().translateSource`). */
51
+ source: ComputedRef<string | null>;
52
+ /** Empty visible locales to fill (from `useLocaleField().translateTargets`). */
53
+ targetLocales: ComputedRef<string[]>;
54
+ }
55
+ /**
56
+ * App context the locale field components resolve at setup time. Both members
57
+ * are *factories invoked during the component's own setup*, so they may call
58
+ * composables (state, route, API clients) without executing at provide time.
59
+ */
60
+ interface LocaleFieldContext {
61
+ /** Resolve the app's content-locale source. */
62
+ useSource: () => LocaleFieldSource;
63
+ /**
64
+ * Optional quick-translate provider; the AI-translate button renders only
65
+ * when this is present.
66
+ */
67
+ useTranslate?: (options: UseTranslateOptions) => LocaleFieldTranslator;
68
+ }
69
+ declare const LOCALE_FIELD_CONTEXT: InjectionKey<LocaleFieldContext>;
70
+ /** Provide the locale-field context (call once, near the app root). */
71
+ declare function provideLocaleFieldContext(context: LocaleFieldContext): void;
72
+ /** Resolve the locale-field context inside a component (throws when absent). */
73
+ declare function useLocaleFieldContext(): LocaleFieldContext;
74
+ /**
75
+ * Tab plumbing shared by every per-locale field editor: one tab per content
76
+ * locale, an active-tab ref, and the completeness indicator. Value access
77
+ * stays with the caller — pass `hasValue` so the indicator works for any
78
+ * value type (strings, rich-text documents, …).
79
+ *
80
+ * **Register variants** (e.g. `de-formal`, whose base `de` is also supported)
81
+ * are a tone/overlay axis, not a real language: a label like "Hotel" is
82
+ * identical in both. So variant tabs are **hidden by default** and only shown
83
+ * when `registerOverride` is true (reader-addressing prose — descriptions,
84
+ * body copy). When shown, a blank variant value *inherits* its base locale
85
+ * (neutral hint, not a "missing" warning), and clearing the field should
86
+ * **delete the key** so the resolver falls through to the base
87
+ * (`de-formal → de`).
88
+ */
89
+ declare function useLocaleTabs(hasValue: (locale: string) => boolean, source: LocaleFieldSource, registerOverride?: MaybeRefOrGetter<boolean>): {
90
+ items: ComputedRef<{
91
+ label: string;
92
+ value: string;
93
+ }[]>;
94
+ active: Ref<string, string>;
95
+ indicatorOf: (loc: string) => LocaleTabIndicator;
96
+ isVariant: (loc: string) => boolean;
97
+ defaultLocale: ComputedRef<string>;
98
+ visibleLocales: ComputedRef<string[]>;
99
+ translateSource: ComputedRef<string | null>;
100
+ translateTargets: ComputedRef<string[]>;
101
+ };
102
+ /**
103
+ * String-valued locale field over a single `LocaleMap<string>` model — the
104
+ * composable behind `LocaleInput` / `LocaleTextarea`. See
105
+ * {@link useLocaleTabs} for the tab/indicator semantics.
106
+ */
107
+ declare function useLocaleField(model: ModelRef<LocaleMap<string> | undefined>, source: LocaleFieldSource, registerOverride?: MaybeRefOrGetter<boolean>): {
108
+ items: ComputedRef<{
109
+ label: string;
110
+ value: string;
111
+ }[]>;
112
+ active: Ref<string, string>;
113
+ activeValue: import("vue").WritableComputedRef<string, string>;
114
+ indicatorOf: (loc: string) => LocaleTabIndicator;
115
+ defaultLocale: ComputedRef<string>;
116
+ translateSource: ComputedRef<string | null>;
117
+ translateTargets: ComputedRef<string[]>;
118
+ };
119
+ /**
120
+ * Resolve a `LocaleMap<string>` to a single display string for list / detail
121
+ * surfaces, using the **default content locale** — deliberately decoupled
122
+ * from the app's own UI language, which is unrelated chrome and would
123
+ * otherwise select content arbitrarily. Lists therefore always show the
124
+ * canonical value, consistently for every user; the per-locale values stay
125
+ * fully editable via `LocaleInput` / `LocaleTextarea`.
126
+ */
127
+ declare function createLocaleDisplay(source: Pick<LocaleFieldSource, 'defaultLocale'>): {
128
+ display: (map: LocaleMap<string> | null | undefined, fallback?: string) => string;
129
+ };
130
+ /**
131
+ * Translation-completeness summary rendered by `TranslationBadge`:
132
+ * `complete` when every in-use translatable leaf covers all supported
133
+ * locales; `missing` counts absent leaves per locale otherwise.
134
+ */
135
+ interface TranslationStatus {
136
+ complete: boolean;
137
+ missing: Record<string, number>;
138
+ }
139
+ //#endregion
140
+ export { LOCALE_FIELD_CONTEXT, LocaleFieldContext, LocaleFieldSource, LocaleFieldTranslateScope, LocaleFieldTranslator, LocaleTabIndicator, TranslationStatus, UseTranslateOptions, createLocaleDisplay, provideLocaleFieldContext, pruneLocaleMap, useLocaleField, useLocaleFieldContext, useLocaleTabs };
@@ -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 };