@jini-ai/ui-kit 0.1.3 → 0.1.5

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @jini-ai/ui-kit
2
2
 
3
+ ## 0.1.5 — 2026-10-22
4
+
5
+ - 2026-10-22: Complexity refactors, coverage.
6
+
7
+ ## 0.1.4 — 2026-10-13
8
+
9
+ - Break facade/native import cycles: native dialogs pick the provider's Button through a context-only lookup (`NativeButton` fallback; `nativeActions` still forces native), and `ToastRegion` creates its Toast elements. No API change.
10
+
3
11
  ## 0.1.2
4
12
 
5
13
  ### Patch Changes
package/dist/confirm.js CHANGED
@@ -1,6 +1,9 @@
1
+ const defaultConsequences = new Map([
2
+ ['warning', 'changes access, but is reversible'], ['danger', 'cannot be undone'],
3
+ ]);
1
4
  export function planConfirm(required, _optional = {}) {
2
5
  const pending = required.pending === true, tone = required.tone ?? 'default';
3
- const consequence = required.consequence?.trim() || (tone === 'danger' ? 'cannot be undone' : tone === 'warning' ? 'changes access, but is reversible' : undefined);
6
+ const consequence = required.consequence?.trim() || defaultConsequences.get(tone);
4
7
  const confirmLabel = `${required.confirmLabel} — ${required.title}${consequence ? `; ${consequence}` : ''}`;
5
8
  const cancelLabel = required.cancelLabel ?? 'Cancel';
6
9
  const agent = (action, label) => required.agentHandle === undefined ? undefined :
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export { KIT_CONTRACT, kitSpec, kitParts, implementedComponents } from './kit.spec.js';
2
- export type { KitComponentName, ImplementedComponentName, KitPart } from './kit.spec.js';
1
+ export { KIT_CONTRACT, kitSpec, kitParts, implementedComponents } from './kit-contract.js';
2
+ export type { KitComponentName, ImplementedComponentName, KitPart } from './kit-contract.js';
3
3
  export type { KitAttrs, AgentSpec, AgentAttrsPort } from './attrs.js';
4
4
  export { needs, assertKitContract, KitConfigError } from './needs.js';
5
5
  export type { KitNeeds } from './needs.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { KIT_CONTRACT, kitSpec, kitParts, implementedComponents } from './kit.spec.js';
1
+ export { KIT_CONTRACT, kitSpec, kitParts, implementedComponents } from './kit-contract.js';
2
2
  export { needs, assertKitContract, KitConfigError } from './needs.js';
3
3
  export { planConfirm, createConfirmController } from './confirm.js';
4
4
  export { createToastService } from './toast.js';
@@ -488,4 +488,4 @@ export type ImplementedComponentName = {
488
488
  export type KitPart = typeof kitSpec[ImplementedComponentName]['parts'][number];
489
489
  export declare const implementedComponents: readonly ImplementedComponentName[];
490
490
  export declare const kitParts: readonly KitPart[];
491
- //# sourceMappingURL=kit.spec.d.ts.map
491
+ //# sourceMappingURL=kit-contract.d.ts.map
@@ -37,4 +37,4 @@ export const kitSpec = {
37
37
  };
38
38
  export const implementedComponents = Object.freeze(Object.keys(kitSpec).filter((name) => kitSpec[name].status === 'implemented'));
39
39
  export const kitParts = Object.freeze([...new Set(implementedComponents.flatMap(name => [...kitSpec[name].parts]))]);
40
- //# sourceMappingURL=kit.spec.js.map
40
+ //# sourceMappingURL=kit-contract.js.map
package/dist/needs.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type KitComponentName } from './kit.spec.js';
1
+ import { type KitComponentName } from './kit-contract.js';
2
2
  export interface KitNeeds<N extends KitComponentName = KitComponentName> {
3
3
  readonly id: string;
4
4
  readonly contract: string;
package/dist/needs.js CHANGED
@@ -1,4 +1,4 @@
1
- import { KIT_CONTRACT, kitSpec } from './kit.spec.js';
1
+ import { KIT_CONTRACT, kitSpec } from './kit-contract.js';
2
2
  export class KitConfigError extends Error {
3
3
  code = 'KIT_CONFIG';
4
4
  issues;
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from 'react';
2
- import { type ImplementedComponentName } from '../kit.spec.js';
2
+ import { type ImplementedComponentName } from '../kit-contract.js';
3
3
  import type { KitContextValue, KitProviderProps, ReactKitProps, ToastProps } from './types.js';
4
4
  import { createToastService } from '../toast.js';
5
5
  export declare function useKit(_required?: Record<string, never>, _optional?: Record<string, never>): KitContextValue;
@@ -25,6 +25,7 @@ export declare function useFacade<K extends Exclude<ImplementedComponentName, 'C
25
25
  };
26
26
  };
27
27
  export declare function useToast(_required?: Record<string, never>, _optional?: Record<string, never>): import("../toast.js").ToastService;
28
+ /** Observe toast messages and disposal-owned service state; ToastRegion owns element creation. */
28
29
  export declare function useToastRegion(required: {
29
30
  service?: ReturnType<typeof createToastService>;
30
31
  attrs?: ToastProps['attrs'];
@@ -34,6 +35,7 @@ export declare function useToastRegion(required: {
34
35
  id?: string;
35
36
  'data-jini-part': string;
36
37
  };
37
- children: import("react").FunctionComponentElement<ToastProps>[];
38
+ messages: readonly import("../toast.js").ToastMessage[];
39
+ service: import("../toast.js").ToastService;
38
40
  };
39
41
  //# sourceMappingURL=Kit.hooks.d.ts.map
@@ -1,17 +1,16 @@
1
- import { useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, createElement } from 'react';
1
+ import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
- import { KitContext } from './context.js';
3
+ import { useKitContext } from './context.js';
4
4
  import { createKit } from './kit.js';
5
5
  import { assertKitContract, KitConfigError } from '../needs.js';
6
- import { kitSpec } from '../kit.spec.js';
6
+ import { kitSpec } from '../kit-contract.js';
7
7
  import { createToastService } from '../toast.js';
8
- import { Toast } from './facade.js';
9
8
  let defaultValue;
10
9
  function getDefault() {
11
10
  return defaultValue ??= { kit: createKit({}), agent: undefined, overlayContainer: null, toast: undefined, cancelLabel: 'Cancel', guard: 'fallback' };
12
11
  }
13
12
  export function useKit(_required = {}, _optional = {}) {
14
- return useContext(KitContext) ?? getDefault();
13
+ return useKitContext({}) ?? getDefault();
15
14
  }
16
15
  export function useKitProvider(required, _optional = {}) {
17
16
  const fallback = useMemo(() => createKit({}), []);
@@ -74,14 +73,13 @@ export function useToast(_required = {}, _optional = {}) {
74
73
  throw new KitConfigError({ issues: ['useToast requires a KitProvider or an injected toast service'] });
75
74
  return service;
76
75
  }
76
+ /** Observe toast messages and disposal-owned service state; ToastRegion owns element creation. */
77
77
  export function useToastRegion(required, _optional = {}) {
78
78
  const context = useKit({});
79
79
  const service = required.service ?? context.toast;
80
80
  if (!service)
81
81
  throw new KitConfigError({ issues: ['ToastRegion requires a KitProvider or a service'] });
82
82
  const messages = useSyncExternalStore(listener => service.subscribe({ listener }), () => service.snapshot({}), () => service.snapshot({}));
83
- const children = messages.map(message => createElement(Toast, { key: message.id, message: message.message,
84
- ...(message.tone ? { tone: message.tone } : {}), onDismiss: () => service.dismiss({ id: message.id }) }));
85
- return { container: useOverlayContainer({}), attrs: { 'data-jini-part': 'kit.toast-region', ...required.attrs }, children };
83
+ return { container: useOverlayContainer({}), attrs: { 'data-jini-part': 'kit.toast-region', ...required.attrs }, messages, service };
86
84
  }
87
85
  //# sourceMappingURL=Kit.hooks.js.map
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Toast } from './facade.js';
2
3
  import { KitContext } from './context.js';
3
4
  import { useKitProvider, useToastRegion, renderOverlay } from './Kit.hooks.js';
4
5
  export function KitProvider(props, _optional = {}) {
@@ -10,6 +11,6 @@ export function Overlay(props, _optional = {}) {
10
11
  }
11
12
  export function ToastRegion(props, _optional = {}) {
12
13
  const vm = useToastRegion(props);
13
- return _jsx(Overlay, { container: vm.container, children: _jsx("div", { ...vm.attrs, "aria-live": "polite", "aria-relevant": "additions removals", children: vm.children }) });
14
+ return _jsx(Overlay, { container: vm.container, children: _jsx("div", { ...vm.attrs, "aria-live": "polite", "aria-relevant": "additions removals", children: vm.messages.map(message => _jsx(Toast, { message: message.message, ...(message.tone ? { tone: message.tone } : {}), onDismiss: () => vm.service.dismiss({ id: message.id }) }, message.id)) }) });
14
15
  }
15
16
  //# sourceMappingURL=KitProvider.js.map
@@ -17,12 +17,15 @@ export function useConfirmController(required, optional = {}) {
17
17
  onCancel: () => latest.current.onCancel({}), onConfirm: () => latest.current.onConfirm({}) }), []);
18
18
  const pending = !!required.pending || executing;
19
19
  const plan = planConfirm({ ...required, cancelLabel: required.cancelLabel ?? context.cancelLabel, pending });
20
- function actionAttrs(part, spec, label) {
20
+ const actionState = pending ? 'pending' : 'ready';
21
+ function actionAttrs(input, _optional = {}) {
22
+ const { part, spec } = input;
23
+ const label = input.label ?? spec?.label ?? input.fallbackLabel;
21
24
  const agent = optional.agent ?? context.agent;
22
25
  const attrs = spec && agent ? agent({ handle: spec.handle }, { role: spec.role, label: spec.label }) : {};
23
26
  return Object.freeze({ ...attrs, 'data-jini-part': part, 'aria-label': label,
24
27
  ...(required.actionAccessibleNames && plan.consequence ? { 'aria-describedby': consequenceId } : {}),
25
- 'aria-busy': pending, 'data-jini-state': pending ? 'pending' : 'ready' });
28
+ 'aria-busy': pending, 'data-jini-state': actionState });
26
29
  }
27
30
  async function onPress() {
28
31
  if (model.isExecuting({}) || !latest.current.open || latest.current.pending)
@@ -47,10 +50,10 @@ export function useConfirmController(required, optional = {}) {
47
50
  'aria-labelledby': titleId, 'aria-modal': true, 'aria-busy': pending }),
48
51
  titleAttrs: { id: titleId, 'data-jini-part': 'confirm.title' },
49
52
  consequenceAttrs: { id: consequenceId, 'data-jini-part': 'confirm.consequence' },
50
- cancel: Object.freeze({ attrs: actionAttrs('confirm.cancel', plan.cancel, required.actionAccessibleNames?.cancel ?? plan.cancel?.label ?? `${plan.cancelLabel} — no action taken`),
53
+ cancel: Object.freeze({ attrs: actionAttrs({ part: 'confirm.cancel', spec: plan.cancel, label: required.actionAccessibleNames?.cancel, fallbackLabel: `${plan.cancelLabel} — no action taken` }),
51
54
  ref: cancelRef, disabled: pending, variant: 'secondary', children: plan.cancelLabel,
52
55
  onPress: () => { model.requestDismiss({ reason: 'cancel-button' }); } }),
53
- confirm: Object.freeze({ attrs: actionAttrs('confirm.confirm', plan.confirm, required.actionAccessibleNames?.confirm ?? plan.confirmLabel), disabled: pending, pending,
56
+ confirm: Object.freeze({ attrs: actionAttrs({ part: 'confirm.confirm', spec: plan.confirm, label: required.actionAccessibleNames?.confirm, fallbackLabel: plan.confirmLabel }), disabled: pending, pending,
54
57
  variant: plan.confirmVariant, children: required.confirmLabel, onPress }),
55
58
  boundaryRef, overlayContainer: useOverlayContainer({}), agentMayConfirm: required.agentMayConfirm !== false,
56
59
  requestDismiss: model.requestDismiss,
@@ -6,49 +6,73 @@ function visible(element) {
6
6
  }
7
7
  return true;
8
8
  }
9
- /** Checks real mounted DOM. jsdom cannot prove layout or browser top-layer behavior; those are browser scenarios. */
10
- export function inspectConfirm(required, _optional = {}) {
11
- const c = required.controller, issues = [];
12
- const frame = c.frameAttrs.id ? document.getElementById(c.frameAttrs.id) : null;
13
- if (!frame)
14
- return ['missing confirm frame'];
9
+ function inspectFrame({ controller: c, frame }, _optional = {}) {
10
+ const issues = [];
15
11
  if (frame.dataset.jiniPart !== 'confirm.dialog' || frame.getAttribute('role') !== 'alertdialog')
16
12
  issues.push('missing alertdialog semantics');
17
13
  if (!c.overlayContainer?.contains(frame))
18
14
  issues.push('frame outside overlay container');
19
15
  if (frame.getAttribute('aria-labelledby') !== c.frameAttrs['aria-labelledby'])
20
16
  issues.push('missing title association');
21
- const cancel = frame.querySelector('[data-jini-part="confirm.cancel"]');
22
- const confirm = frame.querySelector('[data-jini-part="confirm.confirm"]');
23
- for (const [name, element, bag] of [['cancel', cancel, c.cancel], ['confirm', confirm, c.confirm]]) {
24
- if (!element) {
25
- issues.push(`missing ${name} action`);
26
- continue;
27
- }
28
- if (!element.matches('button,input,[role="button"][tabindex]'))
29
- issues.push(`${name} part is not on a focusable action`);
30
- if (!visible(element))
31
- issues.push(`${name} action hidden`);
32
- for (const [key, value] of Object.entries(bag.attrs ?? {})) {
33
- if (value !== undefined && element.getAttribute(key) !== String(value))
34
- issues.push(`${name} did not forward ${key}`);
35
- }
36
- if (c.pending && !element.matches(':disabled') && element.getAttribute('aria-disabled') !== 'true')
37
- issues.push(`${name} enabled while pending`);
17
+ return issues;
18
+ }
19
+ function inspectForwardedAttrs({ name, element, attrs }, _optional = {}) {
20
+ const issues = [];
21
+ for (const [key, value] of Object.entries(attrs ?? {})) {
22
+ if (value !== undefined && element.getAttribute(key) !== String(value))
23
+ issues.push(`${name} did not forward ${key}`);
38
24
  }
39
- if (!c.pending && required.checkFocus && cancel && document.activeElement !== cancel)
25
+ return issues;
26
+ }
27
+ function inspectAction({ name, element, bag, pending }, _optional = {}) {
28
+ if (!element)
29
+ return [`missing ${name} action`];
30
+ const issues = [];
31
+ if (!element.matches('button,input,[role="button"][tabindex]'))
32
+ issues.push(`${name} part is not on a focusable action`);
33
+ if (!visible(element))
34
+ issues.push(`${name} action hidden`);
35
+ issues.push(...inspectForwardedAttrs({ name, element, attrs: bag.attrs }));
36
+ if (pending && !element.matches(':disabled') && element.getAttribute('aria-disabled') !== 'true')
37
+ issues.push(`${name} enabled while pending`);
38
+ return issues;
39
+ }
40
+ function inspectInteraction({ controller: c, checkFocus, cancel, confirm, activeElement }, _optional = {}) {
41
+ const issues = [];
42
+ if (!c.pending && checkFocus && cancel && activeElement !== cancel)
40
43
  issues.push('initial focus is not cancel');
41
44
  if (!c.agentMayConfirm && confirm && [confirm, ...confirm.querySelectorAll('*')].some(element => element.getAttributeNames().some(name => name.startsWith('data-agent-'))))
42
45
  issues.push('human-only confirm published agent metadata');
43
- if (c.consequence && confirm && !confirm.getAttribute('aria-label')?.includes(c.consequence)) {
44
- // A concise action name still has to expose the real, visible consequence as its
45
- // description. An override dropping the associated paragraph must fail the guard.
46
- const description = c.consequenceAttrs?.id ? document.getElementById(c.consequenceAttrs.id) : null;
47
- const describedIds = confirm.getAttribute('aria-describedby')?.split(/\s+/) ?? [];
48
- if (!description || !frame.contains(description) || !visible(description) ||
49
- !describedIds.includes(description.id) || !description.textContent?.includes(c.consequence))
50
- issues.push('missing consequence label');
51
- }
46
+ return issues;
47
+ }
48
+ function inspectDescription({ description, frame, confirm, consequence }, _optional = {}) {
49
+ const describedIds = confirm.getAttribute('aria-describedby')?.split(/\s+/) ?? [];
50
+ if (!description || !frame.contains(description) || !visible(description) ||
51
+ !describedIds.includes(description.id) || !description.textContent?.includes(consequence))
52
+ return ['missing consequence label'];
53
+ return [];
54
+ }
55
+ function inspectConsequence({ controller: c, frame, confirm, document: dom }, _optional = {}) {
56
+ if (!c.consequence || !confirm || confirm.getAttribute('aria-label')?.includes(c.consequence))
57
+ return [];
58
+ // A concise action name still has to expose the real, visible consequence as its
59
+ // description. An override dropping the associated paragraph must fail the guard.
60
+ const description = c.consequenceAttrs?.id ? dom.getElementById(c.consequenceAttrs.id) : null;
61
+ return inspectDescription({ description, frame, confirm, consequence: c.consequence });
62
+ }
63
+ /** Checks real mounted DOM. jsdom cannot prove layout or browser top-layer behavior; those are browser scenarios. */
64
+ export function inspectConfirm(required, _optional = {}) {
65
+ const c = required.controller;
66
+ const frame = c.frameAttrs.id ? document.getElementById(c.frameAttrs.id) : null;
67
+ if (!frame)
68
+ return ['missing confirm frame'];
69
+ const issues = inspectFrame({ controller: c, frame });
70
+ const cancel = frame.querySelector('[data-jini-part="confirm.cancel"]');
71
+ const confirm = frame.querySelector('[data-jini-part="confirm.confirm"]');
72
+ issues.push(...inspectAction({ name: 'cancel', element: cancel, bag: c.cancel, pending: c.pending }));
73
+ issues.push(...inspectAction({ name: 'confirm', element: confirm, bag: c.confirm, pending: c.pending }));
74
+ issues.push(...inspectInteraction({ controller: c, checkFocus: required.checkFocus, cancel, confirm, activeElement: document.activeElement }));
75
+ issues.push(...inspectConsequence({ controller: c, frame, confirm, document }));
52
76
  return issues;
53
77
  }
54
78
  //# sourceMappingURL=guard.js.map
@@ -6,4 +6,8 @@ export declare function getSharedKitContext(required: {
6
6
  version: string;
7
7
  }, _optional?: Record<string, never>): Context<KitContextValue | null>;
8
8
  export declare const KitContext: Context<KitContextValue | null>;
9
+ /** Context-only lookup for native views: no default-kit assembly or facade dependency.
10
+ * @returns The nearest provider binding, or null so a native view can use its own defaults.
11
+ */
12
+ export declare function useKitContext(_required?: Record<string, never>, _optional?: Record<string, never>): KitContextValue | null;
9
13
  //# sourceMappingURL=context.d.ts.map
@@ -1,4 +1,4 @@
1
- import { createContext } from 'react';
1
+ import { createContext, useContext } from 'react';
2
2
  const CONTEXT_KEY = Symbol.for('@jini-ai/ui-kit/react/context@1');
3
3
  export function isDevelopment(_required, _optional = {}) {
4
4
  // Keep the standard expression intact for bundlers to replace. A typeof-process shortcut would
@@ -21,4 +21,10 @@ export function getSharedKitContext(required, _optional = {}) {
21
21
  return slot.context;
22
22
  }
23
23
  export const KitContext = getSharedKitContext({ version: '0.1.0' });
24
+ /** Context-only lookup for native views: no default-kit assembly or facade dependency.
25
+ * @returns The nearest provider binding, or null so a native view can use its own defaults.
26
+ */
27
+ export function useKitContext(_required = {}, _optional = {}) {
28
+ return useContext(KitContext);
29
+ }
24
30
  //# sourceMappingURL=context.js.map
@@ -1,4 +1,4 @@
1
- import { type ImplementedComponentName } from '../kit.spec.js';
1
+ import { type ImplementedComponentName } from '../kit-contract.js';
2
2
  import type { GuardMode, ReactKit, ResolvedKit } from './types.js';
3
3
  type KitOptions = {
4
4
  id?: string;
package/dist/react/kit.js CHANGED
@@ -1,4 +1,4 @@
1
- import { KIT_CONTRACT, implementedComponents, kitSpec } from '../kit.spec.js';
1
+ import { KIT_CONTRACT, implementedComponents, kitSpec } from '../kit-contract.js';
2
2
  import { assertKitContract, KitConfigError } from '../needs.js';
3
3
  import { nativeKit } from './native/index.js';
4
4
  export function createKit(required, optional = {}) {
@@ -1,5 +1,5 @@
1
1
  import type { MouseEvent, SyntheticEvent } from 'react';
2
- import type { DialogProps, ConfirmViewProps } from '../types.js';
2
+ import type { DialogProps, ConfirmViewProps, ReactKit } from '../types.js';
3
3
  /** Native modal lifecycle shared by kit views and compatibility adapters; DOM effects are O(1).
4
4
  * The document port preserves hosts' focus injection seam without introducing another lifecycle.
5
5
  * @example useNativeDialog({ open, title, onClose }, { initialFocus: () => cancelRef.current?.focus() })
@@ -33,9 +33,17 @@ export declare function useNativeDialog(required: DialogProps, optional?: {
33
33
  'aria-labelledby': string;
34
34
  };
35
35
  };
36
- export declare function useNativeConfirm(required: ConfirmViewProps, _optional?: Record<string, never>): {
36
+ /** Native confirmation lifecycle with the view-selected Button. A failed conformance guard
37
+ * forces native actions even when a provider supplied a custom Button.
38
+ * @param required The shared confirmation controller.
39
+ * @param options Button selected by the native view's context-only lookup.
40
+ * @returns Dialog bindings and the action component that preserves the safety override.
41
+ */
42
+ export declare function useNativeConfirm(required: ConfirmViewProps, { Button }?: {
43
+ Button?: ReactKit['Button'];
44
+ }): {
37
45
  controller: import("../types.js").ConfirmController;
38
- Button: import("react").ForwardRefExoticComponent<Omit<import("../types.js").ButtonProps, "ref"> & import("react").RefAttributes<HTMLButtonElement>>;
46
+ Button: import("react").ForwardRefExoticComponent<Omit<import("../types.js").ButtonProps, "ref"> & import("react").RefAttributes<HTMLButtonElement>> | import("react").ComponentType<import("../types.js").ButtonProps>;
39
47
  props: {
40
48
  className: string;
41
49
  role: string;
@@ -1,5 +1,4 @@
1
1
  import { useId, useImperativeHandle, useLayoutEffect, useRef } from 'react';
2
- import { Button } from '../facade.js';
3
2
  import { NativeButton } from './Controls.js';
4
3
  /** Native modal lifecycle shared by kit views and compatibility adapters; DOM effects are O(1).
5
4
  * The document port preserves hosts' focus injection seam without introducing another lifecycle.
@@ -13,8 +12,7 @@ export function useNativeDialog(required, optional = {}) {
13
12
  if (!dialog)
14
13
  return;
15
14
  if (!required.open) {
16
- if (opener.current instanceof HTMLElement && opener.current.isConnected)
17
- opener.current.focus({ preventScroll: true });
15
+ restoreDialogFocus({ opener: opener.current });
18
16
  return;
19
17
  }
20
18
  opener.current = (optional.document ?? document).activeElement;
@@ -36,8 +34,7 @@ export function useNativeDialog(required, optional = {}) {
36
34
  }
37
35
  else
38
36
  dialog.removeAttribute('open');
39
- if (opener.current instanceof HTMLElement && opener.current.isConnected)
40
- opener.current.focus({ preventScroll: true });
37
+ restoreDialogFocus({ opener: opener.current });
41
38
  };
42
39
  // Open is the transition boundary: editing content must not recapture the original trigger.
43
40
  }, [required.open]);
@@ -55,6 +52,10 @@ export function useNativeDialog(required, optional = {}) {
55
52
  onClick: (event) => { if (event.target === event.currentTarget && !required.pending && isOutsideBox(event))
56
53
  required.onClose({}); } } };
57
54
  }
55
+ function restoreDialogFocus({ opener }, _optional = {}) {
56
+ if (opener instanceof HTMLElement && opener.isConnected)
57
+ opener.focus({ preventScroll: true });
58
+ }
58
59
  /** True when the pointer is outside the dialog's own rectangle, i.e. on the ::backdrop. A click with no
59
60
  * coordinates (keyboard activation, synthetic test clicks) keeps the old target-only behaviour. */
60
61
  function isOutsideBox(event) {
@@ -64,7 +65,13 @@ function isOutsideBox(event) {
64
65
  const r = event.currentTarget.getBoundingClientRect();
65
66
  return x < r.left || x > r.right || y < r.top || y > r.bottom;
66
67
  }
67
- export function useNativeConfirm(required, _optional = {}) {
68
+ /** Native confirmation lifecycle with the view-selected Button. A failed conformance guard
69
+ * forces native actions even when a provider supplied a custom Button.
70
+ * @param required The shared confirmation controller.
71
+ * @param options Button selected by the native view's context-only lookup.
72
+ * @returns Dialog bindings and the action component that preserves the safety override.
73
+ */
74
+ export function useNativeConfirm(required, { Button = NativeButton } = {}) {
68
75
  const c = required.controller;
69
76
  const vm = useNativeDialog({ open: c.open, title: c.title, pending: c.pending, onClose: () => c.requestDismiss({ reason: 'close' }) }, { initialFocus: () => c.focusInitial({}) });
70
77
  return { ...vm, controller: c, Button: c.nativeActions ? NativeButton : Button,
@@ -1,13 +1,16 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { forwardRef } from 'react';
3
3
  import { useNativeDialog, useNativeConfirm } from './Dialog.hooks.js';
4
- import { Button } from '../facade.js';
4
+ import { useKitContext } from '../context.js';
5
+ import { NativeButton } from './Controls.js';
5
6
  export const NativeDialog = forwardRef(function NativeDialog(props, ref) {
7
+ const Button = useKitContext({})?.kit.components.Button ?? NativeButton;
6
8
  const vm = useNativeDialog({ ...props, ref });
7
9
  return _jsxs("dialog", { ...vm.props, children: [_jsx("h2", { className: "jini-dialog-title", id: vm.titleId, "data-jini-part": "kit.dialog-title", children: vm.title }), vm.children, _jsx(Button, { ...vm.closeProps, children: vm.closeLabel })] });
8
10
  });
9
11
  export function NativeConfirmDialog(props, _optional = {}) {
10
- const vm = useNativeConfirm(props);
12
+ const Button = useKitContext({})?.kit.components.Button ?? NativeButton;
13
+ const vm = useNativeConfirm(props, { Button });
11
14
  return _jsxs("dialog", { ...vm.props, children: [_jsx("h2", { className: "jini-confirm-dialog-title", ...vm.controller.titleAttrs, children: vm.controller.title }), _jsxs("div", { className: "jini-confirm-dialog-body", "data-jini-part": "confirm.body", children: [vm.controller.body, _jsx("p", { className: "jini-confirm-dialog-consequence", ...vm.controller.consequenceAttrs, children: vm.controller.consequence }), vm.controller.error] }), _jsxs("div", { className: "jini-confirm-dialog-actions", "data-jini-part": "confirm.actions", children: [_jsx(vm.Button, { ...vm.controller.cancel }), _jsx(vm.Button, { ...vm.controller.confirm })] })] });
12
15
  }
13
16
  //# sourceMappingURL=Dialog.js.map
@@ -1,13 +1,21 @@
1
1
  import { createElement, useId, useRef, useState, useEffect, useLayoutEffect } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
+ function navigationIndex(required, _optional = {}) {
4
+ switch (required.key) {
5
+ case 'Home': return 0;
6
+ case 'End': return required.count - 1;
7
+ case required.forward: return (required.current + 1) % required.count;
8
+ case required.backward: return (required.current - 1 + required.count) % required.count;
9
+ default: return undefined;
10
+ }
11
+ }
3
12
  export function useNativeTabs(required, _optional = {}) {
4
13
  const prefix = useId(), root = useRef(null);
5
14
  const enabled = required.items.filter(item => !item.disabled);
6
15
  const active = enabled.find(item => item.id === required.value) ?? enabled[0];
7
16
  function onKeyDown(event) {
8
17
  const index = enabled.findIndex(item => item.id === event.currentTarget.dataset.tabId);
9
- const next = event.key === 'Home' ? 0 : event.key === 'End' ? enabled.length - 1 :
10
- event.key === 'ArrowRight' ? (index + 1) % enabled.length : event.key === 'ArrowLeft' ? (index - 1 + enabled.length) % enabled.length : undefined;
18
+ const next = navigationIndex({ key: event.key, current: index, count: enabled.length, forward: 'ArrowRight', backward: 'ArrowLeft' });
11
19
  if (next === undefined || !enabled[next])
12
20
  return;
13
21
  event.preventDefault();
@@ -69,8 +77,7 @@ export function useNativeMenu(required, _optional = {}) {
69
77
  setOpen(false);
70
78
  return;
71
79
  }
72
- const next = event.key === 'Home' ? 0 : event.key === 'End' ? enabled.length - 1 :
73
- event.key === 'ArrowDown' ? (current + 1) % enabled.length : event.key === 'ArrowUp' ? (current - 1 + enabled.length) % enabled.length : undefined;
80
+ const next = navigationIndex({ key: event.key, current, count: enabled.length, forward: 'ArrowDown', backward: 'ArrowUp' });
74
81
  if (next !== undefined) {
75
82
  event.preventDefault();
76
83
  focus(next);
@@ -1,6 +1,6 @@
1
1
  import type { AgentAttrsPort } from '../../attrs.js';
2
2
  import type { KitScenario } from '../../testing/scenarios.js';
3
- import type { ImplementedComponentName } from '../../kit.spec.js';
3
+ import type { ImplementedComponentName } from '../../kit-contract.js';
4
4
  import type { ResolvedKit } from '../types.js';
5
5
  export interface KitConformanceResult {
6
6
  readonly id: string;
@@ -28,14 +28,15 @@ function check(expectation, root, calls) {
28
28
  return `missing ${expectation.selector}`;
29
29
  if (expectation.type === 'attribute')
30
30
  return element.getAttribute(expectation.name) === expectation.value ? undefined : `${expectation.selector} did not forward ${expectation.name}=${expectation.value}`;
31
- if (expectation.type === 'focused')
32
- return element === document.activeElement ? undefined : `${expectation.selector} not focused`;
33
- if (expectation.type === 'disabled')
34
- return element.matches(':disabled') || element.getAttribute('aria-disabled') === 'true' ? undefined : `${expectation.selector} not disabled`;
35
- if (expectation.type === 'native-select')
36
- return element instanceof HTMLSelectElement ? undefined : 'Select must render HTMLSelectElement';
37
- if (expectation.type === 'focusable')
38
- return element.matches('button,input,select,textarea,[tabindex]') ? undefined : `${expectation.selector} is not focusable`;
31
+ return checkElementState({ expectation, element });
32
+ }
33
+ function checkElementState({ expectation, element }, _optional) {
34
+ switch (expectation.type) {
35
+ case 'focused': return element === document.activeElement ? undefined : `${expectation.selector} not focused`;
36
+ case 'disabled': return element.matches(':disabled') || element.getAttribute('aria-disabled') === 'true' ? undefined : `${expectation.selector} not disabled`;
37
+ case 'native-select': return element instanceof HTMLSelectElement ? undefined : 'Select must render HTMLSelectElement';
38
+ case 'focusable': return element.matches('button,input,select,textarea,[tabindex]') ? undefined : `${expectation.selector} is not focusable`;
39
+ }
39
40
  }
40
41
  export const reactConformanceDriver = {
41
42
  async run({ kit, scenario, agent }) {
@@ -82,10 +83,14 @@ export const reactConformanceDriver = {
82
83
  return errors;
83
84
  },
84
85
  };
85
- export async function runKitConformance(required, optional = {}) {
86
- const driver = optional.driver ?? reactConformanceDriver;
87
- if (optional.includeBrowser && driver === reactConformanceDriver)
86
+ function resolveConformanceDriver(required, _optional) {
87
+ const driver = required.driver ?? reactConformanceDriver;
88
+ if (required.includeBrowser && driver === reactConformanceDriver)
88
89
  throw new Error('Browser cases require a real browser driver');
90
+ return driver;
91
+ }
92
+ export async function runKitConformance(required, optional = {}) {
93
+ const driver = resolveConformanceDriver(optional);
89
94
  const results = [];
90
95
  for (const scenario of optional.scenarios ?? kitConformanceScenarios) {
91
96
  if (optional.only && !optional.only.includes(scenario.component))
@@ -1,7 +1,7 @@
1
1
  import type { ComponentType, ReactNode, Ref, RefObject, HTMLAttributes } from 'react';
2
2
  import type { KitAttrs, AgentAttrsPort } from '../attrs.js';
3
3
  import type { ConfirmInput, DismissReason } from '../confirm.js';
4
- import type { ImplementedComponentName } from '../kit.spec.js';
4
+ import type { ImplementedComponentName } from '../kit-contract.js';
5
5
  import type { KitNeeds } from '../needs.js';
6
6
  import type { ToastService } from '../toast.js';
7
7
  export interface BaseProps {
@@ -1,4 +1,4 @@
1
- import type { ImplementedComponentName } from '../kit.spec.js';
1
+ import type { ImplementedComponentName } from '../kit-contract.js';
2
2
  export type ConformanceAction = {
3
3
  readonly type: 'click' | 'cancel' | 'focus' | 'key';
4
4
  readonly selector: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/ui-kit",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Framework-free UI contract, injected React facades and native defaults.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {