@jini-ai/ui-kit 0.1.4 → 0.6.10

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,11 @@
1
1
  # @jini-ai/ui-kit
2
2
 
3
+ ## 0.6.10
4
+
5
+ ## 0.1.5 — 2026-10-22
6
+
7
+ - 2026-10-22: Complexity refactors, coverage.
8
+
3
9
  ## 0.1.4 — 2026-10-13
4
10
 
5
11
  - 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.
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;
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
3
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
8
  let defaultValue;
9
9
  function getDefault() {
@@ -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
@@ -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 = {}) {
@@ -12,8 +12,7 @@ export function useNativeDialog(required, optional = {}) {
12
12
  if (!dialog)
13
13
  return;
14
14
  if (!required.open) {
15
- if (opener.current instanceof HTMLElement && opener.current.isConnected)
16
- opener.current.focus({ preventScroll: true });
15
+ restoreDialogFocus({ opener: opener.current });
17
16
  return;
18
17
  }
19
18
  opener.current = (optional.document ?? document).activeElement;
@@ -35,8 +34,7 @@ export function useNativeDialog(required, optional = {}) {
35
34
  }
36
35
  else
37
36
  dialog.removeAttribute('open');
38
- if (opener.current instanceof HTMLElement && opener.current.isConnected)
39
- opener.current.focus({ preventScroll: true });
37
+ restoreDialogFocus({ opener: opener.current });
40
38
  };
41
39
  // Open is the transition boundary: editing content must not recapture the original trigger.
42
40
  }, [required.open]);
@@ -54,6 +52,10 @@ export function useNativeDialog(required, optional = {}) {
54
52
  onClick: (event) => { if (event.target === event.currentTarget && !required.pending && isOutsideBox(event))
55
53
  required.onClose({}); } } };
56
54
  }
55
+ function restoreDialogFocus({ opener }, _optional = {}) {
56
+ if (opener instanceof HTMLElement && opener.isConnected)
57
+ opener.focus({ preventScroll: true });
58
+ }
57
59
  /** True when the pointer is outside the dialog's own rectangle, i.e. on the ::backdrop. A click with no
58
60
  * coordinates (keyboard activation, synthetic test clicks) keeps the old target-only behaviour. */
59
61
  function isOutsideBox(event) {
@@ -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.4",
3
+ "version": "0.6.10",
4
4
  "description": "Framework-free UI contract, injected React facades and native defaults.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {