@reticlehq/browser 3.9.0 → 3.10.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.
Files changed (56) hide show
  1. package/dist/actions/action-report.d.ts +14 -0
  2. package/dist/actions/action-report.js +95 -22
  3. package/dist/actions/actions-dom.d.ts +16 -6
  4. package/dist/actions/actions-dom.js +94 -46
  5. package/dist/actions/actions-press.d.ts +10 -0
  6. package/dist/actions/actions-press.js +146 -2
  7. package/dist/actions/actions.js +52 -22
  8. package/dist/actions/insert-text.d.ts +3 -0
  9. package/dist/actions/insert-text.js +90 -0
  10. package/dist/actions/scroll.d.ts +1 -1
  11. package/dist/actions/scroll.js +25 -1
  12. package/dist/actions/value-input.d.ts +2 -0
  13. package/dist/actions/value-input.js +30 -9
  14. package/dist/commands/commands.js +82 -6
  15. package/dist/connect-options.d.ts +11 -1
  16. package/dist/connect-options.js +27 -1
  17. package/dist/dom/a11y.js +4 -3
  18. package/dist/dom/chart-geometry.d.ts +7 -0
  19. package/dist/dom/chart-geometry.js +39 -0
  20. package/dist/dom/occlusion.js +21 -0
  21. package/dist/dom/query-finders.d.ts +1 -1
  22. package/dist/dom/query-finders.js +10 -7
  23. package/dist/dom/query.d.ts +1 -0
  24. package/dist/dom/query.js +78 -51
  25. package/dist/dom/scope-roots.d.ts +8 -0
  26. package/dist/dom/scope-roots.js +44 -0
  27. package/dist/dom/snapshot.d.ts +3 -0
  28. package/dist/dom/snapshot.js +9 -2
  29. package/dist/dom/text-match.js +4 -3
  30. package/dist/dom/theme.js +6 -7
  31. package/dist/dom/visibility.d.ts +2 -0
  32. package/dist/dom/visibility.js +40 -8
  33. package/dist/observers/canvas.d.ts +1 -0
  34. package/dist/observers/canvas.js +10 -1
  35. package/dist/observers/console.js +46 -35
  36. package/dist/observers/dom.js +28 -4
  37. package/dist/observers/net-detail/resource-timing.d.ts +12 -0
  38. package/dist/observers/net-detail/resource-timing.js +69 -0
  39. package/dist/observers/network.js +12 -45
  40. package/dist/observers/perf.js +15 -1
  41. package/dist/presenter/chrome/presenter-verbs.js +1 -0
  42. package/dist/presenter/presenter-config.d.ts +1 -0
  43. package/dist/presenter/presenter-config.js +1 -0
  44. package/dist/presenter/presenter-shell.d.ts +1 -0
  45. package/dist/presenter/presenter-shell.js +6 -3
  46. package/dist/presenter/tour/tour.js +4 -0
  47. package/dist/recorder/recorder.js +82 -39
  48. package/dist/reticle-inject.js +597 -597
  49. package/dist/reticle.d.ts +3 -19
  50. package/dist/reticle.js +74 -108
  51. package/dist/security/serialization.js +35 -0
  52. package/dist/storage-keys.js +2 -1
  53. package/dist/timers/native/native-timers.js +21 -4
  54. package/dist/transport/bridge-url.d.ts +8 -0
  55. package/dist/transport/bridge-url.js +52 -0
  56. package/package.json +10 -10
