@vobs/ui 0.1.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.
@@ -1268,32 +1268,59 @@ export function createPaginationPlans(options) {
1268
1268
  ...(options.siblingCount === undefined ? {} : { siblingCount: options.siblingCount }),
1269
1269
  ...(options.boundaryCount === undefined ? {} : { boundaryCount: options.boundaryCount }),
1270
1270
  });
1271
+ const root = createUiDomPlan('nav', 'pagination', {
1272
+ ...(options.className === undefined ? {} : { className: options.className }),
1273
+ attrs: {
1274
+ id: options.id,
1275
+ 'aria-label': options.label ?? 'Pagination',
1276
+ ...options.attrs,
1277
+ 'data-page': page,
1278
+ 'data-page-count': pageCount,
1279
+ 'data-total-items': normalizeCount(options.totalItems),
1280
+ },
1281
+ });
1282
+ const list = createUiDomPlan('ol', 'pagination-list', { attrs: { role: 'list' } });
1283
+ const previous = createPaginationControl('previous', page <= 1 ? 1 : page - 1, page <= 1, options.previousLabel ?? 'Previous page');
1284
+ const pages = items.map((item, index) => createPaginationItemPlan(item, index, options.pageLabel));
1285
+ const next = createPaginationControl('next', page >= pageCount ? pageCount : page + 1, page >= pageCount, options.nextLabel ?? 'Next page');
1286
+ const summary = options.summary ??
1287
+ formatPaginationSummary({
1288
+ ...options,
1289
+ page,
1290
+ pageCount,
1291
+ });
1292
+ const summaryPlan = createUiDomPlan('output', 'pagination-summary', {
1293
+ attrs: {
1294
+ 'aria-live': 'polite',
1295
+ 'data-summary': summary,
1296
+ },
1297
+ });
1271
1298
  return {
1272
- root: createUiDomPlan('nav', 'pagination', {
1273
- ...(options.className === undefined ? {} : { className: options.className }),
1274
- attrs: {
1275
- id: options.id,
1276
- 'aria-label': options.label ?? 'Pagination',
1277
- ...options.attrs,
1278
- 'data-page': page,
1279
- 'data-page-count': pageCount,
1280
- 'data-total-items': normalizeCount(options.totalItems),
1281
- },
1282
- }),
1283
- list: createUiDomPlan('ol', 'pagination-list', { attrs: { role: 'list' } }),
1284
- previous: createPaginationControl('previous', page <= 1 ? 1 : page - 1, page <= 1, options.previousLabel ?? 'Previous page'),
1285
- pages: items.map((item, index) => createPaginationItemPlan(item, index)),
1286
- next: createPaginationControl('next', page >= pageCount ? pageCount : page + 1, page >= pageCount, options.nextLabel ?? 'Next page'),
1287
- summary: createUiDomPlan('output', 'pagination-summary', {
1288
- attrs: {
1289
- 'aria-live': 'polite',
1290
- 'data-summary': formatPaginationSummary({
1291
- ...options,
1292
- page,
1293
- pageCount,
1294
- }),
1295
- },
1296
- }),
1299
+ root,
1300
+ list,
1301
+ previous,
1302
+ pages,
1303
+ next,
1304
+ summary: summaryPlan,
1305
+ model: {
1306
+ rootId: typeof root.attrs.id === 'string' ? root.attrs.id : '',
1307
+ rootClassName: root.className,
1308
+ page,
1309
+ pageCount,
1310
+ totalItems: normalizeCount(options.totalItems) ?? 0,
1311
+ previousClassName: previous.className,
1312
+ previousLabel: typeof previous.attrs['aria-label'] === 'string' ? previous.attrs['aria-label'] : '',
1313
+ previousPage: typeof previous.attrs['data-page'] === 'number' ? previous.attrs['data-page'] : 1,
1314
+ previousDisabled: previous.attrs.disabled === true || previous.attrs.disabled === '',
1315
+ listClassName: list.className,
1316
+ pages: items.map((item, index) => createPaginationItemModel(item, index, pages[index], options.pageLabel)),
1317
+ nextClassName: next.className,
1318
+ nextLabel: typeof next.attrs['aria-label'] === 'string' ? next.attrs['aria-label'] : '',
1319
+ nextPage: typeof next.attrs['data-page'] === 'number' ? next.attrs['data-page'] : pageCount,
1320
+ nextDisabled: next.attrs.disabled === true || next.attrs.disabled === '',
1321
+ summaryClassName: summaryPlan.className,
1322
+ summary,
1323
+ },
1297
1324
  };
