@ruakit/runtime 0.0.0-stage → 0.1.0-alpha.1
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/LICENSE +21 -0
- package/NOTICE +4 -0
- package/dist/action-feedback.d.ts +4 -0
- package/dist/action-feedback.d.ts.map +1 -0
- package/dist/action-feedback.js +3 -0
- package/dist/action-feedback.js.map +7 -0
- package/dist/dom.d.ts +17 -0
- package/dist/dom.d.ts.map +1 -0
- package/dist/dom.js +3 -0
- package/dist/dom.js.map +7 -0
- package/dist/errors.d.ts +6 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +3 -0
- package/dist/errors.js.map +7 -0
- package/dist/experimental/index.d.ts +24 -0
- package/dist/experimental/index.d.ts.map +1 -0
- package/dist/experimental/index.js +3 -0
- package/dist/experimental/index.js.map +7 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +7 -0
- package/dist/operations.d.ts +5 -0
- package/dist/operations.d.ts.map +1 -0
- package/dist/operations.js +3 -0
- package/dist/operations.js.map +7 -0
- package/dist/runtime.d.ts +4 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +3 -0
- package/dist/runtime.js.map +7 -0
- package/dist/screenshot.d.ts +3 -0
- package/dist/screenshot.d.ts.map +1 -0
- package/dist/screenshot.js +5 -0
- package/dist/screenshot.js.map +7 -0
- package/dist/targets.d.ts +15 -0
- package/dist/targets.d.ts.map +1 -0
- package/dist/targets.js +3 -0
- package/dist/targets.js.map +7 -0
- package/dist/types.d.ts +121 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +7 -0
- package/package.json +37 -3
- package/src/action-feedback.ts +85 -0
- package/src/dom.ts +307 -0
- package/src/errors.ts +14 -0
- package/src/experimental/index.ts +192 -0
- package/src/index.ts +21 -0
- package/src/operations.ts +67 -0
- package/src/runtime.ts +541 -0
- package/src/screenshot.ts +172 -0
- package/src/targets.ts +141 -0
- package/src/types.ts +135 -0
- package/README.md +0 -3
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** A reference returned by getState. Keep both fields when passing it across a boundary. */
|
|
2
|
+
export interface Target {
|
|
3
|
+
id: string;
|
|
4
|
+
version: number;
|
|
5
|
+
}
|
|
6
|
+
/** A named enclosing form, fieldset, section, ARIA group or dialog. Outer groups come first. */
|
|
7
|
+
export interface ControlGroup {
|
|
8
|
+
role: 'form' | 'group' | 'radiogroup' | 'region' | 'dialog' | 'alertdialog';
|
|
9
|
+
name: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ControlState {
|
|
12
|
+
target: Target;
|
|
13
|
+
tag: string;
|
|
14
|
+
type?: string;
|
|
15
|
+
role: string;
|
|
16
|
+
/** Computed accessible name. Empty when the control has no accessible name. */
|
|
17
|
+
label: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
/** A field hint, reported separately from the accessible name. */
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
/** Up to four nearest named groups, ordered from outermost to innermost. */
|
|
22
|
+
groups?: ControlGroup[];
|
|
23
|
+
/** A group name or enclosing group was omitted because of observation limits. */
|
|
24
|
+
groupsTruncated?: boolean;
|
|
25
|
+
context: string;
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
readOnly: boolean;
|
|
28
|
+
value?: string;
|
|
29
|
+
checked?: boolean;
|
|
30
|
+
expanded?: boolean;
|
|
31
|
+
selectedValues?: string[];
|
|
32
|
+
options?: {
|
|
33
|
+
value: string;
|
|
34
|
+
label: string;
|
|
35
|
+
enabled: boolean;
|
|
36
|
+
}[];
|
|
37
|
+
valid?: boolean;
|
|
38
|
+
contentEditable?: boolean;
|
|
39
|
+
}
|
|
40
|
+
export interface PageState {
|
|
41
|
+
url: string;
|
|
42
|
+
title: string;
|
|
43
|
+
text: string;
|
|
44
|
+
controls: ControlState[];
|
|
45
|
+
focused: Target | null;
|
|
46
|
+
/** Text or controls were omitted because the observation limit was reached. */
|
|
47
|
+
truncated: boolean;
|
|
48
|
+
}
|
|
49
|
+
export interface ActionOptions {
|
|
50
|
+
signal?: AbortSignal;
|
|
51
|
+
timeoutMs?: number;
|
|
52
|
+
}
|
|
53
|
+
export interface KeyOptions extends ActionOptions {
|
|
54
|
+
altKey?: boolean;
|
|
55
|
+
ctrlKey?: boolean;
|
|
56
|
+
metaKey?: boolean;
|
|
57
|
+
shiftKey?: boolean;
|
|
58
|
+
}
|
|
59
|
+
export type RuntimeAction = 'click' | 'focus' | 'blur' | 'fill' | 'select' | 'check' | 'insertText' | 'key' | 'scroll';
|
|
60
|
+
export interface ActionRect {
|
|
61
|
+
readonly left: number;
|
|
62
|
+
readonly top: number;
|
|
63
|
+
readonly width: number;
|
|
64
|
+
readonly height: number;
|
|
65
|
+
}
|
|
66
|
+
/** Local UI feedback only. DOM references never enter planner requests or receipts. */
|
|
67
|
+
export interface RuntimeActionEvent {
|
|
68
|
+
readonly id: number;
|
|
69
|
+
readonly action: RuntimeAction;
|
|
70
|
+
readonly phase: 'before' | 'after' | 'cancelled' | 'failed';
|
|
71
|
+
readonly element: HTMLElement;
|
|
72
|
+
readonly rect: ActionRect;
|
|
73
|
+
readonly durationMs: number;
|
|
74
|
+
}
|
|
75
|
+
export interface ActionFeedbackOptions {
|
|
76
|
+
/** Observational callback. Exceptions are ignored; targets are revalidated before execution. */
|
|
77
|
+
onAction?: (event: RuntimeActionEvent) => void;
|
|
78
|
+
/** Movement allowance in ms, or a resolver called after scrolling. Integer 0–10000; defaults to 0. */
|
|
79
|
+
actionDelayMs?: number | ((element: HTMLElement) => number);
|
|
80
|
+
/** Scroll feedback targets into view before cursor movement. Defaults to false. */
|
|
81
|
+
scrollToTarget?: boolean;
|
|
82
|
+
}
|
|
83
|
+
export interface RuntimeOptions extends ActionFeedbackOptions {
|
|
84
|
+
/** Defaults to the current document. Use an app container to limit observation and actions. */
|
|
85
|
+
root?: Document | HTMLElement;
|
|
86
|
+
/** Connect your router here. Resolve once navigation has committed. */
|
|
87
|
+
navigate?: (path: string, signal: AbortSignal) => void | Promise<void>;
|
|
88
|
+
/** Default operation deadline, in milliseconds. Defaults to 2000. */
|
|
89
|
+
timeoutMs?: number;
|
|
90
|
+
}
|
|
91
|
+
export interface ScreenshotImage {
|
|
92
|
+
/** Raw base64 image bytes, without a data URL prefix. Maximum decoded size: 5 MiB. */
|
|
93
|
+
data: string;
|
|
94
|
+
mimeType: 'image/png' | 'image/jpeg' | 'image/webp';
|
|
95
|
+
width: number;
|
|
96
|
+
height: number;
|
|
97
|
+
}
|
|
98
|
+
export interface Screenshot extends ScreenshotImage {
|
|
99
|
+
capturedAt: number;
|
|
100
|
+
url: string;
|
|
101
|
+
}
|
|
102
|
+
/** Each action returns a fresh observation. It does not establish that a larger task succeeded. */
|
|
103
|
+
export interface Runtime {
|
|
104
|
+
getState(): PageState;
|
|
105
|
+
/** Render the configured root to PNG on demand using html-to-image. Private UI is excluded. */
|
|
106
|
+
screenshot(options?: ActionOptions): Promise<Screenshot>;
|
|
107
|
+
click(target: Target, options?: ActionOptions): Promise<PageState>;
|
|
108
|
+
focus(target: Target, options?: ActionOptions): Promise<PageState>;
|
|
109
|
+
blur(target: Target, options?: ActionOptions): Promise<PageState>;
|
|
110
|
+
/** Insert text at the current selection using the browser editing command. */
|
|
111
|
+
insertText(target: Target, text: string, options?: ActionOptions): Promise<PageState>;
|
|
112
|
+
setValue(target: Target, value: string, options?: ActionOptions): Promise<PageState>;
|
|
113
|
+
selectOption(target: Target, values: readonly string[], options?: ActionOptions): Promise<PageState>;
|
|
114
|
+
setChecked(target: Target, checked: boolean, options?: ActionOptions): Promise<PageState>;
|
|
115
|
+
/** Dispatch keydown/keyup to a control. Browser shortcuts and text insertion are not simulated. */
|
|
116
|
+
pressKey(target: Target, key: string, options?: KeyOptions): Promise<PageState>;
|
|
117
|
+
scrollIntoView(target: Target, options?: ActionOptions): Promise<PageState>;
|
|
118
|
+
goto(path: string, options?: ActionOptions): Promise<PageState>;
|
|
119
|
+
waitFor(condition: (state: PageState) => boolean, options?: ActionOptions): Promise<PageState>;
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/dist/types.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ruakit/runtime",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
4
|
+
"description": "Observe and interact with the rendered application",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"src",
|
|
10
|
+
"README.md",
|
|
11
|
+
"LICENSE",
|
|
12
|
+
"NOTICE"
|
|
13
|
+
],
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"import": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./experimental": {
|
|
20
|
+
"types": "./dist/experimental/index.d.ts",
|
|
21
|
+
"import": "./dist/experimental/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/react": "19.3.0",
|
|
27
|
+
"@types/react-dom": "19.3.0",
|
|
28
|
+
"react": "18.3.1",
|
|
29
|
+
"react-dom": "18.3.1"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"dom-accessibility-api": "0.7.1",
|
|
33
|
+
"html-to-image": "1.11.13"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc -p tsconfig.json && node ../shared/scripts/minify.mjs",
|
|
37
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
38
|
+
"test": "vitest run"
|
|
39
|
+
}
|
|
6
40
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Copyright 2026 RuaKit contributors. SPDX-License-Identifier: MIT
|
|
2
|
+
import { pause } from './operations.js';
|
|
3
|
+
import type {
|
|
4
|
+
ActionFeedbackOptions,
|
|
5
|
+
ActionRect,
|
|
6
|
+
RuntimeAction,
|
|
7
|
+
RuntimeActionEvent,
|
|
8
|
+
} from './types.js';
|
|
9
|
+
|
|
10
|
+
let nextAction = 0;
|
|
11
|
+
|
|
12
|
+
/** Present one validated local action. The resolver must reject stale or unavailable targets. */
|
|
13
|
+
export async function withActionFeedback<T>(
|
|
14
|
+
options: ActionFeedbackOptions,
|
|
15
|
+
action: RuntimeAction,
|
|
16
|
+
resolve: () => HTMLElement,
|
|
17
|
+
signal: AbortSignal,
|
|
18
|
+
effect: (element: HTMLElement) => T | Promise<T>,
|
|
19
|
+
): Promise<T> {
|
|
20
|
+
signal.throwIfAborted();
|
|
21
|
+
|
|
22
|
+
const delay = options.actionDelayMs ?? 0;
|
|
23
|
+
const validateDelay = (value: number): number => {
|
|
24
|
+
if (!Number.isInteger(value) || value < 0 || value > 10000)
|
|
25
|
+
throw new RangeError('actionDelayMs must resolve to an integer from 0 to 10000 milliseconds.');
|
|
26
|
+
return value;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
let durationMs = typeof delay === 'function' ? 0 : validateDelay(delay);
|
|
30
|
+
let element = resolve();
|
|
31
|
+
|
|
32
|
+
if (!options.onAction) {
|
|
33
|
+
return effect(element);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const id = ++nextAction;
|
|
37
|
+
let rect: ActionRect = { left: 0, top: 0, width: 0, height: 0 };
|
|
38
|
+
|
|
39
|
+
const notify = (phase: RuntimeActionEvent['phase']): void => {
|
|
40
|
+
if (element.isConnected) {
|
|
41
|
+
const bounds = element.getBoundingClientRect();
|
|
42
|
+
rect = { left: bounds.left, top: bounds.top, width: bounds.width, height: bounds.height };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
options.onAction?.({ id, action, phase, element, rect: { ...rect }, durationMs });
|
|
47
|
+
} catch {
|
|
48
|
+
// UI feedback cannot authorize an action or invalidate its result.
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
if (options.scrollToTarget) {
|
|
54
|
+
element.scrollIntoView?.({ block: 'nearest', inline: 'nearest', behavior: 'instant' });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
signal.throwIfAborted();
|
|
58
|
+
element = resolve();
|
|
59
|
+
|
|
60
|
+
if (typeof delay === 'function') {
|
|
61
|
+
durationMs = validateDelay(delay(element));
|
|
62
|
+
signal.throwIfAborted();
|
|
63
|
+
element = resolve();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
notify('before');
|
|
67
|
+
signal.throwIfAborted();
|
|
68
|
+
if (durationMs) {
|
|
69
|
+
await pause(signal, durationMs);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Scrolling, UI callbacks and animation time can all change the application.
|
|
73
|
+
element = resolve();
|
|
74
|
+
signal.throwIfAborted();
|
|
75
|
+
|
|
76
|
+
const result = await effect(element);
|
|
77
|
+
signal.throwIfAborted();
|
|
78
|
+
notify('after');
|
|
79
|
+
|
|
80
|
+
return result;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
notify(signal.aborted ? 'cancelled' : 'failed');
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/dom.ts
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// Copyright 2026 RuaKit contributors. SPDX-License-Identifier: MIT
|
|
2
|
+
import {
|
|
3
|
+
computeAccessibleDescription,
|
|
4
|
+
computeAccessibleName,
|
|
5
|
+
getRole,
|
|
6
|
+
} from 'dom-accessibility-api';
|
|
7
|
+
import type { ControlGroup, ControlState } from './types.js';
|
|
8
|
+
|
|
9
|
+
export type NativeField = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
|
|
10
|
+
export type ControlData = Omit<ControlState, 'target'>;
|
|
11
|
+
|
|
12
|
+
export const CONTROL_SELECTOR =
|
|
13
|
+
'button, a[href], input:not([type="hidden"]):not([type="password"]):not([type="file"]), textarea, select, [contenteditable="true"], [contenteditable=""], [role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="textbox"], [role="combobox"], [tabindex]';
|
|
14
|
+
const excluded = 'script, style, noscript, template, [data-ruakit-ignore], [data-ruakit-toolbar]';
|
|
15
|
+
const normalize = (value: string): string => value.replace(/\s+/g, ' ').trim();
|
|
16
|
+
|
|
17
|
+
/** Follow open shadow hosts as well as ordinary DOM ancestors. */
|
|
18
|
+
export function composedParent(element: HTMLElement): HTMLElement | null {
|
|
19
|
+
if (element.parentElement) return element.parentElement;
|
|
20
|
+
|
|
21
|
+
const root = element.getRootNode();
|
|
22
|
+
return root instanceof element.ownerDocument.defaultView!.ShadowRoot
|
|
23
|
+
? (root.host as HTMLElement)
|
|
24
|
+
: null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function contains(root: Document | HTMLElement, element: HTMLElement): boolean {
|
|
28
|
+
for (let current: HTMLElement | null = element; current; current = composedParent(current)) {
|
|
29
|
+
if (root.contains(current)) return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function activeElement(document: Document): Element | null {
|
|
36
|
+
let element = document.activeElement;
|
|
37
|
+
while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement;
|
|
38
|
+
return element;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Keep discovery within the configured root, including its open shadow trees. */
|
|
42
|
+
export function* controls(root: Document | HTMLElement | ShadowRoot): Generator<HTMLElement> {
|
|
43
|
+
if (root.nodeType === 1 && (root as HTMLElement).matches(CONTROL_SELECTOR))
|
|
44
|
+
yield root as HTMLElement;
|
|
45
|
+
if (root.nodeType === 1 && (root as HTMLElement).shadowRoot)
|
|
46
|
+
yield* controls((root as HTMLElement).shadowRoot!);
|
|
47
|
+
|
|
48
|
+
for (const child of root.children) {
|
|
49
|
+
if (child.matches(excluded)) continue;
|
|
50
|
+
yield* controls(child as HTMLElement);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isField(element: HTMLElement): element is NativeField {
|
|
55
|
+
return ['INPUT', 'TEXTAREA', 'SELECT'].includes(element.tagName);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isVisible(element: HTMLElement): boolean {
|
|
59
|
+
if (
|
|
60
|
+
!element.isConnected ||
|
|
61
|
+
!element.getClientRects().length ||
|
|
62
|
+
element.closest(`${excluded}, [hidden], [inert], [aria-hidden="true"]`)
|
|
63
|
+
) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const view = element.ownerDocument.defaultView!;
|
|
68
|
+
const current = view.getComputedStyle(element);
|
|
69
|
+
if (current.visibility === 'hidden' || current.visibility === 'collapse') {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (let parent: HTMLElement | null = element; parent; parent = composedParent(parent)) {
|
|
74
|
+
if (parent.matches(`${excluded}, [hidden], [inert], [aria-hidden="true"]`)) return false;
|
|
75
|
+
const style = view.getComputedStyle(parent);
|
|
76
|
+
if (style.display === 'none' || style.opacity === '0') {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Read rendered text without copying hidden content or field values into page prose. */
|
|
85
|
+
export function visibleText(root: Document | HTMLElement, limit: number): string {
|
|
86
|
+
if (
|
|
87
|
+
root.nodeType === 1 &&
|
|
88
|
+
(root as HTMLElement).matches(`${excluded}, input, textarea, select`)
|
|
89
|
+
) {
|
|
90
|
+
return '';
|
|
91
|
+
}
|
|
92
|
+
const document = root.ownerDocument ?? (root as Document);
|
|
93
|
+
const view = document.defaultView!;
|
|
94
|
+
const walker = document.createTreeWalker(
|
|
95
|
+
root,
|
|
96
|
+
view.NodeFilter.SHOW_ELEMENT | view.NodeFilter.SHOW_TEXT,
|
|
97
|
+
{
|
|
98
|
+
acceptNode(node) {
|
|
99
|
+
if (node.nodeType === view.Node.ELEMENT_NODE) {
|
|
100
|
+
const element = node as HTMLElement;
|
|
101
|
+
if (element.matches(`${excluded}, input, textarea, select`)) {
|
|
102
|
+
return view.NodeFilter.FILTER_REJECT;
|
|
103
|
+
}
|
|
104
|
+
// A descendant can override visibility:hidden. Its own text is checked below.
|
|
105
|
+
const visibility = view.getComputedStyle(element).visibility;
|
|
106
|
+
return isVisible(element) || visibility === 'hidden' || visibility === 'collapse'
|
|
107
|
+
? view.NodeFilter.FILTER_SKIP
|
|
108
|
+
: view.NodeFilter.FILTER_REJECT;
|
|
109
|
+
}
|
|
110
|
+
return node.parentElement && isVisible(node.parentElement)
|
|
111
|
+
? view.NodeFilter.FILTER_ACCEPT
|
|
112
|
+
: view.NodeFilter.FILTER_REJECT;
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
let result = '';
|
|
117
|
+
while (walker.nextNode()) {
|
|
118
|
+
const value = normalize(walker.currentNode.textContent ?? '');
|
|
119
|
+
if (value) {
|
|
120
|
+
result += (result ? ' ' : '') + value;
|
|
121
|
+
}
|
|
122
|
+
if (result.length >= limit) {
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function roleOf(element: HTMLElement): string {
|
|
130
|
+
const explicit = element.getAttribute('role');
|
|
131
|
+
if (explicit) {
|
|
132
|
+
return explicit;
|
|
133
|
+
}
|
|
134
|
+
if (element.tagName === 'A') {
|
|
135
|
+
return 'link';
|
|
136
|
+
}
|
|
137
|
+
if (element.tagName === 'BUTTON') {
|
|
138
|
+
return 'button';
|
|
139
|
+
}
|
|
140
|
+
if (element.tagName === 'SELECT') {
|
|
141
|
+
const select = element as HTMLSelectElement;
|
|
142
|
+
return select.multiple || select.size > 1 ? 'listbox' : 'combobox';
|
|
143
|
+
}
|
|
144
|
+
if (element.tagName === 'INPUT') {
|
|
145
|
+
const type = (element as HTMLInputElement).type;
|
|
146
|
+
if (['button', 'submit', 'reset', 'image'].includes(type)) {
|
|
147
|
+
return 'button';
|
|
148
|
+
}
|
|
149
|
+
if (['checkbox', 'radio'].includes(type)) {
|
|
150
|
+
return type;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return isField(element) ? 'textbox' : 'control';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const groupRoles = new Set<ControlGroup['role']>([
|
|
157
|
+
'form',
|
|
158
|
+
'group',
|
|
159
|
+
'radiogroup',
|
|
160
|
+
'region',
|
|
161
|
+
'dialog',
|
|
162
|
+
'alertdialog',
|
|
163
|
+
]);
|
|
164
|
+
|
|
165
|
+
/** Preserve group names without copying complete forms, field values or surrounding page prose. */
|
|
166
|
+
function observeGroups(
|
|
167
|
+
element: HTMLElement,
|
|
168
|
+
root: Document | HTMLElement,
|
|
169
|
+
): Pick<ControlData, 'groups' | 'groupsTruncated'> {
|
|
170
|
+
const groups: ControlGroup[] = [];
|
|
171
|
+
const ancestors = new Set<HTMLElement>();
|
|
172
|
+
let truncated = false;
|
|
173
|
+
|
|
174
|
+
const add = (container: HTMLElement): void => {
|
|
175
|
+
if (container.closest(`${excluded}, [hidden], [inert], [aria-hidden="true"]`)) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const role = getRole(container) as ControlGroup['role'] | null;
|
|
179
|
+
if (!role || !groupRoles.has(role)) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
let name = normalize(computeAccessibleName(container));
|
|
183
|
+
if (!name) {
|
|
184
|
+
// A direct visible heading also identifies an otherwise unnamed section or group.
|
|
185
|
+
const heading = [...container.children].find(
|
|
186
|
+
(child): child is HTMLElement =>
|
|
187
|
+
child instanceof element.ownerDocument.defaultView!.HTMLElement &&
|
|
188
|
+
child.matches('h1, h2, h3, h4, h5, h6, [role="heading"]') &&
|
|
189
|
+
isVisible(child),
|
|
190
|
+
);
|
|
191
|
+
if (heading) {
|
|
192
|
+
name = visibleText(heading, 201);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (!name) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (groups.length === 4) {
|
|
199
|
+
truncated = true;
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
truncated ||= name.length > 200;
|
|
203
|
+
groups.push({ role, name: name.slice(0, 200) });
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const ownerForm =
|
|
207
|
+
isField(element) || element.tagName === 'BUTTON'
|
|
208
|
+
? (element as NativeField | HTMLButtonElement).form
|
|
209
|
+
: null;
|
|
210
|
+
|
|
211
|
+
for (
|
|
212
|
+
let parent = composedParent(element);
|
|
213
|
+
parent && contains(root, parent);
|
|
214
|
+
parent = composedParent(parent)
|
|
215
|
+
) {
|
|
216
|
+
ancestors.add(parent);
|
|
217
|
+
// A form attribute can associate a field with a different form from its DOM ancestor.
|
|
218
|
+
if (parent.tagName !== 'FORM' || !ownerForm || parent === ownerForm) {
|
|
219
|
+
add(parent);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (ownerForm && contains(root, ownerForm) && !ancestors.has(ownerForm)) {
|
|
224
|
+
add(ownerForm);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
...(groups.length ? { groups: groups.reverse() } : {}),
|
|
229
|
+
...(truncated ? { groupsTruncated: true } : {}),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function observeControl(element: HTMLElement, root: Document | HTMLElement): ControlData {
|
|
234
|
+
const editable = element.matches('[contenteditable="true"], [contenteditable=""]');
|
|
235
|
+
const field = isField(element) ? element : undefined;
|
|
236
|
+
const type = element.tagName === 'INPUT' ? (element as HTMLInputElement).type : '';
|
|
237
|
+
const select = element.tagName === 'SELECT' ? (element as HTMLSelectElement) : undefined;
|
|
238
|
+
const row = element.closest<HTMLElement>('tr, li, article, [role="row"], [role="listitem"]');
|
|
239
|
+
const description = normalize(computeAccessibleDescription(element)).slice(0, 1000);
|
|
240
|
+
const placeholder = element.matches('input, textarea')
|
|
241
|
+
? normalize(element.getAttribute('placeholder') ?? '').slice(0, 1000)
|
|
242
|
+
: '';
|
|
243
|
+
|
|
244
|
+
const data: ControlData = {
|
|
245
|
+
tag: element.tagName.toLowerCase(),
|
|
246
|
+
role: editable ? 'textbox' : roleOf(element),
|
|
247
|
+
...(editable
|
|
248
|
+
? { contentEditable: true, value: element.innerText ?? element.textContent ?? '' }
|
|
249
|
+
: {}),
|
|
250
|
+
...(field ? { type: type || element.tagName.toLowerCase() } : {}),
|
|
251
|
+
label: normalize(computeAccessibleName(element)).slice(0, 1000),
|
|
252
|
+
...(description ? { description } : {}),
|
|
253
|
+
...(placeholder ? { placeholder } : {}),
|
|
254
|
+
...observeGroups(element, root),
|
|
255
|
+
context: row && contains(root, row) ? visibleText(row, 1000).slice(0, 1000) : '',
|
|
256
|
+
enabled: !element.matches(':disabled') && !element.closest('[aria-disabled="true"], [inert]'),
|
|
257
|
+
readOnly:
|
|
258
|
+
element.getAttribute('aria-readonly') === 'true' ||
|
|
259
|
+
Boolean(field && 'readOnly' in field && field.readOnly),
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
if (field) {
|
|
263
|
+
data.value = field.value;
|
|
264
|
+
data.valid = field.validity.valid && element.getAttribute('aria-invalid') !== 'true';
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (type === 'checkbox' || type === 'radio') {
|
|
268
|
+
data.checked = (element as HTMLInputElement).checked;
|
|
269
|
+
} else if (element.hasAttribute('aria-checked')) {
|
|
270
|
+
data.checked = element.getAttribute('aria-checked') === 'true';
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (element.hasAttribute('aria-expanded')) {
|
|
274
|
+
data.expanded = element.getAttribute('aria-expanded') === 'true';
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (select) {
|
|
278
|
+
data.selectedValues = [...select.selectedOptions].map((option) => option.value);
|
|
279
|
+
data.options = [...select.options].map((option) => ({
|
|
280
|
+
value: option.value,
|
|
281
|
+
label: option.label,
|
|
282
|
+
enabled: !option.disabled && !option.closest('optgroup[disabled]'),
|
|
283
|
+
}));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return data;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function configuration(element: HTMLElement): string {
|
|
290
|
+
return JSON.stringify(
|
|
291
|
+
[
|
|
292
|
+
'type',
|
|
293
|
+
'name',
|
|
294
|
+
'href',
|
|
295
|
+
'role',
|
|
296
|
+
'form',
|
|
297
|
+
'required',
|
|
298
|
+
'min',
|
|
299
|
+
'max',
|
|
300
|
+
'step',
|
|
301
|
+
'pattern',
|
|
302
|
+
'minlength',
|
|
303
|
+
'maxlength',
|
|
304
|
+
'multiple',
|
|
305
|
+
].map((name) => element.getAttribute(name)),
|
|
306
|
+
);
|
|
307
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Copyright 2026 RuaKit contributors. SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
export type RuntimeErrorCode =
|
|
4
|
+
'STALE_TARGET' | 'DISABLED' | 'READ_ONLY' | 'UNSUPPORTED' | 'VALUE_REJECTED' | 'BUSY' | 'TIMEOUT';
|
|
5
|
+
|
|
6
|
+
export class RuntimeError extends Error {
|
|
7
|
+
constructor(
|
|
8
|
+
readonly code: RuntimeErrorCode,
|
|
9
|
+
message: string,
|
|
10
|
+
) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'RuntimeError';
|
|
13
|
+
}
|
|
14
|
+
}
|