@@ -31,7 +31,12 @@ export interface ActionResult {
31
31
  effect: ActionEffect;
32
32
  testid?: string;
33
33
  testidOf?: number;
34
+ testidNth?: number;
34
35
  component?: string;
36
+ componentNth?: number;
37
+ componentOf?: number;
38
+ ownText?: string;
39
+ within?: string[];
35
40
  role?: string;
36
41
  name?: string;
37
42
  nth?: number;
@@ -48,17 +53,26 @@ export interface ActionResult {
48
53
  export interface CapturedAnchor {
49
54
  testid?: string;
50
55
  testidOf?: number;
56
+ testidNth?: number;
51
57
  component?: string;
52
58
  source?: {
53
59
  file: string;
54
60
  line: number;
55
61
  column?: number;
56
62
  };
63
+ componentNth?: number;
64
+ componentOf?: number;
65
+ ownText?: string;
57
66
  role?: string;
58
67
  name?: string;
59
68
  nth?: number;
60
69
  of?: number;
70
+ within?: string[];
61
71
  secret?: true;
62
72
  }
73
+ export declare function positionAmongNamesakes(el: Element, role: string, name: string): {
74
+ nth: number;
75
+ of: number;
76
+ } | null;
63
77
  export declare function anchorOf(el: Element): CapturedAnchor;
64
78
  export declare const result: (ref: string, action: string, effect: ActionEffect, settled: boolean, settleReason: SettleReason | null, anchor: CapturedAnchor, warning?: string) => ActionResult;
@@ -1,36 +1,80 @@
1
1
  import { readTestId } from '../dom/addressing/testid-attr.js';
2
2
  import { QueryBy } from '@reticlehq/core';
3
3
  import { elementsMatching } from '../dom/query.js';
4
+ import { ownWords } from '../dom/query-finders.js';
4
5
  import { getAccessibleName, getRole, isSensitiveField } from '../dom/a11y.js';
5
6
  import { identifyComponent } from '../registry/stores/adapters.js';
6
7
  import { isReticleUi } from '../dom/dom-ignore.js';
8
+ import { isInViewport } from '../dom/visibility.js';
7
9
  const CANVAS_SELECTOR = 'canvas';
8
10
  export function visibleCanvases(doc) {
9
- const width = doc.defaultView?.innerWidth ?? 0;
10
- const height = doc.defaultView?.innerHeight ?? 0;
11
- let count = 0;
12
- for (const canvas of doc.querySelectorAll(CANVAS_SELECTOR)) {
13
- if (isReticleUi(canvas)) {
14
- continue;
15
- }
16
- const box = canvas.getBoundingClientRect();
17
- const sized = box.width > 0 && box.height > 0;
18
- const onScreen = box.bottom > 0 && box.right > 0 && box.top < height && box.left < width;
19
- if (sized && onScreen) {
20
- count += 1;
11
+ return Array.from(doc.querySelectorAll(CANVAS_SELECTOR)).filter((canvas) => !isReticleUi(canvas) && isInViewport(canvas)).length;
12
+ }
13
+ export function positionAmongNamesakes(el, role, name) {
14
+ const same = name.length > 0
15
+ ? elementsMatching({ by: QueryBy.ROLE, value: role, name })
16
+ : elementsMatching({ by: QueryBy.ROLE, value: role }).filter((candidate) => 0 === getAccessibleName(candidate).length);
17
+ const at = same.indexOf(el);
18
+ return 1 < same.length && 0 <= at ? { nth: at, of: same.length } : null;
19
+ }
20
+ function positionInComponentAnchor(el, component, source, role) {
21
+ if (component === undefined && source === undefined) {
22
+ return null;
23
+ }
24
+ const same = elementsMatching({
25
+ by: QueryBy.COMPONENT,
26
+ value: component ?? '',
27
+ ...(component === undefined ? {} : { component }),
28
+ ...(source === undefined ? {} : { source }),
29
+ ...(role.length > 0 ? { role } : {}),
30
+ });
31
+ const at = same.indexOf(el);
32
+ return 1 < same.length && 0 <= at ? { nth: at, of: same.length } : null;
33
+ }
34
+ const CONTAINER_ROLES = new Set([
35
+ 'alert',
36
+ 'alertdialog',
37
+ 'dialog',
38
+ 'form',
39
+ 'region',
40
+ 'navigation',
41
+ 'menu',
42
+ 'menubar',
43
+ 'listbox',
44
+ 'list',
45
+ 'listitem',
46
+ 'tabpanel',
47
+ 'table',
48
+ 'grid',
49
+ 'row',
50
+ 'group',
51
+ 'toolbar',
52
+ 'tree',
53
+ 'search',
54
+ ]);
55
+ const WITHIN_MAX = 2;
56
+ function containerRoles(el) {
57
+ const out = [];
58
+ for (let a = el.parentElement; a !== null && out.length < WITHIN_MAX; a = a.parentElement) {
59
+ const role = getRole(a);
60
+ const landmarkNeedsName = 'form' === role || 'region' === role;
61
+ if (CONTAINER_ROLES.has(role) && (!landmarkNeedsName || getAccessibleName(a).length > 0)) {
62
+ out.push(role);
21
63
  }
22
64
  }
23
- return count;
65
+ return out.length > 0 ? out : undefined;
24
66
  }
67
+ const OWN_TEXT_MAX = 80;
25
68
  export function anchorOf(el) {
26
69
  const testid = readTestId(el) ?? undefined;
27
70
  const info = identifyComponent(el);
28
71
  const out = {};
29
72
  if (testid !== undefined) {
30
73
  out.testid = testid;
31
- const shared = elementsMatching({ by: QueryBy.TESTID, value: testid }).length;
32
- if (1 < shared) {
33
- out.testidOf = shared;
74
+ const shared = elementsMatching({ by: QueryBy.TESTID, value: testid });
75
+ if (1 < shared.length) {
76
+ out.testidOf = shared.length;
77
+ out.testidNth = shared.findIndex((match) => match === el);
34
78
  }
35
79
  }
36
80
  const component = info?.componentStack[0];
@@ -42,21 +86,35 @@ export function anchorOf(el) {
42
86
  }
43
87
  const role = getRole(el);
44
88
  const name = getAccessibleName(el);
89
+ const position = positionInComponentAnchor(el, out.component, out.source, role);
90
+ if (position !== null) {
91
+ out.componentNth = position.nth;
92
+ out.componentOf = position.of;
93
+ }
45
94
  if (role.length > 0) {
46
95
  out.role = role;
47
96
  }
48
97
  if (name.length > 0) {
49
98
  out.name = name;
99
+ const within = containerRoles(el);
100
+ if (within !== undefined) {
101
+ out.within = within;
102
+ }
103
+ }
104
+ else if (out.component !== undefined || out.source !== undefined) {
105
+ const words = ownWords(el);
106
+ if (0 < words.length && words.length <= OWN_TEXT_MAX) {
107
+ out.ownText = words;
108
+ }
50
109
  }
51
110
  if (isSensitiveField(el)) {
52
111
  out.secret = true;
53
112
  }
54
- if (role.length > 0 && name.length > 0) {
55
- const same = elementsMatching({ by: QueryBy.ROLE, value: role, name });
56
- const at = same.indexOf(el);
57
- if (1 < same.length && 0 <= at) {
58
- out.nth = at;
59
- out.of = same.length;
113
+ if (role.length > 0) {
114
+ const position = positionAmongNamesakes(el, role, name);
115
+ if (position !== null) {
116
+ out.nth = position.nth;
117
+ out.of = position.of;
60
118
  }
61
119
  }
62
120
  return out;
@@ -77,12 +135,27 @@ export const result = (ref, action, effect, settled, settleReason, anchor, warni
77
135
  if (anchor.testidOf !== undefined) {
78
136
  base.testidOf = anchor.testidOf;
79
137
  }
138
+ if (anchor.testidNth !== undefined) {
139
+ base.testidNth = anchor.testidNth;
140
+ }
80
141
  if (anchor.component !== undefined) {
81
142
  base.component = anchor.component;
82
143
  }
83
144
  if (anchor.source !== undefined) {
84
145
  base.source = anchor.source;
85
146
  }
147
+ if (anchor.componentNth !== undefined) {
148
+ base.componentNth = anchor.componentNth;
149
+ }
150
+ if (anchor.componentOf !== undefined) {
151
+ base.componentOf = anchor.componentOf;
152
+ }
153
+ if (anchor.ownText !== undefined) {
154
+ base.ownText = anchor.ownText;
155
+ }
156
+ if (anchor.within !== undefined) {
157
+ base.within = anchor.within;
158
+ }
86
159
  if (anchor.role !== undefined) {
87
160
  base.role = anchor.role;
88
161
  }
@@ -1,5 +1,5 @@
1
1
  import type { ActionTarget } from '../dom/realm.js';
2
- import { ClickButton } from '@reticlehq/core';
2
+ import { ClickButton, type BoxFraction } from '@reticlehq/core';
3
3
  import { type ModifierFlags } from './actions-press.js';
4
4
  interface ClickGeometry {
5
5
  occluded: boolean;
@@ -21,21 +21,31 @@ export declare function fireClickSequence(el: ActionTarget, hold?: {
21
21
  ms: number;
22
22
  sleep: (ms: number) => Promise<void>;
23
23
  now: () => number;
24
- }, detail?: number, input?: ClickInput): Promise<{
24
+ }, detail?: number, input?: ClickInput, point?: BoxFraction): Promise<{
25
25
  prevented: boolean;
26
26
  heldMs: number;
27
27
  }>;
28
28
  export declare function scrollIntoViewNow(el: Element, options?: ScrollIntoViewOptions): void;
29
29
  export declare function isOffViewport(el: Element, rect: DOMRect): boolean;
30
30
  export declare function clickGeometry(el: ActionTarget): ClickGeometry;
31
- export declare function firePointer(el: Element, type: string, relatedTarget?: EventTarget | null): void;
32
- export declare function firePointerNonBubbling(el: Element, type: string, relatedTarget?: EventTarget | null): void;
33
- export declare function dragElement(source: ActionTarget, target: ActionTarget | null, data: unknown): Promise<boolean>;
31
+ export declare function firePointer(el: Element, type: string, relatedTarget?: EventTarget | null, coords?: MouseEventInit): void;
32
+ export declare function leavePreviousHover(next: Element): EventTarget;
33
+ export declare function firePointerNonBubbling(el: Element, type: string, relatedTarget?: EventTarget | null, coords?: MouseEventInit): void;
34
+ interface Point {
35
+ x: number;
36
+ y: number;
37
+ }
38
+ export declare function coordsAt(p: Point): MouseEventInit;
39
+ export declare function coordsIn(el: ActionTarget, at: BoxFraction | undefined): MouseEventInit;
40
+ export declare function dragElement(source: ActionTarget, target: ActionTarget | null, data: unknown, points?: {
41
+ at?: BoxFraction | undefined;
42
+ to?: BoxFraction | undefined;
43
+ }, input?: ClickInput): Promise<boolean>;
34
44
  export declare function fireTapSequence(el: ActionTarget, hold: {
35
45
  ms: number;
36
46
  sleep: (ms: number) => Promise<void>;
37
47
  now: () => number;
38
- } | undefined): Promise<{
48
+ } | undefined, point?: BoxFraction): Promise<{
39
49
  prevented: boolean;
40
50
  heldMs: number;
41
51
  }>;
@@ -4,7 +4,7 @@ import { getAccessibleName, getRole } from '../dom/a11y.js';
4
4
  import { hitTestOccluder } from '../dom/occlusion.js';
5
5
  import { nativeFrame } from '../timers/native/native-timers.js';
6
6
  import { asSyntheticInput } from './synthetic/synthetic-input.js';
7
- import { ClickButton, clickButtonFromArgs } from '@reticlehq/core';
7
+ import { ClickButton, clickButtonFromArgs, pointInBox } from '@reticlehq/core';
8
8
  import { pressModifiers } from './actions-press.js';
9
9
  export const NO_GEOMETRY = {
10
10
  occluded: false,
@@ -55,10 +55,10 @@ function fireAt(el, type, at, buttons, related, detail = 0, input = PLAIN_CLICK)
55
55
  ? pointerEventFor(el, type, { ...init, pointerId: 1, isPrimary: true })
56
56
  : mouseEventFor(el, type, init));
57
57
  }
58
- export async function fireClickSequence(el, hold, detail, input = PLAIN_CLICK) {
58
+ export async function fireClickSequence(el, hold, detail, input = PLAIN_CLICK, point) {
59
59
  const doc = el.ownerDocument;
60
60
  const from = doc.activeElement ?? doc.body;
61
- const at = centreOf(el);
61
+ const at = pointOf(el, point);
62
62
  const d = detail ?? 0;
63
63
  const held = BUTTON_CODES[input.button].bit;
64
64
  const isLeft = ClickButton.LEFT === input.button;
@@ -119,15 +119,36 @@ function hasNoSize(el) {
119
119
  return false;
120
120
  }
121
121
  const rects = Array.from(el.getClientRects());
122
- return rects.length > 0 && rects.every((r) => 0 === r.width || 0 === r.height);
122
+ if (0 === rects.length) {
123
+ return isMeasurable(el.ownerDocument.documentElement.getBoundingClientRect());
124
+ }
125
+ return rects.every((r) => 0 === r.width || 0 === r.height);
126
+ }
127
+ function sizedLabelOf(el) {
128
+ const labels = 'labels' in el ? el.labels : null;
129
+ for (const label of labels ?? []) {
130
+ const box = label.getBoundingClientRect();
131
+ if (0 < box.width && 0 < box.height) {
132
+ return label;
133
+ }
134
+ }
135
+ return null;
123
136
  }
124
137
  export function clickGeometry(el) {
125
138
  if (typeof el.getBoundingClientRect !== 'function') {
126
139
  return NO_GEOMETRY;
127
140
  }
128
141
  let rect = el.getBoundingClientRect();
142
+ const sizeless = isMeasurable(rect) ? 0 === rect.width || 0 === rect.height : hasNoSize(el);
143
+ if (sizeless) {
144
+ const label = sizedLabelOf(el);
145
+ if (label !== null) {
146
+ return clickGeometry(label);
147
+ }
148
+ return { ...NO_GEOMETRY, noSize: true };
149
+ }
129
150
  if (!isMeasurable(rect)) {
130
- return hasNoSize(el) ? { ...NO_GEOMETRY, noSize: true } : NO_GEOMETRY;
151
+ return NO_GEOMETRY;
131
152
  }
132
153
  let scrolledIntoView = false;
133
154
  if (isOffViewport(el, rect) && 'function' === typeof el.scrollIntoView) {
@@ -138,11 +159,30 @@ export function clickGeometry(el) {
138
159
  const stillOff = scrolledIntoView && isOffViewport(el, rect);
139
160
  return { ...hitTest(el, rect), scrolledIntoView, ...(stillOff ? { offViewport: true } : {}) };
140
161
  }
141
- export function firePointer(el, type, relatedTarget = null) {
142
- asSyntheticInput(() => el.dispatchEvent(pointerEventFor(el, type, { bubbles: true, cancelable: true, relatedTarget })));
162
+ export function firePointer(el, type, relatedTarget = null, coords = {}) {
163
+ asSyntheticInput(() => el.dispatchEvent(pointerEventFor(el, type, { bubbles: true, cancelable: true, relatedTarget, ...coords })));
164
+ }
165
+ const hoveredIn = new WeakMap();
166
+ export function leavePreviousHover(next) {
167
+ const doc = next.ownerDocument;
168
+ const prev = hoveredIn.get(doc);
169
+ hoveredIn.set(doc, next);
170
+ if (prev === undefined || !prev.isConnected) {
171
+ return doc.activeElement ?? doc.body;
172
+ }
173
+ if (prev === next) {
174
+ return prev;
175
+ }
176
+ firePointer(prev, 'pointerout', next);
177
+ prev.dispatchEvent(mouseEventFor(prev, 'mouseout', { bubbles: true, relatedTarget: next }));
178
+ for (let el = prev; el !== null && !el.contains(next); el = el.parentElement) {
179
+ firePointerNonBubbling(el, 'pointerleave', next);
180
+ el.dispatchEvent(mouseEventFor(el, 'mouseleave', { relatedTarget: next }));
181
+ }
182
+ return prev;
143
183
  }
144
- export function firePointerNonBubbling(el, type, relatedTarget = null) {
145
- asSyntheticInput(() => el.dispatchEvent(pointerEventFor(el, type, { bubbles: false, cancelable: true, relatedTarget })));
184
+ export function firePointerNonBubbling(el, type, relatedTarget = null, coords = {}) {
185
+ asSyntheticInput(() => el.dispatchEvent(pointerEventFor(el, type, { bubbles: false, cancelable: true, relatedTarget, ...coords })));
146
186
  }
147
187
  function makeDataTransfer(data) {
148
188
  if (typeof DataTransfer !== 'function') {
@@ -165,18 +205,31 @@ function centreOf(el) {
165
205
  const box = el.getBoundingClientRect();
166
206
  return { x: box.left + box.width / 2, y: box.top + box.height / 2 };
167
207
  }
208
+ function pointOf(el, at) {
209
+ if (at === undefined) {
210
+ return centreOf(el);
211
+ }
212
+ const box = el.getBoundingClientRect();
213
+ return pointInBox({ x: box.left, y: box.top, width: box.width, height: box.height }, at);
214
+ }
215
+ export function coordsAt(p) {
216
+ return { clientX: p.x, clientY: p.y, screenX: p.x, screenY: p.y };
217
+ }
218
+ export function coordsIn(el, at) {
219
+ return at === undefined ? {} : coordsAt(pointOf(el, at));
220
+ }
168
221
  function lerp(a, b, t) {
169
222
  return { x: Math.round(a.x + (b.x - a.x) * t), y: Math.round(a.y + (b.y - a.y) * t) };
170
223
  }
171
- export async function dragElement(source, target, data) {
224
+ export async function dragElement(source, target, data, points = {}, input = PLAIN_CLICK) {
172
225
  const dest = target ?? source;
173
- const from = centreOf(source);
174
- const to = centreOf(dest);
226
+ const from = pointOf(source, points.at);
227
+ const to = pointOf(dest, points.to);
175
228
  const sourceRect = source.getBoundingClientRect();
176
229
  const destRect = target !== null ? dest.getBoundingClientRect() : null;
177
230
  const crosses = (rect, p) => p.x >= rect.left && p.x <= rect.right && p.y >= rect.top && p.y <= rect.bottom;
178
- fireAt(source, 'pointerdown', from, BUTTON_HELD);
179
- fireAt(source, 'mousedown', from, BUTTON_HELD);
231
+ fireAt(source, 'pointerdown', from, BUTTON_HELD, undefined, 0, input);
232
+ fireAt(source, 'mousedown', from, BUTTON_HELD, undefined, 0, input);
180
233
  await nativeFrame();
181
234
  let leftSource = false;
182
235
  let enteredDest = false;
@@ -184,30 +237,30 @@ export async function dragElement(source, target, data) {
184
237
  const at = lerp(from, to, step / DRAG_STEPS);
185
238
  if (!leftSource && !crosses(sourceRect, at)) {
186
239
  leftSource = true;
187
- fireAt(source, 'pointerout', at, BUTTON_HELD, dest);
188
- fireAt(source, 'mouseout', at, BUTTON_HELD, dest);
189
- fireAt(source, 'pointerleave', at, BUTTON_HELD, dest);
190
- fireAt(source, 'mouseleave', at, BUTTON_HELD, dest);
240
+ fireAt(source, 'pointerout', at, BUTTON_HELD, dest, 0, input);
241
+ fireAt(source, 'mouseout', at, BUTTON_HELD, dest, 0, input);
242
+ fireAt(source, 'pointerleave', at, BUTTON_HELD, dest, 0, input);
243
+ fireAt(source, 'mouseleave', at, BUTTON_HELD, dest, 0, input);
191
244
  }
192
245
  if (leftSource && !enteredDest && destRect !== null && crosses(destRect, at)) {
193
246
  enteredDest = true;
194
- fireAt(dest, 'pointerover', at, BUTTON_HELD, source);
195
- fireAt(dest, 'mouseover', at, BUTTON_HELD, source);
196
- fireAt(dest, 'pointerenter', at, BUTTON_HELD, source);
197
- fireAt(dest, 'mouseenter', at, BUTTON_HELD, source);
247
+ fireAt(dest, 'pointerover', at, BUTTON_HELD, source, 0, input);
248
+ fireAt(dest, 'mouseover', at, BUTTON_HELD, source, 0, input);
249
+ fireAt(dest, 'pointerenter', at, BUTTON_HELD, source, 0, input);
250
+ fireAt(dest, 'mouseenter', at, BUTTON_HELD, source, 0, input);
198
251
  }
199
- fireAt(dest, 'pointermove', at, BUTTON_HELD);
200
- fireAt(dest, 'mousemove', at, BUTTON_HELD);
252
+ fireAt(dest, 'pointermove', at, BUTTON_HELD, undefined, 0, input);
253
+ fireAt(dest, 'mousemove', at, BUTTON_HELD, undefined, 0, input);
201
254
  await nativeFrame();
202
255
  }
203
256
  if (!enteredDest && destRect !== null) {
204
- fireAt(dest, 'pointerover', to, BUTTON_HELD, source);
205
- fireAt(dest, 'mouseover', to, BUTTON_HELD, source);
206
- fireAt(dest, 'pointerenter', to, BUTTON_HELD, source);
207
- fireAt(dest, 'mouseenter', to, BUTTON_HELD, source);
257
+ fireAt(dest, 'pointerover', to, BUTTON_HELD, source, 0, input);
258
+ fireAt(dest, 'mouseover', to, BUTTON_HELD, source, 0, input);
259
+ fireAt(dest, 'pointerenter', to, BUTTON_HELD, source, 0, input);
260
+ fireAt(dest, 'mouseenter', to, BUTTON_HELD, source, 0, input);
208
261
  }
209
- fireAt(dest, 'pointerup', to, BUTTON_RELEASED);
210
- fireAt(dest, 'mouseup', to, BUTTON_RELEASED);
262
+ fireAt(dest, 'pointerup', to, BUTTON_RELEASED, undefined, 0, input);
263
+ fireAt(dest, 'mouseup', to, BUTTON_RELEASED, undefined, 0, input);
211
264
  let dropPrevented = false;
212
265
  if ('function' === typeof DragEvent) {
213
266
  const dataTransfer = makeDataTransfer(data);
@@ -225,9 +278,10 @@ export async function dragElement(source, target, data) {
225
278
  }
226
279
  return dropPrevented;
227
280
  }
228
- export async function fireTapSequence(el, hold) {
229
- const touches = touchListFor(el);
230
- firePointerTouch(el, 'pointerdown');
281
+ export async function fireTapSequence(el, hold, point) {
282
+ const touches = touchListFor(el, point);
283
+ const coords = coordsIn(el, point);
284
+ firePointerTouch(el, 'pointerdown', coords);
231
285
  asSyntheticInput(() => el.dispatchEvent(makeTouchEvent('touchstart', touches)));
232
286
  if (el.tabIndex >= 0 && 'function' === typeof el.focus) {
233
287
  el.focus();
@@ -239,11 +293,11 @@ export async function fireTapSequence(el, hold) {
239
293
  heldMs = hold.now() - started;
240
294
  }
241
295
  asSyntheticInput(() => el.dispatchEvent(makeTouchEvent('touchend', [])));
242
- firePointerTouch(el, 'pointerup');
243
- const clicked = asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'click', { bubbles: true, cancelable: true })));
296
+ firePointerTouch(el, 'pointerup', coords);
297
+ const clicked = asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'click', { bubbles: true, cancelable: true, ...coords })));
244
298
  return { prevented: !clicked, heldMs };
245
299
  }
246
- function firePointerTouch(el, type) {
300
+ function firePointerTouch(el, type, coords) {
247
301
  asSyntheticInput(() => el.dispatchEvent(pointerEventFor(el, type, {
248
302
  bubbles: true,
249
303
  cancelable: true,
@@ -251,22 +305,16 @@ function firePointerTouch(el, type) {
251
305
  isPrimary: true,
252
306
  width: 23,
253
307
  height: 23,
308
+ ...coords,
254
309
  })));
255
310
  }
256
- function touchListFor(el) {
311
+ function touchListFor(el, point) {
257
312
  if ('function' !== typeof Touch || 'function' !== typeof el.getBoundingClientRect) {
258
313
  return [];
259
314
  }
260
- const rect = el.getBoundingClientRect();
315
+ const at = pointOf(el, point);
261
316
  try {
262
- return [
263
- new Touch({
264
- identifier: 0,
265
- target: el,
266
- clientX: rect.left + rect.width / 2,
267
- clientY: rect.top + rect.height / 2,
268
- }),
269
- ];
317
+ return [new Touch({ identifier: 0, target: el, clientX: at.x, clientY: at.y })];
270
318
  }
271
319
  catch {
272
320
  return [];
@@ -19,3 +19,13 @@ export declare function keyboardEvent(type: string, init: KeyboardEventInit & {
19
19
  export declare function holdKey(el: ActionTarget, key: string, code: string, mods: ModifierFlags, ms: number): Promise<void>;
20
20
  export declare function pressCombo(el: ActionTarget, keys: readonly string[], mods: ModifierFlags, holdMs: number): Promise<boolean>;
21
21
  export declare function closeModalOnEscape(el: ActionTarget, key: string, keydownProceeded: boolean): void;
22
+ export declare const SPACE_KEY = " ";
23
+ interface Held {
24
+ metaKey: boolean;
25
+ ctrlKey: boolean;
26
+ altKey: boolean;
27
+ shiftKey: boolean;
28
+ }
29
+ export declare function runKeydownDefault(el: Element, key: string, held: Held): void;
30
+ export declare function runKeyupDefault(el: Element, key: string, held: Held): void;
31
+ export {};
@@ -7,7 +7,21 @@ function asString(value, fallback = '') {
7
7
  return 'string' === typeof value ? value : fallback;
8
8
  }
9
9
  export function pressKey(args) {
10
- return pressKeyFromArgs(args);
10
+ return splitChord(pressKeyFromArgs(args)).key;
11
+ }
12
+ function splitChord(raw) {
13
+ const at = raw.lastIndexOf('+', raw.length - 2);
14
+ const flags = 0 < at
15
+ ? raw
16
+ .slice(0, at)
17
+ .split('+')
18
+ .map((name) => MODIFIER_FLAG_ALIASES[name.toLowerCase()])
19
+ : [];
20
+ if (0 === flags.length || flags.some((flag) => flag === undefined)) {
21
+ return { key: CODE_KEYS[raw] ?? raw, flags: [] };
22
+ }
23
+ const key = raw.slice(at + 1);
24
+ return { key: CODE_KEYS[key] ?? key, flags: flags.filter((flag) => flag !== undefined) };
11
25
  }
12
26
  const MODIFIER_FLAG_KEYS = [
13
27
  'metaKey',
@@ -37,6 +51,9 @@ export function pressModifiers(args) {
37
51
  if (flag !== undefined)
38
52
  flags[flag] = true;
39
53
  }
54
+ for (const flag of splitChord(pressKeyFromArgs(args)).flags) {
55
+ flags[flag] = true;
56
+ }
40
57
  return flags;
41
58
  }
42
59
  function modifierFlagFor(key) {
@@ -53,7 +70,7 @@ export function pressCode(args, key) {
53
70
  return `Key${key.toUpperCase()}`;
54
71
  if (/[0-9]/.test(key))
55
72
  return `Digit${key}`;
56
- return '';
73
+ return PUNCTUATION_CODES[key] ?? '';
57
74
  }
58
75
  return /^[A-Z][A-Za-z0-9]*$/.test(key) && KNOWN_NAMED_KEYS.has(key) ? key : '';
59
76
  }
@@ -74,6 +91,53 @@ export function dispatchKeypress(el, key, code, mods, repeat = false) {
74
91
  }
75
92
  asSyntheticInput(() => el.dispatchEvent(event));
76
93
  }
94
+ const PUNCTUATION_CODES = {
95
+ '/': 'Slash',
96
+ '?': 'Slash',
97
+ '.': 'Period',
98
+ '>': 'Period',
99
+ ',': 'Comma',
100
+ '<': 'Comma',
101
+ ';': 'Semicolon',
102
+ ':': 'Semicolon',
103
+ "'": 'Quote',
104
+ '"': 'Quote',
105
+ '[': 'BracketLeft',
106
+ '{': 'BracketLeft',
107
+ ']': 'BracketRight',
108
+ '}': 'BracketRight',
109
+ '\\': 'Backslash',
110
+ '|': 'Backslash',
111
+ '-': 'Minus',
112
+ _: 'Minus',
113
+ '=': 'Equal',
114
+ '+': 'Equal',
115
+ '`': 'Backquote',
116
+ '~': 'Backquote',
117
+ '!': 'Digit1',
118
+ '@': 'Digit2',
119
+ '#': 'Digit3',
120
+ $: 'Digit4',
121
+ '%': 'Digit5',
122
+ '^': 'Digit6',
123
+ '&': 'Digit7',
124
+ '*': 'Digit8',
125
+ '(': 'Digit9',
126
+ ')': 'Digit0',
127
+ };
128
+ const CODE_KEYS = {
129
+ Slash: '/',
130
+ Period: '.',
131
+ Comma: ',',
132
+ Semicolon: ';',
133
+ Quote: "'",
134
+ BracketLeft: '[',
135
+ BracketRight: ']',
136
+ Backslash: '\\',
137
+ Minus: '-',
138
+ Equal: '=',
139
+ Backquote: '`',
140
+ };
77
141
  const ENTER_KEY = 'Enter';
78
142
  const ENTER_CHAR_CODE = 13;
79
143
  const LEGACY_KEY_CODE_FIELDS = ['keyCode', 'charCode', 'which'];
@@ -233,3 +297,83 @@ export function closeModalOnEscape(el, key, keydownProceeded) {
233
297
  return;
234
298
  }
235
299
  }
300
+ export const SPACE_KEY = ' ';
301
+ const ENTER = 'Enter';
302
+ const TAB = 'Tab';
303
+ const ENTER_ACTIVATES = 'button, a[href], area[href], summary, input[type="submit"], input[type="button"], input[type="reset"], input[type="image"]';
304
+ const SPACE_ACTIVATES = 'button, summary, input[type="submit"], input[type="button"], input[type="reset"], input[type="image"], input[type="checkbox"], input[type="radio"]';
305
+ const IMPLICIT_SUBMIT_TYPES = new Set([
306
+ 'text',
307
+ 'search',
308
+ 'url',
309
+ 'tel',
310
+ 'email',
311
+ 'password',
312
+ 'date',
313
+ 'month',
314
+ 'week',
315
+ 'time',
316
+ 'datetime-local',
317
+ 'number',
318
+ ]);
319
+ const FOCUSABLE = 'a[href], area[href], button, input, select, textarea, summary, iframe, [tabindex], [contenteditable=""], [contenteditable="true"]';
320
+ const shortcut = (held) => held.metaKey || held.ctrlKey || held.altKey;
321
+ export function runKeydownDefault(el, key, held) {
322
+ if (shortcut(held)) {
323
+ return;
324
+ }
325
+ if (ENTER === key && !held.shiftKey) {
326
+ enterDefault(el);
327
+ return;
328
+ }
329
+ if (TAB === key) {
330
+ moveFocus(el, held.shiftKey ? -1 : 1);
331
+ }
332
+ }
333
+ export function runKeyupDefault(el, key, held) {
334
+ if (SPACE_KEY !== key || shortcut(held) || held.shiftKey) {
335
+ return;
336
+ }
337
+ if (el.matches(SPACE_ACTIVATES) && !isDisabled(el)) {
338
+ el.click();
339
+ }
340
+ }
341
+ function enterDefault(el) {
342
+ if (el.matches(ENTER_ACTIVATES)) {
343
+ if (!isDisabled(el)) {
344
+ el.click();
345
+ }
346
+ return;
347
+ }
348
+ const type = 'input' === el.localName ? (el.getAttribute('type') ?? 'text').toLowerCase() : '';
349
+ if (!IMPLICIT_SUBMIT_TYPES.has(type)) {
350
+ return;
351
+ }
352
+ const form = el.form;
353
+ form?.requestSubmit();
354
+ }
355
+ function isDisabled(el) {
356
+ return el.matches(':disabled');
357
+ }
358
+ function tabbable(el) {
359
+ if (isDisabled(el) || null !== el.closest('[inert], [hidden]')) {
360
+ return false;
361
+ }
362
+ const html = el;
363
+ if (html.tabIndex < 0) {
364
+ return false;
365
+ }
366
+ return 'hidden' !== el.type;
367
+ }
368
+ function moveFocus(from, step) {
369
+ const doc = from.ownerDocument;
370
+ const all = [...doc.querySelectorAll(FOCUSABLE)].filter(tabbable);
371
+ const positive = all.filter((el) => el.tabIndex > 0).sort((a, b) => a.tabIndex - b.tabIndex);
372
+ const order = [...positive, ...all.filter((el) => 0 === el.tabIndex)];
373
+ let at = order.indexOf(from);
374
+ if (at < 0) {
375
+ at = order.findIndex((el) => from.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING);
376
+ at = at < 0 ? order.length : at - (1 === step ? 1 : 0);
377
+ }
378
+ order[at + step]?.focus();
379
+ }