@quiesco/agent 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +23 -2
- package/metro.cjs +144 -0
- package/package.json +54 -3
- package/src/actions/hit-test.ts +42 -0
- package/src/actions/host.ts +194 -0
- package/src/actions/js.ts +245 -0
- package/src/actions/multitouch.ts +179 -0
- package/src/actions/native.ts +500 -0
- package/src/actions/performer.ts +87 -0
- package/src/actions/value.ts +460 -0
- package/src/actions/wheel.ts +260 -0
- package/src/api-types.ts +32 -0
- package/src/api.ts +11 -0
- package/src/assertions/index.ts +160 -0
- package/src/backdoor.ts +54 -0
- package/src/build-config.ts +6 -0
- package/src/connection/client.ts +196 -0
- package/src/connection/params.ts +63 -0
- package/src/early.ts +10 -0
- package/src/errors.ts +23 -0
- package/src/events.ts +136 -0
- package/src/expo-fetch.js +13 -0
- package/src/fiber-adapter/default-deps.native.ts +36 -0
- package/src/fiber-adapter/default-deps.ts +38 -0
- package/src/fiber-adapter/fiber.ts +81 -0
- package/src/fiber-adapter/hook.ts +154 -0
- package/src/fiber-adapter/host.ts +210 -0
- package/src/fiber-adapter/index.ts +221 -0
- package/src/fiber-adapter/scheduler.ts +70 -0
- package/src/fiber-adapter/traverse.ts +260 -0
- package/src/fiber-adapter/types.ts +142 -0
- package/src/index.ts +7 -0
- package/src/install.ts +6 -0
- package/src/json-check.ts +43 -0
- package/src/platform.ts +155 -0
- package/src/query/index.ts +5 -0
- package/src/query/matcher.ts +121 -0
- package/src/query/semantic.ts +266 -0
- package/src/query/tree.ts +116 -0
- package/src/query/visibility.ts +60 -0
- package/src/reset/index.ts +59 -0
- package/src/reset/storage-defaults.native.ts +26 -0
- package/src/reset/storage-defaults.ts +39 -0
- package/src/root/QuiescoRoot.tsx +28 -0
- package/src/runtime.ts +1003 -0
- package/src/singleton.ts +18 -0
- package/src/stub.ts +28 -0
- package/src/sync/frame-stable.ts +142 -0
- package/src/sync/idle-callback.ts +142 -0
- package/src/sync/index.ts +17 -0
- package/src/sync/native-settle.ts +41 -0
- package/src/sync/network.ts +299 -0
- package/src/sync/offline.ts +121 -0
- package/src/sync/react.ts +28 -0
- package/src/sync/timer.ts +124 -0
- package/src/sync/tracker.ts +51 -0
- package/src/sync/wait-for-idle.ts +141 -0
- package/src/version.ts +2 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Value controls (Phase 6.3): `setValue`, `adjustSlider` and `accessibilityAction`.
|
|
3
|
+
*
|
|
4
|
+
* A control is found by the shape of the props of the nearest component around the target (the
|
|
5
|
+
* element, then its ancestors, a few levels up — see HostNode.findProps), so it works with the
|
|
6
|
+
* community components without depending on their (minified) names:
|
|
7
|
+
* - date: `value` is a Date and there is `onChange` / `onValueChange` (@react-native-community/datetimepicker);
|
|
8
|
+
* - segmented: a `values` array and `onChange` / `onValueChange` (@react-native-segmented-control);
|
|
9
|
+
* - picker: `onValueChange` and a `selectedValue` prop (@react-native-picker/picker);
|
|
10
|
+
* - switch: a boolean `value` and `onValueChange` (Switch, expo-checkbox);
|
|
11
|
+
* - slider: `onValueChange` / `onSlidingComplete` and a numeric `value` or a `minimumValue` / `maximumValue`
|
|
12
|
+
* (@react-native-community/slider).
|
|
13
|
+
* DOM form controls (`<select>`, `<input type="date">` ...) rendered on the web are recognized by
|
|
14
|
+
* their host type when no such component owns them.
|
|
15
|
+
*
|
|
16
|
+
* This module is the `js` mode: it calls the component's callbacks with the events the native
|
|
17
|
+
* component would send. Real-input modes (actions/native.ts, actions/host.ts) use it to read the
|
|
18
|
+
* control and fall back to it where no real input exists (documented per control in docs/api.mdx).
|
|
19
|
+
*/
|
|
20
|
+
import type { ControlValue, DateValue } from '@quiesco/protocol';
|
|
21
|
+
|
|
22
|
+
import { QuiescoError } from '../errors';
|
|
23
|
+
import type { CompositeNode, HostNode } from '../fiber-adapter';
|
|
24
|
+
|
|
25
|
+
export type ControlKind = 'slider' | 'switch' | 'picker' | 'segmented' | 'date' | 'select' | 'input' | 'checkbox';
|
|
26
|
+
|
|
27
|
+
/** The DOM form control behind an element on the web. */
|
|
28
|
+
export type DomKind = 'select' | 'input' | 'checkbox';
|
|
29
|
+
|
|
30
|
+
export interface Control {
|
|
31
|
+
kind: ControlKind;
|
|
32
|
+
/** Props of the component that owns the value (or of the DOM element for DOM kinds). */
|
|
33
|
+
props: Readonly<Record<string, unknown>>;
|
|
34
|
+
/** Component name, when known (diagnostics). */
|
|
35
|
+
name: string | null;
|
|
36
|
+
/** The targeted host element; null for a component that renders none (Android DateTimePicker). */
|
|
37
|
+
node: HostNode | null;
|
|
38
|
+
/** The element is a DOM form control (web). */
|
|
39
|
+
dom?: DomKind;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PickerItem {
|
|
43
|
+
value: unknown;
|
|
44
|
+
label: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type Props = Readonly<Record<string, unknown>>;
|
|
48
|
+
type Fn = (...args: unknown[]) => unknown;
|
|
49
|
+
|
|
50
|
+
const isFn = (v: unknown): v is Fn => typeof v === 'function';
|
|
51
|
+
|
|
52
|
+
function classify(p: Props): ControlKind | null {
|
|
53
|
+
const change = isFn(p['onChange']) || isFn(p['onValueChange']);
|
|
54
|
+
if (p['value'] instanceof Date && change) return 'date';
|
|
55
|
+
if (Array.isArray(p['values']) && change) return 'segmented';
|
|
56
|
+
if (isFn(p['onValueChange']) && 'selectedValue' in p) return 'picker';
|
|
57
|
+
if (typeof p['value'] === 'boolean' && isFn(p['onValueChange'])) return 'switch';
|
|
58
|
+
if ((isFn(p['onValueChange']) || isFn(p['onSlidingComplete'])) && (typeof p['value'] === 'number' || 'minimumValue' in p || 'maximumValue' in p)) return 'slider';
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const DATE_INPUTS: ReadonlySet<string> = new Set(['date', 'time', 'datetime-local', 'month', 'week']);
|
|
63
|
+
const VALUE_INPUTS: ReadonlySet<string> = new Set([...DATE_INPUTS, 'range', 'number', 'color']);
|
|
64
|
+
|
|
65
|
+
function domKind(node: HostNode): DomKind | undefined {
|
|
66
|
+
if (node.type === 'select') return 'select';
|
|
67
|
+
if (node.type !== 'input') return undefined;
|
|
68
|
+
const t = String(node.props['type'] ?? 'text');
|
|
69
|
+
if (t === 'checkbox' || t === 'radio') return 'checkbox';
|
|
70
|
+
return VALUE_INPUTS.has(t) ? 'input' : undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Component kinds that render a DOM control of the given kind themselves (react-native-web's Switch,
|
|
75
|
+
* @react-native-picker/picker's web Picker). A value `<input>` belongs to no component kind: no community
|
|
76
|
+
* component renders one (datetimepicker has no web implementation), so a component with a Date `value`
|
|
77
|
+
* around it is the app's own wrapper, whose callbacks have the app's signature (measured: calling it as
|
|
78
|
+
* a datetimepicker crashed the example's DateField).
|
|
79
|
+
*/
|
|
80
|
+
const DOM_OWNERS: Readonly<Record<DomKind, readonly ControlKind[]>> = {
|
|
81
|
+
select: ['picker'],
|
|
82
|
+
checkbox: ['switch'],
|
|
83
|
+
input: [],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
function isHostNode(t: HostNode | CompositeNode): t is HostNode {
|
|
87
|
+
return 'measure' in t;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function unsupported(action: 'setValue' | 'adjustSlider' | 'accessibilityAction', message: string, platform: string, hint?: string): QuiescoError {
|
|
91
|
+
return new QuiescoError('ACTION_NOT_SUPPORTED', message, { action, platform, ...(hint !== undefined ? { hint } : {}) });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Function props of the element and the components around it (for "what could be called" errors). */
|
|
95
|
+
function callbacks(target: HostNode | CompositeNode): string[] {
|
|
96
|
+
const names = new Set<string>();
|
|
97
|
+
const add = (p: Props): void => {
|
|
98
|
+
for (const [k, v] of Object.entries(p)) if (isFn(v) && /^on[A-Z]/.test(k)) names.add(k);
|
|
99
|
+
};
|
|
100
|
+
add(target.props);
|
|
101
|
+
if (isHostNode(target)) add(target.ownerProps());
|
|
102
|
+
return [...names].sort();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The value control behind `target`, or ACTION_NOT_SUPPORTED naming the callbacks that were found. */
|
|
106
|
+
export function detectControl(target: HostNode | CompositeNode, platform: string): Control {
|
|
107
|
+
const node = isHostNode(target) ? target : null;
|
|
108
|
+
const dom = node === null ? undefined : domKind(node);
|
|
109
|
+
const found = target.findProps((p) => classify(p) !== null);
|
|
110
|
+
const kind = found === null ? null : classify(found.props);
|
|
111
|
+
if (found !== null && kind !== null && (dom === undefined || DOM_OWNERS[dom].includes(kind))) {
|
|
112
|
+
return { kind, props: found.props, name: found.name, node, ...(dom !== undefined ? { dom } : {}) };
|
|
113
|
+
}
|
|
114
|
+
if (node !== null && dom !== undefined) return { kind: dom, props: node.props, name: null, node, dom };
|
|
115
|
+
// A container holding exactly one control (e.g. a View with the testID around a component that
|
|
116
|
+
// does not pass testID on, like segmented-control's JS implementation on Android / web).
|
|
117
|
+
if (node !== null) {
|
|
118
|
+
const inside = controlsBelow(node);
|
|
119
|
+
if (inside.length === 1) return inside[0] as Control;
|
|
120
|
+
}
|
|
121
|
+
const cbs = callbacks(target);
|
|
122
|
+
throw unsupported(
|
|
123
|
+
'setValue',
|
|
124
|
+
`setValue does not know this element: no slider, switch, picker, segmented control or date picker found around it` +
|
|
125
|
+
(cbs.length > 0 ? ` (callbacks found: ${cbs.join(', ')})` : ' (it has no callbacks)'),
|
|
126
|
+
platform,
|
|
127
|
+
'text inputs: use replaceText; other controls: tap / drag them, or call the app through a backdoor',
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** How far up from a descendant a control's component may be when searching inside a container. */
|
|
132
|
+
const INSIDE_DEPTH = 4;
|
|
133
|
+
|
|
134
|
+
/** The distinct controls rendered inside `node` (each reported at its first host, in render order). */
|
|
135
|
+
function controlsBelow(node: HostNode): Control[] {
|
|
136
|
+
const seen = new Set<Props>();
|
|
137
|
+
const out: Control[] = [];
|
|
138
|
+
const visit = (n: HostNode): void => {
|
|
139
|
+
for (const c of n.children) {
|
|
140
|
+
const found = c.findProps((p) => classify(p) !== null, INSIDE_DEPTH);
|
|
141
|
+
const kind = found === null ? null : classify(found.props);
|
|
142
|
+
if (found !== null && kind !== null && isInside(found.props, node)) {
|
|
143
|
+
if (!seen.has(found.props)) {
|
|
144
|
+
seen.add(found.props);
|
|
145
|
+
out.push({ kind, props: found.props, name: found.name, node: c });
|
|
146
|
+
}
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
visit(c);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
visit(node);
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The component with `props` is below `container`: the container itself does not see it going up. */
|
|
157
|
+
function isInside(props: Props, container: HostNode): boolean {
|
|
158
|
+
return container.findProps((p) => p === props, INSIDE_DEPTH) === null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---- reading controls ----
|
|
162
|
+
|
|
163
|
+
function num(v: unknown, fallback: number): number {
|
|
164
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : fallback;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface SliderRange {
|
|
168
|
+
min: number;
|
|
169
|
+
max: number;
|
|
170
|
+
/** 0 = continuous. */
|
|
171
|
+
step: number;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** @react-native-community/slider defaults: 0 … 1, continuous. */
|
|
175
|
+
export function sliderRange(p: Props): SliderRange {
|
|
176
|
+
return { min: num(p['minimumValue'], 0), max: num(p['maximumValue'], 1), step: Math.max(0, num(p['step'], 0)) };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** `value` within the range and on a step (tolerance for float steps). */
|
|
180
|
+
export function checkSliderValue(range: SliderRange, value: number, platform: string): void {
|
|
181
|
+
const eps = 1e-9 * Math.max(1, Math.abs(range.max - range.min));
|
|
182
|
+
if (value < range.min - eps || value > range.max + eps) {
|
|
183
|
+
throw unsupported('setValue', `slider value ${value} is outside its range ${range.min} … ${range.max}`, platform);
|
|
184
|
+
}
|
|
185
|
+
if (range.step > 0) {
|
|
186
|
+
const k = (value - range.min) / range.step;
|
|
187
|
+
if (Math.abs(k - Math.round(k)) > 1e-6) {
|
|
188
|
+
throw unsupported('setValue', `slider value ${value} is not on a step (minimumValue ${range.min}, step ${range.step})`, platform);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The value of a slider at `position` (0 … 1), on a step. */
|
|
194
|
+
export function sliderValueAt(range: SliderRange, position: number): number {
|
|
195
|
+
const raw = range.min + position * (range.max - range.min);
|
|
196
|
+
if (range.step <= 0) return raw;
|
|
197
|
+
const snapped = range.min + Math.round((raw - range.min) / range.step) * range.step;
|
|
198
|
+
return Math.min(range.max, Math.max(range.min, snapped));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** React elements in `children` (arrays and fragments flattened), as plain props. */
|
|
202
|
+
function elementProps(children: unknown, out: Props[] = []): Props[] {
|
|
203
|
+
if (Array.isArray(children)) {
|
|
204
|
+
for (const c of children) elementProps(c, out);
|
|
205
|
+
} else if (typeof children === 'object' && children !== null && 'props' in children) {
|
|
206
|
+
const props = (children as { props: unknown }).props;
|
|
207
|
+
if (typeof props === 'object' && props !== null) {
|
|
208
|
+
const p = props as Props;
|
|
209
|
+
if ('value' in p || 'label' in p) out.push(p);
|
|
210
|
+
else elementProps(p['children'], out);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Items of a Picker (its `Picker.Item` children) or of a `<select>` (its `<option>` hosts). */
|
|
217
|
+
export function pickerItems(control: Control): PickerItem[] {
|
|
218
|
+
if (control.kind === 'select' && control.node !== null) {
|
|
219
|
+
return control.node.children
|
|
220
|
+
.filter((c) => c.type === 'option')
|
|
221
|
+
.map((o) => ({ value: o.props['value'] ?? o.text, label: typeof o.props['label'] === 'string' ? o.props['label'] : o.text }));
|
|
222
|
+
}
|
|
223
|
+
return elementProps(control.props['children']).map((p) => ({
|
|
224
|
+
value: p['value'] ?? p['label'],
|
|
225
|
+
label: typeof p['label'] === 'string' ? p['label'] : String(p['value'] ?? ''),
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Index of the item whose value (exactly, or as a string) or label is `value`. */
|
|
230
|
+
export function pickerIndex(control: Control, value: ControlValue, platform: string): number {
|
|
231
|
+
const items = pickerItems(control);
|
|
232
|
+
if (typeof value === 'object') throw unsupported('setValue', 'a picker takes an item value or label, not a date', platform);
|
|
233
|
+
let i = items.findIndex((it) => it.value === value);
|
|
234
|
+
if (i < 0) i = items.findIndex((it) => String(it.value) === String(value));
|
|
235
|
+
if (i < 0) i = items.findIndex((it) => it.label === value);
|
|
236
|
+
if (i < 0) {
|
|
237
|
+
const list = items.map((it) => (it.label === String(it.value) ? JSON.stringify(it.value) : `${JSON.stringify(it.value)} (${it.label})`)).join(', ');
|
|
238
|
+
throw unsupported('setValue', `the picker has no item ${JSON.stringify(value)}; items: ${list || 'none'}`, platform);
|
|
239
|
+
}
|
|
240
|
+
return i;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Index of the current selection of a picker / select, or -1. */
|
|
244
|
+
export function selectedPickerIndex(control: Control): number {
|
|
245
|
+
const items = pickerItems(control);
|
|
246
|
+
const sel = control.kind === 'select' ? control.props['value'] : control.props['selectedValue'];
|
|
247
|
+
return items.findIndex((it) => it.value === sel);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function segmentValues(control: Control): string[] {
|
|
251
|
+
const v = control.props['values'];
|
|
252
|
+
return Array.isArray(v) ? v.map(String) : [];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** A segment by its value, or by its index when given a number that is not a value. */
|
|
256
|
+
export function segmentIndex(control: Control, value: ControlValue, platform: string): number {
|
|
257
|
+
const values = segmentValues(control);
|
|
258
|
+
if (typeof value === 'object' || typeof value === 'boolean') throw unsupported('setValue', 'a segmented control takes a segment value or index', platform);
|
|
259
|
+
const byValue = values.indexOf(String(value));
|
|
260
|
+
if (byValue >= 0) return byValue;
|
|
261
|
+
if (typeof value === 'number' && Number.isInteger(value) && value >= 0 && value < values.length) return value;
|
|
262
|
+
throw unsupported('setValue', `the segmented control has no segment ${JSON.stringify(value)}; segments: ${values.map((v) => JSON.stringify(v)).join(', ')}`, platform);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function toDate(value: ControlValue, platform: string): Date {
|
|
266
|
+
if (typeof value === 'object' && value !== null && typeof (value as DateValue).date === 'string') {
|
|
267
|
+
const d = new Date((value as DateValue).date);
|
|
268
|
+
if (!Number.isNaN(d.getTime())) return d;
|
|
269
|
+
}
|
|
270
|
+
throw unsupported('setValue', `a date picker takes a date (Date), got ${JSON.stringify(value)}`, platform);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** A date within the picker's minimumDate / maximumDate. */
|
|
274
|
+
export function checkDate(props: Props, d: Date, platform: string): void {
|
|
275
|
+
const min = props['minimumDate'];
|
|
276
|
+
const max = props['maximumDate'];
|
|
277
|
+
if (min instanceof Date && d.getTime() < min.getTime()) throw unsupported('setValue', `${d.toISOString()} is before the picker's minimumDate ${min.toISOString()}`, platform);
|
|
278
|
+
if (max instanceof Date && d.getTime() > max.getTime()) throw unsupported('setValue', `${d.toISOString()} is after the picker's maximumDate ${max.toISOString()}`, platform);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const pad = (n: number, w = 2): string => String(n).padStart(w, '0');
|
|
282
|
+
|
|
283
|
+
/** The string an `<input type=...>` holds for `value` (local time for dates, like the browser shows). */
|
|
284
|
+
export function inputString(type: string, value: ControlValue, platform: string): string {
|
|
285
|
+
if (!DATE_INPUTS.has(type)) {
|
|
286
|
+
if (typeof value === 'object') throw unsupported('setValue', `an <input type="${type}"> takes a number or string`, platform);
|
|
287
|
+
return String(value);
|
|
288
|
+
}
|
|
289
|
+
const d = typeof value === 'string' ? null : toDate(value, platform);
|
|
290
|
+
if (d === null) return value as string; // already in the input's format
|
|
291
|
+
const day = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
292
|
+
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
293
|
+
switch (type) {
|
|
294
|
+
case 'date':
|
|
295
|
+
return day;
|
|
296
|
+
case 'time':
|
|
297
|
+
return time;
|
|
298
|
+
case 'month':
|
|
299
|
+
return day.slice(0, 7);
|
|
300
|
+
case 'datetime-local':
|
|
301
|
+
return `${day}T${time}`;
|
|
302
|
+
default: {
|
|
303
|
+
// ISO week: the Thursday of d's week decides the year.
|
|
304
|
+
const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
|
305
|
+
t.setUTCDate(t.getUTCDate() + 4 - (t.getUTCDay() || 7));
|
|
306
|
+
const week = Math.ceil(((t.getTime() - Date.UTC(t.getUTCFullYear(), 0, 1)) / 86400000 + 1) / 7);
|
|
307
|
+
return `${t.getUTCFullYear()}-W${pad(week)}`;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** The current boolean of a switch / checkbox. */
|
|
313
|
+
export function switchState(control: Control): boolean {
|
|
314
|
+
return control.dom === 'checkbox' && control.kind === 'checkbox' ? control.props['checked'] === true : control.props['value'] === true;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function isDisabled(p: Props): boolean {
|
|
318
|
+
return p['disabled'] === true || p['enabled'] === false;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** A disabled control: real input does nothing either, so modes leave it to jsSetValue (a no-op). */
|
|
322
|
+
export function isDisabledControl(control: Control): boolean {
|
|
323
|
+
return isDisabled(control.props);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function call(p: Props, name: string, ...args: unknown[]): void {
|
|
327
|
+
const f = p[name];
|
|
328
|
+
if (isFn(f)) f(...args);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function domEvent(value: string, extra: Record<string, unknown> = {}): Record<string, unknown> {
|
|
332
|
+
const target = { value, ...extra };
|
|
333
|
+
return { target, currentTarget: target, nativeEvent: { ...target }, type: 'change', persist() {}, preventDefault() {}, stopPropagation() {} };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Set the control through its callbacks (js mode), with the arguments the real component passes.
|
|
338
|
+
* A disabled control is left alone (like a user's attempt), and setting the current value sends no
|
|
339
|
+
* event (native controls report changes only).
|
|
340
|
+
*/
|
|
341
|
+
export function jsSetValue(control: Control, value: ControlValue, platform: string): void {
|
|
342
|
+
const p = control.props;
|
|
343
|
+
if (isDisabled(p)) return;
|
|
344
|
+
switch (control.kind) {
|
|
345
|
+
case 'slider': {
|
|
346
|
+
if (typeof value !== 'number') throw unsupported('setValue', `a slider takes a number, got ${JSON.stringify(value)}`, platform);
|
|
347
|
+
checkSliderValue(sliderRange(p), value, platform);
|
|
348
|
+
call(p, 'onSlidingStart', num(p['value'], sliderRange(p).min));
|
|
349
|
+
call(p, 'onValueChange', value);
|
|
350
|
+
call(p, 'onSlidingComplete', value);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
case 'switch': {
|
|
354
|
+
if (typeof value !== 'boolean') throw unsupported('setValue', `a switch takes true / false, got ${JSON.stringify(value)}`, platform);
|
|
355
|
+
if (switchState(control) === value) return;
|
|
356
|
+
// React Native's Switch calls onChange (native event) and onValueChange.
|
|
357
|
+
call(p, 'onChange', { nativeEvent: { value } });
|
|
358
|
+
call(p, 'onValueChange', value);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
case 'picker': {
|
|
362
|
+
const i = pickerIndex(control, value, platform);
|
|
363
|
+
if (i === selectedPickerIndex(control)) return;
|
|
364
|
+
const item = pickerItems(control)[i] as PickerItem;
|
|
365
|
+
call(p, 'onValueChange', item.value, i);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
case 'segmented': {
|
|
369
|
+
const i = segmentIndex(control, value, platform);
|
|
370
|
+
if (i === p['selectedIndex']) return;
|
|
371
|
+
const v = segmentValues(control)[i];
|
|
372
|
+
call(p, 'onChange', { nativeEvent: { value: v, selectedSegmentIndex: i } });
|
|
373
|
+
call(p, 'onValueChange', v);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
case 'date': {
|
|
377
|
+
const d = toDate(value, platform);
|
|
378
|
+
checkDate(p, d, platform);
|
|
379
|
+
// datetimepicker: { type: 'set', nativeEvent: { timestamp, utcOffset } }; onValueChange wins over onChange.
|
|
380
|
+
const event = { type: 'set', nativeEvent: { timestamp: d.getTime(), utcOffset: -d.getTimezoneOffset() } };
|
|
381
|
+
if (isFn(p['onValueChange'])) call(p, 'onValueChange', event, d);
|
|
382
|
+
else call(p, 'onChange', event, d);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
case 'select': {
|
|
386
|
+
const i = pickerIndex(control, value, platform);
|
|
387
|
+
const item = pickerItems(control)[i] as PickerItem;
|
|
388
|
+
call(p, 'onChange', domEvent(String(item.value), { selectedIndex: i }));
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
case 'input': {
|
|
392
|
+
const s = inputString(String(p['type'] ?? 'text'), value, platform);
|
|
393
|
+
call(p, 'onChange', domEvent(s));
|
|
394
|
+
call(p, 'onInput', domEvent(s));
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
case 'checkbox': {
|
|
398
|
+
if (typeof value !== 'boolean') throw unsupported('setValue', `a checkbox takes true / false, got ${JSON.stringify(value)}`, platform);
|
|
399
|
+
if ((p['checked'] === true) === value) return;
|
|
400
|
+
call(p, 'onChange', domEvent(value ? 'on' : '', { checked: value }));
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ---- accessibility actions ----
|
|
407
|
+
|
|
408
|
+
/** React Native's standard action names (others are custom actions with generated ids on Android). */
|
|
409
|
+
export const STANDARD_A11Y_ACTIONS: ReadonlySet<string> = new Set(['activate', 'longpress', 'increment', 'decrement', 'expand', 'collapse']);
|
|
410
|
+
|
|
411
|
+
export interface A11yTarget {
|
|
412
|
+
/** The host that declares the actions (has a native view), or null when only a component declares them. */
|
|
413
|
+
host: HostNode | null;
|
|
414
|
+
actions: { name: string; label?: string }[];
|
|
415
|
+
handler: Fn | null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function actionsOf(p: Props): { name: string; label?: string }[] | null {
|
|
419
|
+
const a = p['accessibilityActions'];
|
|
420
|
+
if (!Array.isArray(a)) return null;
|
|
421
|
+
return a
|
|
422
|
+
.filter((x): x is { name: unknown; label?: unknown } => typeof x === 'object' && x !== null)
|
|
423
|
+
.map((x) => ({ name: String(x.name), ...(typeof x.label === 'string' ? { label: x.label } : {}) }));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** The element (or the nearest ancestor) declaring `accessibilityActions`, and its handler. */
|
|
427
|
+
export function a11yTarget(target: HostNode | CompositeNode): A11yTarget | null {
|
|
428
|
+
if (isHostNode(target)) {
|
|
429
|
+
for (let n: HostNode | null = target; n !== null; n = n.parent) {
|
|
430
|
+
const actions = actionsOf(n.props);
|
|
431
|
+
if (actions !== null) {
|
|
432
|
+
const h = n.props['onAccessibilityAction'];
|
|
433
|
+
return { host: n, actions, handler: isFn(h) ? h : (n.findHandler('onAccessibilityAction')?.fn ?? null) };
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
// react-native-web does not pass accessibilityActions to the DOM: read the component's props.
|
|
438
|
+
const found = target.findProps((p) => actionsOf(p) !== null);
|
|
439
|
+
if (found === null) return null;
|
|
440
|
+
const h = found.props['onAccessibilityAction'];
|
|
441
|
+
return { host: null, actions: actionsOf(found.props) ?? [], handler: isFn(h) ? h : null };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** The declared action `name`, its index, and its index among the custom (non-standard) actions. */
|
|
445
|
+
export function declaredAction(t: A11yTarget, name: string, platform: string): { label: string; index: number; customIndex: number } {
|
|
446
|
+
const index = t.actions.findIndex((a) => a.name === name);
|
|
447
|
+
if (index < 0) {
|
|
448
|
+
const names = t.actions.map((a) => a.name).join(', ');
|
|
449
|
+
throw unsupported('accessibilityAction', `the element does not declare the accessibility action "${name}" (declared: ${names || 'none'})`, platform, 'add it to accessibilityActions; a screen reader can only perform declared actions');
|
|
450
|
+
}
|
|
451
|
+
const action = t.actions[index] as { name: string; label?: string };
|
|
452
|
+
const customIndex = STANDARD_A11Y_ACTIONS.has(name) ? -1 : t.actions.slice(0, index).filter((a) => !STANDARD_A11Y_ACTIONS.has(a.name)).length;
|
|
453
|
+
return { label: action.label ?? action.name, index, customIndex };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function jsAccessibilityAction(t: A11yTarget, name: string, platform: string): void {
|
|
457
|
+
declaredAction(t, name, platform);
|
|
458
|
+
if (t.handler === null) throw unsupported('accessibilityAction', 'the element declares accessibilityActions but has no onAccessibilityAction', platform);
|
|
459
|
+
t.handler({ nativeEvent: { actionName: name } });
|
|
460
|
+
}
|