1298
1325
  }
1299
1326
  export function createPaginationItems(options) {
@@ -1837,7 +1864,7 @@ function createPaginationControl(control, page, disabled, label) {
1837
1864
  },
1838
1865
  });
1839
1866
  }
1840
- function createPaginationItemPlan(item, index) {
1867
+ function createPaginationItemPlan(item, index, pageLabel) {
1841
1868
  if (item.kind === 'ellipsis') {
1842
1869
  return createUiDomPlan('span', 'pagination-ellipsis', {
1843
1870
  attrs: {
@@ -1852,13 +1879,26 @@ function createPaginationItemPlan(item, index) {
1852
1879
  state: item.current === true ? 'current' : 'idle',
1853
1880
  attrs: {
1854
1881
  type: 'button',
1855
- 'aria-label': `Page ${page}`,
1882
+ 'aria-label': pageLabel?.(page) ?? `Page ${page}`,
1856
1883
  'aria-current': item.current === true ? 'page' : undefined,
1857
1884
  'data-kind': 'page',
1858
1885
  'data-page': page,
1859
1886
  },
1860
1887
  });
1861
1888
  }
1889
+ function createPaginationItemModel(item, index, plan, pageLabel) {
1890
+ const page = item.page ?? 1;
1891
+ const isPage = item.kind === 'page';
1892
+ return {
1893
+ id: String(index),
1894
+ className: plan?.className ?? '',
1895
+ kind: item.kind,
1896
+ page,
1897
+ state: item.kind === 'ellipsis' ? 'ellipsis' : item.current === true ? 'current' : 'idle',
1898
+ current: item.current === true ? 'page' : undefined,
1899
+ label: isPage ? (pageLabel?.(page) ?? `Page ${page}`) : '',
1900
+ };
1901
+ }
1862
1902
  function createNoteLikePlans(kind, options) {
1863
1903
  const tone = options.tone ?? 'info';
1864
1904
  return {
@@ -2045,9 +2085,11 @@ function normalizeColumnPreferenceIds(ids, knownIds) {
2045
2085
  if (ids === undefined)
2046
2086
  return [];
2047
2087
  const result = [];
2088
+ const seen = new Set();
2048
2089
  for (const id of ids) {
2049
- if (!knownIds.has(id) || result.includes(id))
2090
+ if (!knownIds.has(id) || seen.has(id))
2050
2091
  continue;
2092
+ seen.add(id);
2051
2093
  result.push(id);
2052
2094
  }
2053
2095
  return result;
@@ -2056,10 +2098,11 @@ function orderTableColumns(columns, orderIds) {
2056
2098
  if (orderIds.length === 0)
2057
2099
  return [...columns];
2058
2100
  const columnById = new Map(columns.map((column) => [column.id, column]));
2101
+ const orderedIdSet = new Set(orderIds);
2059
2102
  const ordered = orderIds
2060
2103
  .map((id) => columnById.get(id))
2061
2104
  .filter((column) => column !== undefined);
2062
- const remaining = columns.filter((column) => !orderIds.includes(column.id));
2105
+ const remaining = columns.filter((column) => !orderedIdSet.has(column.id));
2063
2106
  return [...ordered, ...remaining];
2064
2107
  }
2065
2108
  function tableColumnPreferenceSticky(column, stickyStartIds, stickyEndIds) {
@@ -0,0 +1,26 @@
1
+ /** @license MIT
2
+ * Copyright (c) 2026 vobsjs
3
+ * @vobs/ui
4
+ */
5
+ import { type ComponentProps, type ComponentSlotContract } from '@vobs/runtime-dom';
6
+ import { type OverlayCloseReason } from './overlay.js';
7
+ export type DialogSize = 'lg' | 'md' | 'sm';
8
+ export interface DialogProps {
9
+ readonly open: boolean;
10
+ readonly id: string;
11
+ readonly size?: DialogSize;
12
+ readonly modal?: boolean;
13
+ readonly 'title-id'?: string;
14
+ readonly 'description-id'?: string;
15
+ readonly 'surface-class'?: string;
16
+ readonly [key: string]: unknown;
17
+ }
18
+ export interface DialogEvents extends Record<string, (...args: never[]) => unknown> {
19
+ readonly close: (reason: OverlayCloseReason) => void;
20
+ }
21
+ export interface DialogScope {
22
+ readonly props: ComponentProps<DialogProps>;
23
+ readonly emit: <Name extends keyof DialogEvents & string>(name: Name, ...args: Parameters<DialogEvents[Name]>) => void;
24
+ }
25
+ export type DialogSlots = Readonly<ComponentSlotContract>;
26
+ export declare const Dialog: import("@vobs/runtime-core").ComponentDefinition<DialogProps, DialogEvents, DialogScope, Readonly<Readonly<Record<string, Readonly<Record<string, unknown>>>>>>;
package/dist/dialog.js ADDED
@@ -0,0 +1,94 @@
1
+ /** @license MIT
2
+ * Copyright (c) 2026 vobsjs
3
+ * @vobs/ui
4
+ */
5
+ import { effect } from '@vobs/reactivity';
6
+ import { COMPONENT_SLOTS, createCompiledTemplate, defineComponent, } from '@vobs/runtime-dom';
7
+ import { createOverlayStack } from './overlay.js';
8
+ export const Dialog = defineComponent({
9
+ template: createCompiledTemplate({
10
+ templateId: 'k-dialog',
11
+ sourceId: '@vobs/ui/dialog',
12
+ rootTag: 'div',
13
+ mount(owner, scope, environment) {
14
+ return mountDialog(owner, scope, environment);
15
+ },
16
+ }),
17
+ props: ['open', 'id', 'size', 'modal', 'title-id', 'description-id', 'surface-class'],
18
+ emits: ['close'],
19
+ [COMPONENT_SLOTS]: { default: {} },
20
+ setup(context) {
21
+ return {
22
+ props: context.props,
23
+ emit: (name, ...args) => context.emit(name, ...args),
24
+ };
25
+ },
26
+ });
27
+ function mountDialog(owner, scope, environment) {
28
+ const root = document.createElement('div');
29
+ root.className = 'kui-overlay-root';
30
+ root.setAttribute('data-backdrop', 'true');
31
+ root.setAttribute('role', 'presentation');
32
+ const surface = document.createElement('section');
33
+ surface.className = 'kui-dialog';
34
+ surface.setAttribute('role', 'dialog');
35
+ surface.setAttribute('aria-modal', 'true');
36
+ root.append(surface);
37
+ environment.slots?.default?.(owner, surface);
38
+ const stack = createOverlayStack({ document });
39
+ let handle;
40
+ let wasOpen = false;
41
+ const stop = effect(() => {
42
+ const props = scope.props;
43
+ const open = props.open.value === true;
44
+ const id = readString(props.id.value, 'dialog');
45
+ const size = readSize(props.size?.value);
46
+ const modal = props.modal?.value !== false;
47
+ root.hidden = !open;
48
+ root.setAttribute('data-state', open ? 'open' : 'closed');
49
+ surface.id = id;
50
+ surface.setAttribute('data-size', size);
51
+ surface.setAttribute('aria-modal', String(modal));
52
+ setOptionalAttribute(surface, 'aria-labelledby', props['title-id']?.value);
53
+ setOptionalAttribute(surface, 'aria-describedby', props['description-id']?.value);
54
+ const surfaceClass = readString(props['surface-class']?.value, '');
55
+ surface.className = `kui-dialog${surfaceClass === '' ? '' : ` ${surfaceClass}`}`;
56
+ if (open === wasOpen)
57
+ return;
58
+ wasOpen = open;
59
+ if (open) {
60
+ handle = stack.open({
61
+ id,
62
+ kind: 'dialog',
63
+ modal,
64
+ element: surface,
65
+ managedElement: false,
66
+ closeOnOutside: true,
67
+ onClose: (reason) => scope.emit('close', reason),
68
+ });
69
+ }
70
+ else {
71
+ handle?.destroy();
72
+ handle = undefined;
73
+ }
74
+ }, { scheduler: environment.scheduler, flush: 'post' });
75
+ owner.own(stop);
76
+ owner.own(() => {
77
+ handle?.destroy();
78
+ stack.destroy();
79
+ });
80
+ return { root, refs: {} };
81
+ }
82
+ function readString(value, fallback) {
83
+ return typeof value === 'string' && value.trim() !== '' ? value : fallback;
84
+ }
85
+ function readSize(value) {
86
+ return value === 'sm' || value === 'lg' ? value : 'md';
87
+ }
88
+ function setOptionalAttribute(element, name, value) {
89
+ const normalized = typeof value === 'string' ? value.trim() : '';
90
+ if (normalized === '')
91
+ element.removeAttribute(name);
92
+ else
93
+ element.setAttribute(name, normalized);
94
+ }
package/dist/forms.js CHANGED
@@ -361,14 +361,16 @@ export function createChoiceGroupCardOptionStateContract(options) {
361
361
  const descriptionIds = normalizeKnownIds(options.descriptionIds ??
362
362
  options.options
363
363
  .filter((option) => option.description !== undefined)
364
- .map((option) => option.id), knownIds).filter((id) => options.options.find((option) => option.id === id)?.description !== undefined);
364
+ .map((option) => option.id), knownIds);
365
+ const optionById = new Map(options.options.map((option) => [option.id, option]));
366
+ const filteredDescriptionIds = descriptionIds.filter((id) => optionById.get(id)?.description !== undefined);
365
367
  const actionIds = normalizeKnownIds(options.actionIds, knownIds);
366
368
  const actionSet = new Set(actionIds);
367
369
  const actionDisabledIds = normalizeKnownIds(options.actionDisabledIds, knownIds).filter((id) => actionSet.has(id));
368
370
  const cardSet = new Set(cardIds);
369
371
  const selectedEmphasisSet = new Set(selectedEmphasisIds);
370
372
  const disabledEmphasisSet = new Set(disabledEmphasisIds);
371
- const descriptionSet = new Set(descriptionIds);
373
+ const descriptionSet = new Set(filteredDescriptionIds);
372
374
  const actionDisabledSet = new Set(actionDisabledIds);
373
375
  const state = options.options.length === 0
374
376
  ? 'empty'
@@ -431,13 +433,13 @@ export function createChoiceGroupCardOptionStateContract(options) {
431
433
  cardCount: cardIds.length,
432
434
  selectedEmphasisCount: selectedEmphasisIds.length,
433
435
  disabledEmphasisCount: disabledEmphasisIds.length,
434
- descriptionCount: descriptionIds.length,
436
+ descriptionCount: filteredDescriptionIds.length,
435
437
  actionCount: actionIds.length,
436
438
  actionDisabledCount: actionDisabledIds.length,
437
439
  cardIds,
438
440
  selectedEmphasisIds,
439
441
  disabledEmphasisIds,
440
- descriptionIds,
442
+ descriptionIds: filteredDescriptionIds,
441
443
  actionIds,
442
444
  actionDisabledIds,
443
445
  attrs: {
@@ -453,7 +455,7 @@ export function createChoiceGroupCardOptionStateContract(options) {
453
455
  'data-card-ids': cardIds.join(','),
454
456
  'data-selected-emphasis-ids': selectedEmphasisIds.join(','),
455
457
  'data-disabled-emphasis-ids': disabledEmphasisIds.join(','),
456
- 'data-description-ids': descriptionIds.join(','),
458
+ 'data-description-ids': filteredDescriptionIds.join(','),
457
459
  'data-action-ids': actionIds.join(','),
458
460
  'data-action-disabled-ids': actionDisabledIds.join(','),
459
461
  },
@@ -875,9 +877,11 @@ function normalizeKnownIds(value, knownIds) {
875
877
  if (value === undefined)
876
878
  return [];
877
879
  const result = [];
880
+ const seen = new Set();
878
881
  for (const id of value) {
879
- if (!knownIds.has(id) || result.includes(id))
882
+ if (!knownIds.has(id) || seen.has(id))
880
883
  continue;
884
+ seen.add(id);
881
885
  result.push(id);
882
886
  }
883
887
  return result;
package/dist/index.d.ts CHANGED
@@ -5,18 +5,27 @@
5
5
  import { type ComponentRegistry, type InjectionScope } from '@vobs/runtime-dom';
6
6
  import { type AppShellConfigInput } from './app-shell.js';
7
7
  import { type UiIconSource } from './icons.js';
8
+ export { PageState, type PageStateEvents, type PageStateProps, type PageStateScope, type PageStateStatus, } from './page-state.js';
8
9
  export * from './theme.js';
9
10
  export * from './icons.js';
10
11
  export * from './calendar.js';
11
12
  export * from './calendar-picker.js';
13
+ export * from './dialog.js';
12
14
  export * from './workbench.js';
13
- export { AppShell, UiAppShellEnvironmentKey, createAppShellRegistry, defineAppShell, defineAppShellConfig, provideUiAppShells, resolveActiveAppShellNavItemId, resolveAppShellState, resolveAppShellStatus, type AppShellAccountConfig, type AppShellAccountMenuItem, type AppShellAccountMenuItemKind, type AppShellAccountMenuItemTone, type AppShellAction, type AppShellBrandConfig, type AppShellConfigInput, type AppShellContentMode, type AppShellDefinition, type AppShellDefinitionRecord, type AppShellNavigationConfig, type AppShellNavItem, type AppShellNavItemTone, type AppShellPageHeaderConfig, type AppShellProps, type AppShellRegistry, type AppShellRouteConfig, type AppShellSidebarMode, type AppShellStatusbarConfig, type AppShellThemeIconConfig, type AppShellThemeConfig, type AppShellTopbarConfig, type ResolvedAppShellState, type ResolvedAppShellStatus, } from './app-shell.js';
15
+ export { AppShell, UiAppShellEnvironmentKey, createAppShellRegistry, defineAppShell, defineAppShellConfig, provideUiAppShells, resolveActiveAppShellNavItemId, resolveAppShellState, resolveAppShellStatus, type AppShellAccountConfig, type AppShellAccountMenuItem, type AppShellAccountMenuItemKind, type AppShellAccountMenuItemTone, type AppShellAction, type AppShellBrandConfig, type AppShellConfigInput, type AppShellContentMode, type AppShellDefinition, type AppShellDefinitionRecord, type AppShellLanguageConfig, type AppShellLanguageOption, type AppShellNavigationConfig, type AppShellNavItem, type AppShellNavItemTone, type AppShellPageHeaderConfig, type AppShellProps, type AppShellRegistry, type AppShellRouteConfig, type AppShellRoutePageHeaderConfig, type AppShellSidebarMode, type AppShellStatusbarConfig, type AppShellThemeIconConfig, type AppShellThemeConfig, type AppShellText, type AppShellTextConfig, type AppShellTopbarConfig, type ResolvedAppShellPageHeader, type ResolvedAppShellState, type ResolvedAppShellStatus, resolveAppShellText, } from './app-shell.js';
14
16
  export interface SetupUiOptions {
15
17
  readonly icons?: true | UiIconSource;
16
18
  readonly appShells?: AppShellConfigInput;
17
19
  }
20
+ declare const uiComponents: {
21
+ 'k-app-shell': ComponentRegistry[string];
22
+ 'k-icon': ComponentRegistry[string];
23
+ 'k-calendar-picker': ComponentRegistry[string];
24
+ 'k-dialog': ComponentRegistry[string];
25
+ 'k-page-state': ComponentRegistry[string];
26
+ };
18
27
  export interface UiSetupEnvironment {
19
- readonly components: ComponentRegistry;
28
+ readonly components: typeof uiComponents;
20
29
  readonly injections?: InjectionScope;
21
30
  }
22
31
  export declare function setupUi(options?: SetupUiOptions): UiSetupEnvironment;
package/dist/index.js CHANGED
@@ -5,19 +5,25 @@
5
5
  import { createInjectionScope, } from '@vobs/runtime-dom';
6
6
  import { AppShell, provideUiAppShells } from './app-shell.js';
7
7
  import { CalendarPicker } from './calendar-picker.js';
8
+ import { Dialog } from './dialog.js';
8
9
  import { Icon, provideUiIcons } from './icons.js';
10
+ import { PageState } from './page-state.js';
11
+ export { PageState, } from './page-state.js';
9
12
  export * from './theme.js';
10
13
  export * from './icons.js';
11
14
  export * from './calendar.js';
12
15
  export * from './calendar-picker.js';
16
+ export * from './dialog.js';
13
17
  export * from './workbench.js';
14
- export { AppShell, UiAppShellEnvironmentKey, createAppShellRegistry, defineAppShell, defineAppShellConfig, provideUiAppShells, resolveActiveAppShellNavItemId, resolveAppShellState, resolveAppShellStatus, } from './app-shell.js';
18
+ export { AppShell, UiAppShellEnvironmentKey, createAppShellRegistry, defineAppShell, defineAppShellConfig, provideUiAppShells, resolveActiveAppShellNavItemId, resolveAppShellState, resolveAppShellStatus, resolveAppShellText, } from './app-shell.js';
19
+ const uiComponents = {
20
+ 'k-app-shell': AppShell,
21
+ 'k-icon': Icon,
22
+ 'k-calendar-picker': CalendarPicker,
23
+ 'k-dialog': Dialog,
24
+ 'k-page-state': PageState,
25
+ };
15
26
  export function setupUi(options = {}) {
16
- const components = {
17
- 'k-app-shell': AppShell,
18
- 'k-icon': Icon,
19
- 'k-calendar-picker': CalendarPicker,
20
- };
21
27
  const injections = createInjectionScope();
22
28
  if (options.icons !== undefined && options.icons !== true) {
23
29
  provideUiIcons(injections, options.icons);
@@ -26,7 +32,9 @@ export function setupUi(options = {}) {
26
32
  provideUiAppShells(injections, options.appShells);
27
33
  }
28
34
  if (options.icons === undefined || options.icons === true) {
29
- return options.appShells === undefined ? { components } : { components, injections };
35
+ return options.appShells === undefined
36
+ ? { components: uiComponents }
37
+ : { components: uiComponents, injections };
30
38
  }
31
- return { components, injections };
39
+ return { components: uiComponents, injections };
32
40
  }
package/dist/overlay.d.ts CHANGED
@@ -30,6 +30,10 @@ export interface OpenOverlayOptions {
30
30
  readonly closeOnOutside?: boolean;
31
31
  readonly element?: HTMLElement;
32
32
  readonly initialFocus?: HTMLElement;
33
+ readonly autoFocus?: boolean;
34
+ readonly restoreFocusTarget?: HTMLElement;
35
+ /** Keep a caller-owned element in its current DOM position and leave it mounted on destroy. */
36
+ readonly managedElement?: boolean;
33
37
  readonly onClose?: (reason: OverlayCloseReason) => void;
34
38
  }
35
39
  export interface OverlayHandle {
package/dist/overlay.js CHANGED
@@ -47,13 +47,15 @@ export function createOverlayStack(options) {
47
47
  open(openOptions) {
48
48
  const previouslyFocused = activeHtmlElement(options.document);
49
49
  const modal = openOptions.modal ?? (openOptions.kind === undefined || openOptions.kind === 'dialog');
50
- const entry = createEntry(openOptions, modal, entries.length + 1, previouslyFocused);
50
+ const entry = createEntry(openOptions, modal, entries.length + 1, openOptions.restoreFocusTarget ?? previouslyFocused);
51
51
  entries.push(entry);
52
- if (portalTarget !== undefined && entry.element !== undefined)
52
+ if (portalTarget !== undefined &&
53
+ entry.element !== undefined &&
54
+ openOptions.managedElement !== false)
53
55
  portalTarget.append(entry.element);
54
56
  if (modal)
55
57
  lockScroll();
56
- focusInitialElement(openOptions.initialFocus, entry.element);
58
+ focusInitialElement(openOptions.initialFocus, entry.element, openOptions.autoFocus === true);
57
59
  return entry.handle;
58
60
  },
59
61
  destroy() {
@@ -98,7 +100,8 @@ export function createOverlayStack(options) {
98
100
  entries.splice(index, 1);
99
101
  if (modal)
100
102
  unlockScroll();
101
- openOptions.element?.remove();
103
+ if (openOptions.managedElement !== false)
104
+ openOptions.element?.remove();
102
105
  if (openOptions.restoreFocus !== false)
103
106
  previouslyFocused?.focus();
104
107
  },
@@ -617,11 +620,13 @@ function activeHtmlElement(document) {
617
620
  const active = document.activeElement;
618
621
  return isHTMLElement(active) ? active : undefined;
619
622
  }
620
- function focusInitialElement(initialFocus, element) {
623
+ function focusInitialElement(initialFocus, element, autoFocus) {
621
624
  if (initialFocus !== undefined) {
622
625
  initialFocus.focus();
623
626
  return;
624
627
  }
628
+ if (!autoFocus)
629
+ return;
625
630
  const target = element === undefined ? undefined : (firstFocusable(element) ?? element);
626
631
  target?.focus();
627
632
  }
@@ -0,0 +1,23 @@
1
+ /** @license MIT
2
+ * Copyright (c) 2026 vobsjs
3
+ * @vobs/ui
4
+ */
5
+ import { type ComponentProps } from '@vobs/runtime-dom';
6
+ export type PageStateStatus = 'loading' | 'ready' | 'empty' | 'error' | 'submitting';
7
+ export interface PageStateProps {
8
+ readonly state: PageStateStatus;
9
+ readonly 'loading-label'?: string;
10
+ readonly 'error-title'?: string;
11
+ readonly 'error-description'?: string;
12
+ readonly 'error-message'?: string;
13
+ readonly 'retry-label'?: string;
14
+ readonly [key: string]: unknown;
15
+ }
16
+ export interface PageStateEvents extends Record<string, (...args: never[]) => unknown> {
17
+ readonly retry: () => void;
18
+ }
19
+ export interface PageStateScope {
20
+ readonly props: ComponentProps<PageStateProps>;
21
+ readonly emit: <Name extends keyof PageStateEvents & string>(name: Name, ...args: Parameters<PageStateEvents[Name]>) => void;
22
+ }
23
+ export declare const PageState: import("@vobs/runtime-core").ComponentDefinition<PageStateProps, PageStateEvents, PageStateScope, Readonly<Record<string, Readonly<Record<string, unknown>>>>>;
@@ -0,0 +1,96 @@
1
+ /** @license MIT
2
+ * Copyright (c) 2026 vobsjs
3
+ * @vobs/ui
4
+ */
5
+ import { effect } from '@vobs/reactivity';
6
+ import { createCompiledTemplate, defineComponent, } from '@vobs/runtime-dom';
7
+ export const PageState = defineComponent({
8
+ template: createCompiledTemplate({
9
+ templateId: 'k-page-state',
10
+ sourceId: '@vobs/ui/page-state',
11
+ rootTag: 'section',
12
+ mount(owner, scope, environment) {
13
+ return mountPageState(owner, scope, environment);
14
+ },
15
+ hydrate(owner, root, scope, environment) {
16
+ return mountPageState(owner, scope, environment, root);
17
+ },
18
+ }),
19
+ props: [
20
+ 'state',
21
+ 'loading-label',
22
+ 'error-title',
23
+ 'error-description',
24
+ 'error-message',
25
+ 'retry-label',
26
+ ],
27
+ emits: ['retry'],
28
+ setup(context) {
29
+ return {
30
+ props: context.props,
31
+ emit: (name, ...args) => context.emit(name, ...args),
32
+ };
33
+ },
34
+ });
35
+ function mountPageState(owner, scope, environment, existingRoot) {
36
+ const root = existingRoot ?? document.createElement('section');
37
+ root.className = 'kui-page-state';
38
+ root.setAttribute('role', 'status');
39
+ root.setAttribute('aria-live', 'polite');
40
+ const stop = effect(() => {
41
+ renderPageState(root, scope);
42
+ }, { scheduler: environment.scheduler });
43
+ owner.own(stop);
44
+ return { root, refs: {} };
45
+ }
46
+ function renderPageState(root, scope) {
47
+ const state = readState(scope.props.state?.value);
48
+ const isError = state === 'error';
49
+ const isBlocking = state === 'loading' || isError;
50
+ root.hidden = !isBlocking;
51
+ root.setAttribute('data-state', state);
52
+ root.replaceChildren();
53
+ if (!isBlocking)
54
+ return;
55
+ const mark = document.createElement('span');
56
+ mark.className = 'kui-page-state-mark';
57
+ mark.setAttribute('aria-hidden', 'true');
58
+ mark.textContent = isError ? '!' : '...';
59
+ root.append(mark);
60
+ if (!isError) {
61
+ const loading = document.createElement('p');
62
+ loading.className = 'kui-page-state-loading';
63
+ loading.textContent = readString(scope.props['loading-label']?.value, 'Loading...');
64
+ root.append(loading);
65
+ return;
66
+ }
67
+ const error = document.createElement('div');
68
+ error.className = 'kui-page-state-error';
69
+ const title = document.createElement('h2');
70
+ title.textContent = readString(scope.props['error-title']?.value, 'Unable to load');
71
+ const description = document.createElement('p');
72
+ description.textContent = readString(scope.props['error-description']?.value, 'Try again to reload this page.');
73
+ error.append(title, description);
74
+ const message = readString(scope.props['error-message']?.value, '');
75
+ if (message !== '') {
76
+ const detail = document.createElement('p');
77
+ detail.className = 'kui-page-state-message';
78
+ detail.textContent = message;
79
+ error.append(detail);
80
+ }
81
+ const retry = document.createElement('button');
82
+ retry.type = 'button';
83
+ retry.className = 'kui-button kui-button--solid kui-button--md';
84
+ retry.textContent = readString(scope.props['retry-label']?.value, 'Try again');
85
+ retry.addEventListener('click', () => scope.emit('retry'));
86
+ error.append(retry);
87
+ root.append(error);
88
+ }
89
+ function readState(value) {
90
+ return value === 'ready' || value === 'empty' || value === 'error' || value === 'submitting'
91
+ ? value
92
+ : 'loading';
93
+ }
94
+ function readString(value, fallback) {
95
+ return typeof value === 'string' && value.trim() !== '' ? value : fallback;
96
+ }