@vobs/ui 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.
@@ -4,16 +4,23 @@
4
4
  */
5
5
  import { type UiAttributes, type UiDensity, type UiDomPlan } from './primitives.js';
6
6
  import { type ReadonlySignal } from '@vobs/reactivity';
7
+ import { type I18nInstance } from '@vobs/i18n';
7
8
  import { type RouteLocation } from '@vobs/router';
9
+ import type { TranslationAdapter } from '@vobs/runtime-core';
8
10
  import { type ComponentProps, type InjectionKey } from '@vobs/runtime-dom';
9
11
  import { type UiEnvironment } from './icons.js';
10
- import type { BrandInput, ThemeName } from './theme.js';
12
+ import type { BrandInput, ThemePreference } from './theme.js';
11
13
  export type AppShellContentMode = 'contained' | 'scroll';
12
14
  export type AppShellSidebarMode = 'collapsed' | 'expanded' | 'hidden';
13
15
  export type AppShellNavItemTone = 'danger' | 'default' | 'success' | 'warning';
16
+ export interface AppShellTextConfig {
17
+ readonly key: string;
18
+ readonly fallback?: string;
19
+ }
20
+ export type AppShellText = string | AppShellTextConfig;
14
21
  export interface AppShellNavItem {
15
22
  readonly id: string;
16
- readonly label: string;
23
+ readonly label: AppShellText;
17
24
  readonly route?: string;
18
25
  readonly href?: string;
19
26
  readonly icon?: string;
@@ -26,7 +33,7 @@ export interface AppShellNavItem {
26
33
  }
27
34
  export interface AppShellAction {
28
35
  readonly id: string;
29
- readonly label: string;
36
+ readonly label: AppShellText;
30
37
  readonly icon?: string;
31
38
  readonly badge?: string | number;
32
39
  readonly intent?: string;
@@ -37,7 +44,7 @@ export type AppShellAccountMenuItemTone = 'danger' | 'default';
37
44
  export type AppShellAccountMenuItemKind = 'item' | 'separator';
38
45
  export interface AppShellAccountMenuItem {
39
46
  readonly id: string;
40
- readonly label?: string;
47
+ readonly label?: AppShellText;
41
48
  readonly kind?: AppShellAccountMenuItemKind;
42
49
  readonly route?: string;
43
50
  readonly href?: string;
@@ -46,7 +53,7 @@ export interface AppShellAccountMenuItem {
46
53
  readonly tone?: AppShellAccountMenuItemTone;
47
54
  }
48
55
  export interface AppShellBrandConfig {
49
- readonly label: string;
56
+ readonly label: AppShellText;
50
57
  readonly href?: string;
51
58
  readonly mark?: string;
52
59
  }
@@ -55,26 +62,43 @@ export interface AppShellNavigationConfig {
55
62
  readonly utility?: readonly AppShellNavItem[];
56
63
  }
57
64
  export interface AppShellAccountConfig {
58
- readonly label: string;
65
+ readonly label: AppShellText;
59
66
  readonly initials?: string;
60
67
  readonly icon?: string;
61
68
  readonly menu?: readonly AppShellAccountMenuItem[];
62
69
  readonly menuExpanded?: boolean;
63
70
  }
71
+ export interface AppShellLanguageOption {
72
+ readonly locale: string;
73
+ readonly label: AppShellText;
74
+ }
75
+ export interface AppShellLanguageConfig {
76
+ readonly label?: AppShellText;
77
+ readonly icon?: string;
78
+ readonly options: readonly AppShellLanguageOption[];
79
+ }
64
80
  export interface AppShellTopbarConfig {
65
81
  readonly actions?: readonly AppShellAction[];
82
+ readonly language?: AppShellLanguageConfig;
66
83
  readonly account?: AppShellAccountConfig;
67
84
  }
68
85
  export interface AppShellPageHeaderConfig {
69
- readonly kicker?: string;
70
- readonly titleFallback?: string;
71
- readonly descriptionFallback?: string;
86
+ readonly visible?: boolean;
87
+ readonly kicker?: AppShellText;
88
+ readonly titleFallback?: AppShellText;
89
+ readonly descriptionFallback?: AppShellText;
90
+ }
91
+ export interface AppShellRoutePageHeaderConfig {
92
+ readonly visible?: boolean;
93
+ readonly kicker?: AppShellText;
94
+ readonly title?: AppShellText;
95
+ readonly description?: AppShellText;
72
96
  }
73
97
  export interface AppShellStatusbarConfig {
74
- readonly text?: string;
98
+ readonly text?: AppShellText;
75
99
  readonly icon?: string;
76
- readonly version?: string;
77
- readonly pendingText?: string;
100
+ readonly version?: AppShellText;
101
+ readonly pendingText?: AppShellText;
78
102
  readonly pendingIcon?: string;
79
103
  }
80
104
  export interface AppShellThemeIconConfig {
@@ -82,7 +106,7 @@ export interface AppShellThemeIconConfig {
82
106
  readonly light?: string;
83
107
  }
84
108
  export interface AppShellThemeConfig {
85
- readonly mode?: ThemeName;
109
+ readonly mode?: ThemePreference;
86
110
  readonly brand?: BrandInput;
87
111
  readonly brands?: readonly BrandInput[];
88
112
  readonly icons?: AppShellThemeIconConfig;
@@ -92,14 +116,15 @@ export interface AppShellThemeConfig {
92
116
  export interface AppShellRouteConfig {
93
117
  readonly path?: string;
94
118
  readonly route?: string;
95
- readonly title?: string;
96
- readonly description?: string;
97
- readonly status?: string;
119
+ readonly title?: AppShellText;
120
+ readonly description?: AppShellText;
121
+ readonly status?: AppShellText;
98
122
  readonly activeNav?: string;
123
+ readonly pageHeader?: false | AppShellRoutePageHeaderConfig;
99
124
  }
100
125
  export interface AppShellDefinition {
101
126
  readonly id: string;
102
- readonly title?: string;
127
+ readonly title?: AppShellText;
103
128
  readonly brand: AppShellBrandConfig;
104
129
  readonly navigation?: AppShellNavigationConfig;
105
130
  readonly routes?: readonly AppShellRouteConfig[];
@@ -134,6 +159,13 @@ export interface ResolvedAppShellState {
134
159
  readonly description: string;
135
160
  readonly status: string;
136
161
  readonly page: AppShellRouteConfig | undefined;
162
+ readonly pageHeader: ResolvedAppShellPageHeader;
163
+ }
164
+ export interface ResolvedAppShellPageHeader {
165
+ readonly visible: boolean;
166
+ readonly kicker: string;
167
+ readonly title: string;
168
+ readonly description: string;
137
169
  }
138
170
  export interface ResolvedAppShellStatus {
139
171
  readonly state: 'idle' | 'pending';
@@ -145,14 +177,17 @@ export declare function defineAppShell<const Shell extends AppShellDefinition>(s
145
177
  export declare function defineAppShellConfig<const Config extends AppShellConfigInput>(config: Config): Config;
146
178
  export declare function createAppShellRegistry(config: AppShellConfigInput): AppShellRegistry;
147
179
  export declare function provideUiAppShells(context: UiAppShellProviderContext, config: AppShellConfigInput): () => void;
148
- export declare function resolveAppShellState(shell: AppShellDefinition, route: RouteLocation | string | null | undefined): ResolvedAppShellState;
149
- export declare function resolveAppShellStatus(shell: AppShellDefinition, route: RouteLocation | string | null | undefined, pendingRoute: RouteLocation | string | null | undefined): ResolvedAppShellStatus;
180
+ export declare function resolveAppShellState(shell: AppShellDefinition, route: RouteLocation | string | null | undefined, translate?: (key: string) => string): ResolvedAppShellState;
181
+ export declare function resolveAppShellStatus(shell: AppShellDefinition, route: RouteLocation | string | null | undefined, pendingRoute: RouteLocation | string | null | undefined, translate?: (key: string) => string): ResolvedAppShellStatus;
182
+ export declare function resolveAppShellText(value: AppShellText | undefined, translate?: (key: string) => string): string | undefined;
150
183
  export declare function resolveActiveAppShellNavItemId(routePath: string | undefined, items: readonly AppShellNavItem[], activeNav: string | undefined): string | undefined;
151
184
  export declare const AppShell: import("@vobs/runtime-core").ComponentDefinition<AppShellProps, Record<string, never>, AppShellComponentScope, Readonly<Record<string, Readonly<Record<string, unknown>>>>>;
152
185
  export interface AppShellComponentScope {
153
186
  readonly props: ComponentProps<AppShellProps>;
154
187
  readonly appShells: AppShellRegistry;
155
188
  readonly icons: UiEnvironment;
189
+ readonly i18n: I18nInstance | undefined;
190
+ readonly translation: TranslationAdapter | undefined;
156
191
  readonly currentRoute: ReadonlySignal<RouteLocation | null>;
157
192
  readonly pendingRoute: ReadonlySignal<RouteLocation | null>;
158
193
  }
@@ -166,6 +201,7 @@ export interface AppShellPlanOptions {
166
201
  readonly activeId?: string;
167
202
  readonly sidebarMode?: AppShellSidebarMode;
168
203
  readonly contentMode?: AppShellContentMode;
204
+ readonly pageHeaderVisible?: boolean;
169
205
  readonly title?: string;
170
206
  readonly subtitle?: string;
171
207
  readonly commandLabel?: string;
package/dist/app-shell.js CHANGED
@@ -4,9 +4,10 @@
4
4
  */
5
5
  import { createUiDomPlan, } from './primitives.js';
6
6
  import { effect, signal } from '@vobs/reactivity';
7
+ import { I18nKey } from '@vobs/i18n';
7
8
  import { CurrentRouteKey, PendingRouteKey } from '@vobs/router';
8
- import { createCompiledTemplate, createInjectionKey, createRuntimeError, defineComponent, mountSlot, } from '@vobs/runtime-dom';
9
- import { bindAppShellAccountMenu, bindAppShellThemeControls, createAppShellFrame, syncAppShellFrame, } from './app-shell-dom.js';
9
+ import { createCompiledTemplate, createInjectionKey, createRuntimeError, defineComponent, mountSlot, TranslationAdapterKey, } from '@vobs/runtime-dom';
10
+ import { bindAppShellAccountMenu, bindAppShellActions, bindAppShellLanguageMenu, bindAppShellThemeControls, createAppShellFrame, syncAppShellFrame, } from './app-shell-dom.js';
10
11
  import { createUiEnvironment, UiIconEnvironmentKey } from './icons.js';
11
12
  export const UiAppShellEnvironmentKey = createInjectionKey('vobs.ui.appShells');
12
13
  export function defineAppShell(shell) {
@@ -30,23 +31,25 @@ export function provideUiAppShells(context, config) {
30
31
  appShells: createAppShellRegistry(config),
31
32
  });
32
33
  }
33
- export function resolveAppShellState(shell, route) {
34
+ export function resolveAppShellState(shell, route, translate) {
34
35
  const routePath = typeof route === 'string' ? route : route?.path;
35
36
  const page = resolveAppShellPage(shell, routePath);
36
37
  const navItems = collectAppShellNavItems(shell);
37
38
  const activeId = resolveActiveAppShellNavItemId(routePath, navItems, page?.activeNav);
39
+ const pageHeader = resolveAppShellPageHeader(shell, page, translate);
38
40
  return {
39
41
  activeId,
40
- title: page?.title ?? shell.pageHeader?.titleFallback ?? shell.title ?? '',
41
- description: page?.description ?? shell.pageHeader?.descriptionFallback ?? '',
42
- status: page?.status ?? shell.statusbar?.text ?? '',
42
+ title: pageHeader.title,
43
+ description: pageHeader.description,
44
+ status: resolveAppShellText(page?.status ?? shell.statusbar?.text, translate) ?? '',
43
45
  page,
46
+ pageHeader,
44
47
  };
45
48
  }
46
- export function resolveAppShellStatus(shell, route, pendingRoute) {
49
+ export function resolveAppShellStatus(shell, route, pendingRoute, translate) {
47
50
  if (pendingRoute !== null && pendingRoute !== undefined) {
48
- const pending = resolveAppShellState(shell, pendingRoute);
49
- const textBase = shell.statusbar?.pendingText ?? 'Loading';
51
+ const pending = resolveAppShellState(shell, pendingRoute, translate);
52
+ const textBase = resolveAppShellText(shell.statusbar?.pendingText, translate) ?? 'Loading';
50
53
  return {
51
54
  state: 'pending',
52
55
  text: pending.title.length === 0 ? textBase : `${textBase} ${pending.title}`,
@@ -55,13 +58,20 @@ export function resolveAppShellStatus(shell, route, pendingRoute) {
55
58
  : { icon: shell.statusbar?.pendingIcon ?? shell.statusbar?.icon }),
56
59
  };
57
60
  }
58
- const current = resolveAppShellState(shell, route);
61
+ const current = resolveAppShellState(shell, route, translate);
59
62
  return {
60
63
  state: 'idle',
61
- text: current.status || shell.statusbar?.text || '',
64
+ text: current.status || resolveAppShellText(shell.statusbar?.text, translate) || '',
62
65
  ...(shell.statusbar?.icon === undefined ? {} : { icon: shell.statusbar.icon }),
63
66
  };
64
67
  }
68
+ export function resolveAppShellText(value, translate) {
69
+ if (value === undefined)
70
+ return undefined;
71
+ if (typeof value === 'string')
72
+ return value;
73
+ return translate?.(value.key) ?? value.fallback ?? value.key;
74
+ }
65
75
  export function resolveActiveAppShellNavItemId(routePath, items, activeNav) {
66
76
  if (activeNav !== undefined && items.some((item) => item.id === activeNav))
67
77
  return activeNav;
@@ -108,6 +118,8 @@ export const AppShell = defineComponent({
108
118
  props: context.props,
109
119
  appShells: appShellEnvironment.appShells,
110
120
  icons: context.maybeInject(UiIconEnvironmentKey) ?? createUiEnvironment(),
121
+ i18n: context.maybeInject(I18nKey),
122
+ translation: context.maybeInject(TranslationAdapterKey),
111
123
  currentRoute,
112
124
  pendingRoute,
113
125
  };
@@ -182,10 +194,10 @@ export function createAppShellPlans(options) {
182
194
  state: action.disabled === true ? 'disabled' : action.pressed === true ? 'pressed' : 'idle',
183
195
  attrs: {
184
196
  type: 'button',
185
- 'aria-label': action.label,
197
+ 'aria-label': resolveAppShellText(action.label),
186
198
  'aria-pressed': action.pressed === undefined ? undefined : action.pressed ? 'true' : 'false',
187
199
  'data-action-id': action.id,
188
- 'data-label': action.label,
200
+ 'data-label': resolveAppShellText(action.label),
189
201
  },
190
202
  })),
191
203
  main: createUiDomPlan('main', 'app-shell-main', {
@@ -194,7 +206,13 @@ export function createAppShellPlans(options) {
194
206
  },
195
207
  }),
196
208
  pageFrame: createUiDomPlan('section', 'app-shell-page-frame'),
197
- pageHeader: createUiDomPlan('header', 'app-shell-page-header'),
209
+ pageHeader: createUiDomPlan('header', 'app-shell-page-header', {
210
+ state: options.pageHeaderVisible === false ? 'hidden' : 'visible',
211
+ attrs: {
212
+ hidden: options.pageHeaderVisible === false,
213
+ 'aria-hidden': options.pageHeaderVisible === false ? 'true' : undefined,
214
+ },
215
+ }),
198
216
  pageTitle: options.title === undefined
199
217
  ? undefined
200
218
  : createUiDomPlan('h1', 'app-shell-page-title', {
@@ -249,7 +267,7 @@ function createNavItemPlan(options, item, activeId) {
249
267
  'aria-current': active ? 'page' : undefined,
250
268
  tabindex: active && item.disabled !== true ? 0 : -1,
251
269
  'data-nav-id': item.id,
252
- 'data-label': item.label,
270
+ 'data-label': resolveAppShellText(item.label),
253
271
  'data-tone': item.tone ?? 'default',
254
272
  'data-has-badge': item.badge === undefined ? undefined : 'true',
255
273
  },
@@ -271,18 +289,31 @@ function mountAppShell(owner, scope, environment) {
271
289
  mountSlot(owner, pageBody, 'default', environment.slots?.default, undefined);
272
290
  }
273
291
  bindAppShellThemeControls(owner, frame, shell, scope, environment);
292
+ bindAppShellActions(owner, frame, shell);
274
293
  bindAppShellAccountMenu(owner, frame, shell);
275
- syncAppShellFrame(frame, shell, resolveAppShellState(shell, scope.currentRoute.value), resolveAppShellStatus(shell, scope.currentRoute.value, scope.pendingRoute.value), scope, environment);
294
+ bindAppShellLanguageMenu(owner, frame, shell, scope, environment);
295
+ syncAppShellFrame(frame, shell, resolveShellState(shell, scope), resolveShellStatus(shell, scope), scope, environment);
276
296
  owner.run(() => {
277
297
  const stop = effect(() => {
278
- const state = resolveAppShellState(shell, scope.currentRoute.value);
279
- const status = resolveAppShellStatus(shell, scope.currentRoute.value, scope.pendingRoute.value);
298
+ void scope.translation?.locale?.value;
299
+ const state = resolveShellState(shell, scope);
300
+ const status = resolveShellStatus(shell, scope);
280
301
  syncAppShellFrame(frame, shell, state, status, scope, environment);
281
302
  }, { scheduler: environment.scheduler });
282
303
  owner.own(stop);
283
304
  });
284
305
  return { root: frame.root, refs: {} };
285
306
  }
307
+ function resolveShellState(shell, scope) {
308
+ return resolveAppShellState(shell, scope.currentRoute.value, appShellTranslate(scope));
309
+ }
310
+ function resolveShellStatus(shell, scope) {
311
+ return resolveAppShellStatus(shell, scope.currentRoute.value, scope.pendingRoute.value, appShellTranslate(scope));
312
+ }
313
+ function appShellTranslate(scope) {
314
+ const translation = scope.translation;
315
+ return translation === undefined ? undefined : (key) => translation.t(key);
316
+ }
286
317
  function readAppShell(scope) {
287
318
  const id = readOptionalString(scope.props.shell?.value);
288
319
  if (id === undefined)
@@ -323,6 +354,20 @@ function resolveAppShellPage(shell, routePath) {
323
354
  .filter((candidate) => candidate.score >= 0)
324
355
  .sort((left, right) => right.score - left.score)[0]?.page;
325
356
  }
357
+ function resolveAppShellPageHeader(shell, page, translate) {
358
+ const routeHeader = page?.pageHeader;
359
+ const header = routeHeader === false ? undefined : routeHeader;
360
+ const title = resolveAppShellText(header?.title ?? page?.title ?? shell.pageHeader?.titleFallback ?? shell.title, translate) ?? '';
361
+ const description = resolveAppShellText(header?.description ?? page?.description ?? shell.pageHeader?.descriptionFallback, translate) ?? '';
362
+ return {
363
+ visible: routeHeader === false ? false : (header?.visible ?? shell.pageHeader?.visible ?? true),
364
+ kicker: resolveAppShellText(header?.kicker ?? shell.pageHeader?.kicker ?? shell.title, translate) ??
365
+ resolveAppShellText(shell.brand.label, translate) ??
366
+ '',
367
+ title,
368
+ description,
369
+ };
370
+ }
326
371
  function scoreRoutePattern(pattern, path) {
327
372
  if (pattern === undefined)
328
373
  return -1;
package/dist/base.css CHANGED
@@ -31,8 +31,7 @@
31
31
  }
32
32
 
33
33
  :where(:focus-visible) {
34
- outline: 2px solid var(--border-brand);
35
- outline-offset: 2px;
34
+ outline: 1px solid var(--border-brand);
36
35
  }
37
36
 
38
37
  @media (prefers-reduced-motion: reduce) {
@@ -137,10 +137,11 @@ export function createUploadTransportStateContract(options = {}) {
137
137
  const disabled = options.disabled === true;
138
138
  const maxRetries = normalizeRetryCount(options.maxRetries);
139
139
  const retryingIds = normalizeIds(options.retryingIds);
140
+ const retryingSet = new Set(retryingIds);
140
141
  const files = (options.files ?? []).map((file) => uploadTransportFileContract(file, {
141
142
  disabled,
142
143
  maxRetries,
143
- retrying: retryingIds.includes(file.id),
144
+ retrying: retryingSet.has(file.id),
144
145
  serverErrors: normalizeServerErrors(options.serverErrors?.[file.id], file.serverError),
145
146
  }));
146
147
  const fileCount = files.length;
@@ -291,13 +292,14 @@ export function createCascaderAdvancedStateContract(options) {
291
292
  const visibleIds = options.visibleIds === undefined
292
293
  ? flattenedOptions.map((option) => option.id)
293
294
  : normalizeCascaderIds(options.visibleIds, new Set(flattenedOptions.map((option) => option.id)));
295
+ const visibleSet = new Set(visibleIds);
294
296
  const allowedSet = options.allowedIds === undefined
295
297
  ? undefined
296
298
  : new Set(normalizeCascaderIds(options.allowedIds, new Set(flattenedOptions.map((option) => option.id))));
297
299
  const deniedIds = normalizeCascaderIds(options.deniedIds, new Set(flattenedOptions.map((option) => option.id)));
298
300
  const deniedSet = new Set(deniedIds);
299
301
  const optionContracts = flattenedOptions.map((option) => {
300
- const visible = visibleIds.includes(option.id);
302
+ const visible = visibleSet.has(option.id);
301
303
  const allowed = !deniedSet.has(option.id) && (allowedSet === undefined || allowedSet.has(option.id));
302
304
  const reason = allowed ? '' : (options.disabledReasons?.[option.id] ?? 'Permission denied');
303
305
  return {
@@ -658,9 +660,11 @@ function normalizeCascaderIds(ids, knownIds) {
658
660
  if (ids === undefined)
659
661
  return [];
660
662
  const result = [];
663
+ const seen = new Set();
661
664
  for (const id of ids) {
662
- if (!knownIds.has(id) || result.includes(id))
665
+ if (!knownIds.has(id) || seen.has(id))
663
666
  continue;
667
+ seen.add(id);
664
668
  result.push(id);
665
669
  }
666
670
  return result;