@delacour/react-native-ui 0.4.1-alpha.20261020200151 → 0.4.1-alpha.20261021071622
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/package.json +1 -1
- package/src/components/badge/AGENTS.md +3 -1
- package/src/components/badge/index.ts +2 -0
- package/src/components/button/AGENTS.md +2 -1
- package/src/components/button/index.ts +2 -0
- package/src/components/pressable/AGENTS.md +15 -2
- package/src/components/pressable/pressable.tsx +23 -13
- package/src/components/pressable/pressable.variants.test.ts +180 -1
- package/src/components/pressable/pressable.variants.ts +82 -16
- package/src/components/screen/AGENTS.md +1 -1
- package/src/components/screen/index.ts +2 -0
- package/src/components/surface/AGENTS.md +1 -0
- package/src/components/surface/index.ts +2 -0
- package/src/components/text-animation/AGENTS.md +1 -1
- package/src/components/text-animation/index.ts +4 -0
- package/src/components/text-animation/text-animation.variants.test.ts +9 -0
- package/src/components/text-animation/text-animation.variants.ts +3 -1
- package/src/variant-axes.test.ts +87 -0
package/package.json
CHANGED
|
@@ -90,4 +90,6 @@ A compact label for status, category or count. Compound root plus `Badge.Label`,
|
|
|
90
90
|
- **`material="etched"` squares the capsule and tints.** The root and the close button take
|
|
91
91
|
`rounded-sm`; `soft` status fills become `bg-<status>/8` in light and `/16` in dark, written out
|
|
92
92
|
per colour because Tailwind's scanner cannot see a built class. The material rides the badge
|
|
93
|
-
context so the close button follows the root.
|
|
93
|
+
context so the close button follows the root. `BADGE_MATERIALS` and `BadgeMaterial` are exported
|
|
94
|
+
from the index beside the other three axes — a public prop whose type a caller cannot name is half
|
|
95
|
+
an API.
|
|
@@ -4,9 +4,11 @@ export type { BadgeSlotProps } from "./badge.types";
|
|
|
4
4
|
export {
|
|
5
5
|
BADGE_COLORS,
|
|
6
6
|
BADGE_FOREGROUND_TOKEN,
|
|
7
|
+
BADGE_MATERIALS,
|
|
7
8
|
BADGE_SIZES,
|
|
8
9
|
BADGE_VARIANTS,
|
|
9
10
|
type BadgeColor,
|
|
11
|
+
type BadgeMaterial,
|
|
10
12
|
type BadgeSize,
|
|
11
13
|
type BadgeVariant,
|
|
12
14
|
type BadgeVariantProps,
|
|
@@ -254,4 +254,5 @@ for a chunk that says something rather than doing something. An
|
|
|
254
254
|
inset top edge at 16%); `secondary`, `tertiary` and `outline` take the light/dark edge
|
|
255
255
|
(`shadow-etched dark:shadow-etched-dark`); `ghost`, `destructive` and `destructive-soft` stay flat
|
|
256
256
|
— a ghost has no surface to lift and a signal colour does not take a second highlight. The press
|
|
257
|
-
scale is already 0.97. The tokens live in `tokens.css`.
|
|
257
|
+
scale is already 0.97. The tokens live in `tokens.css`. `BUTTON_MATERIALS` and `ButtonMaterial`
|
|
258
|
+
are exported from the index like every other axis.
|
|
@@ -23,6 +23,7 @@ export {
|
|
|
23
23
|
BUTTON_GROUP_SEPARATOR_ORIENTATION,
|
|
24
24
|
BUTTON_ICON_SIZES,
|
|
25
25
|
BUTTON_LABEL_SIZES,
|
|
26
|
+
BUTTON_MATERIALS,
|
|
26
27
|
BUTTON_SIZES,
|
|
27
28
|
BUTTON_SPINNER_PLACEMENTS,
|
|
28
29
|
BUTTON_VARIANTS,
|
|
@@ -33,6 +34,7 @@ export {
|
|
|
33
34
|
type ButtonIconSize,
|
|
34
35
|
type ButtonLabelSize,
|
|
35
36
|
type ButtonLayout,
|
|
37
|
+
type ButtonMaterial,
|
|
36
38
|
type ButtonSize,
|
|
37
39
|
type ButtonSpinnerPlacement,
|
|
38
40
|
type ButtonVariant,
|
|
@@ -12,8 +12,8 @@ and only `onPress` and `onLongPress` cross back to JS.
|
|
|
12
12
|
| --- | --- |
|
|
13
13
|
| `index.ts` | → `@delacour/react-native-ui/pressable` |
|
|
14
14
|
| `pressable.tsx` | The Gesture API primitive |
|
|
15
|
-
| `pressable.variants.ts` | Shared feedback vocabulary, no RN imports |
|
|
16
|
-
| `pressable.variants.test.ts` | Feedback, rest opacity, accessibility state and
|
|
15
|
+
| `pressable.variants.ts` | Shared feedback vocabulary and screen-reader activation routing, no RN imports |
|
|
16
|
+
| `pressable.variants.test.ts` | Feedback, rest opacity, accessibility state, activation and action-dispatch matrices |
|
|
17
17
|
|
|
18
18
|
## Design
|
|
19
19
|
|
|
@@ -71,6 +71,19 @@ and only `onPress` and `onLongPress` cross back to JS.
|
|
|
71
71
|
`ProgressButton` — owns it on both platforms and the pressable wires nothing;
|
|
72
72
|
a caller's own `onAccessibilityTap` wins the same way. A caller's other
|
|
73
73
|
actions and its `onAccessibilityAction` are merged in, never replaced.
|
|
74
|
+
- **TalkBack's long press is wired to `onLongPress` the same way.**
|
|
75
|
+
Double-tap-and-hold performs `ACTION_LONG_CLICK`, which React Native routes
|
|
76
|
+
only to a listed `longpress` action and otherwise drops into an empty
|
|
77
|
+
`View.performLongClick` — so on Android the pressable appends `longpress`
|
|
78
|
+
whenever there is an `onLongPress`. iOS gets no entry: VoiceOver's
|
|
79
|
+
double-tap-and-hold passes a held touch through, which the long-press gesture
|
|
80
|
+
already recognises, and a `longpress` entry would only become a rotor custom
|
|
81
|
+
action. A caller that lists `longpress` itself — `SelectionMode.Item`,
|
|
82
|
+
`Attachment` — owns it, as with `activate`. The dispatch is two pure functions
|
|
83
|
+
in `pressable.variants.ts`: `dispatchPressableAccessibilityAction` maps an
|
|
84
|
+
action name to its handler, and `invokePressableHandler` is the
|
|
85
|
+
`isPressableInteractive` gate every screen-reader path calls exactly once.
|
|
86
|
+
A gated control consumes both actions and fires neither.
|
|
74
87
|
`Button`, `ListGroup.Item`, `Screen.Navbar.BackButton`, `Dialog.Close`,
|
|
75
88
|
`BottomSheet.Close` and every other `Pressable` consumer inherit it.
|
|
76
89
|
Verified on an iOS simulator with `AXPress` through Simulator.app's
|
|
@@ -18,6 +18,8 @@ import { useResolveClassNames } from "uniwind";
|
|
|
18
18
|
import { composeRefs } from "../../lib/compose-refs";
|
|
19
19
|
import { mergeProps } from "../../lib/merge-props";
|
|
20
20
|
import {
|
|
21
|
+
dispatchPressableAccessibilityAction,
|
|
22
|
+
invokePressableHandler,
|
|
21
23
|
isPressableInteractive,
|
|
22
24
|
type PressableFeedback,
|
|
23
25
|
resolvePressableAccessibilityState,
|
|
@@ -142,8 +144,9 @@ export type PressableProps = Omit<ViewProps, "style"> & {
|
|
|
142
144
|
*
|
|
143
145
|
* A gesture only ever sees touches, so a screen reader's activation is wired
|
|
144
146
|
* to `onPress` separately — `onAccessibilityTap` on iOS, an `activate` action on
|
|
145
|
-
* Android —
|
|
146
|
-
* `
|
|
147
|
+
* Android — and TalkBack's long press to `onLongPress` through a `longpress`
|
|
148
|
+
* action, all behind the same `disabled` / `busy` guard. See
|
|
149
|
+
* `resolvePressableActivation` and `dispatchPressableAccessibilityAction`.
|
|
147
150
|
*/
|
|
148
151
|
export function Pressable({
|
|
149
152
|
children,
|
|
@@ -220,26 +223,33 @@ export function Pressable({
|
|
|
220
223
|
const activation = resolvePressableActivation({
|
|
221
224
|
accessibilityActions,
|
|
222
225
|
hasAccessibilityTap: onAccessibilityTap !== undefined,
|
|
226
|
+
hasLongPress: onLongPress !== undefined,
|
|
223
227
|
hasPress: onPress !== undefined,
|
|
224
228
|
platform: Platform.OS,
|
|
225
229
|
});
|
|
226
|
-
const activate = () => {
|
|
227
|
-
if (interactive) onPress?.();
|
|
228
|
-
};
|
|
229
230
|
// Built once and spread last at both render sites, after `{...props}`, so a
|
|
230
231
|
// caller's actions and handlers are merged in here rather than replacing the
|
|
231
|
-
// activation the pressable adds. `activate`
|
|
232
|
-
// pressable added
|
|
232
|
+
// activation the pressable adds. `activate` and `longpress` are consumed only
|
|
233
|
+
// when the pressable added them; every other action reaches the caller's
|
|
234
|
+
// handler. Both paths share the gesture's `disabled` / `busy` guard.
|
|
233
235
|
const accessibilityWiring = {
|
|
234
236
|
accessibilityActions: activation.accessibilityActions,
|
|
235
237
|
onAccessibilityAction: (event: AccessibilityActionEvent) => {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
238
|
+
const consumed = dispatchPressableAccessibilityAction({
|
|
239
|
+
actionName: event.nativeEvent.actionName,
|
|
240
|
+
activation,
|
|
241
|
+
busy,
|
|
242
|
+
disabled,
|
|
243
|
+
onLongPress,
|
|
244
|
+
onPress,
|
|
245
|
+
});
|
|
246
|
+
if (!consumed) onAccessibilityAction?.(event);
|
|
241
247
|
},
|
|
242
|
-
onAccessibilityTap: activation.wiresAccessibilityTap
|
|
248
|
+
onAccessibilityTap: activation.wiresAccessibilityTap
|
|
249
|
+
? () => {
|
|
250
|
+
invokePressableHandler(onPress, { busy, disabled });
|
|
251
|
+
}
|
|
252
|
+
: onAccessibilityTap,
|
|
243
253
|
};
|
|
244
254
|
|
|
245
255
|
const content = asChild ? (
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import {
|
|
3
|
+
dispatchPressableAccessibilityAction,
|
|
4
|
+
invokePressableHandler,
|
|
3
5
|
isPressableInteractive,
|
|
4
6
|
PRESSABLE_FEEDBACK,
|
|
5
7
|
PRESSABLE_FEEDBACK_FALLBACK,
|
|
@@ -187,6 +189,7 @@ describe("resolvePressableActivation", () => {
|
|
|
187
189
|
expect(resolvePressableActivation({ hasAccessibilityTap: false, hasPress: true, platform: "ios" })).toEqual({
|
|
188
190
|
accessibilityActions: undefined,
|
|
189
191
|
routesActivate: false,
|
|
192
|
+
routesLongPress: false,
|
|
190
193
|
wiresAccessibilityTap: true,
|
|
191
194
|
});
|
|
192
195
|
});
|
|
@@ -213,6 +216,7 @@ describe("resolvePressableActivation", () => {
|
|
|
213
216
|
expect(resolved).toEqual({
|
|
214
217
|
accessibilityActions: [{ label: "Remove", name: "remove" }, { name: "activate" }],
|
|
215
218
|
routesActivate: true,
|
|
219
|
+
routesLongPress: false,
|
|
216
220
|
wiresAccessibilityTap: false,
|
|
217
221
|
});
|
|
218
222
|
});
|
|
@@ -234,7 +238,12 @@ describe("resolvePressableActivation", () => {
|
|
|
234
238
|
hasPress: true,
|
|
235
239
|
platform,
|
|
236
240
|
})
|
|
237
|
-
).toEqual({
|
|
241
|
+
).toEqual({
|
|
242
|
+
accessibilityActions: actions,
|
|
243
|
+
routesActivate: false,
|
|
244
|
+
routesLongPress: false,
|
|
245
|
+
wiresAccessibilityTap: false,
|
|
246
|
+
});
|
|
238
247
|
}
|
|
239
248
|
});
|
|
240
249
|
|
|
@@ -249,6 +258,7 @@ describe("resolvePressableActivation", () => {
|
|
|
249
258
|
expect(resolvePressableActivation({ hasAccessibilityTap: false, hasPress: false, platform })).toEqual({
|
|
250
259
|
accessibilityActions: undefined,
|
|
251
260
|
routesActivate: false,
|
|
261
|
+
routesLongPress: false,
|
|
252
262
|
wiresAccessibilityTap: false,
|
|
253
263
|
});
|
|
254
264
|
}
|
|
@@ -258,7 +268,176 @@ describe("resolvePressableActivation", () => {
|
|
|
258
268
|
expect(resolvePressableActivation({ hasAccessibilityTap: false, hasPress: true, platform: "web" })).toEqual({
|
|
259
269
|
accessibilityActions: undefined,
|
|
260
270
|
routesActivate: false,
|
|
271
|
+
routesLongPress: false,
|
|
272
|
+
wiresAccessibilityTap: false,
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("Android appends 'longpress' when there is an onLongPress, and routes it", () => {
|
|
277
|
+
expect(
|
|
278
|
+
resolvePressableActivation({
|
|
279
|
+
hasAccessibilityTap: false,
|
|
280
|
+
hasLongPress: true,
|
|
281
|
+
hasPress: true,
|
|
282
|
+
platform: "android",
|
|
283
|
+
})
|
|
284
|
+
).toEqual({
|
|
285
|
+
accessibilityActions: [{ name: "activate" }, { name: "longpress" }],
|
|
286
|
+
routesActivate: true,
|
|
287
|
+
routesLongPress: true,
|
|
288
|
+
wiresAccessibilityTap: false,
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("Android routes 'longpress' alone for a long-press-only pressable", () => {
|
|
293
|
+
expect(
|
|
294
|
+
resolvePressableActivation({
|
|
295
|
+
hasAccessibilityTap: false,
|
|
296
|
+
hasLongPress: true,
|
|
297
|
+
hasPress: false,
|
|
298
|
+
platform: "android",
|
|
299
|
+
})
|
|
300
|
+
).toEqual({
|
|
301
|
+
accessibilityActions: [{ name: "longpress" }],
|
|
302
|
+
routesActivate: false,
|
|
303
|
+
routesLongPress: true,
|
|
304
|
+
wiresAccessibilityTap: false,
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("a caller that lists 'longpress' owns it, and still gets 'activate' added", () => {
|
|
309
|
+
const resolved = resolvePressableActivation({
|
|
310
|
+
accessibilityActions: [{ label: "Start selecting", name: "longpress" }],
|
|
311
|
+
hasAccessibilityTap: false,
|
|
312
|
+
hasLongPress: true,
|
|
313
|
+
hasPress: true,
|
|
314
|
+
platform: "android",
|
|
315
|
+
});
|
|
316
|
+
expect(resolved).toEqual({
|
|
317
|
+
accessibilityActions: [{ label: "Start selecting", name: "longpress" }, { name: "activate" }],
|
|
318
|
+
routesActivate: true,
|
|
319
|
+
routesLongPress: false,
|
|
261
320
|
wiresAccessibilityTap: false,
|
|
262
321
|
});
|
|
263
322
|
});
|
|
323
|
+
|
|
324
|
+
test("iOS adds no 'longpress' entry — VoiceOver's double-tap-and-hold is a held touch the gesture sees", () => {
|
|
325
|
+
expect(
|
|
326
|
+
resolvePressableActivation({ hasAccessibilityTap: false, hasLongPress: true, hasPress: true, platform: "ios" })
|
|
327
|
+
).toEqual({
|
|
328
|
+
accessibilityActions: undefined,
|
|
329
|
+
routesActivate: false,
|
|
330
|
+
routesLongPress: false,
|
|
331
|
+
wiresAccessibilityTap: true,
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe("invokePressableHandler", () => {
|
|
337
|
+
test("fires the handler exactly once when interactive", () => {
|
|
338
|
+
let calls = 0;
|
|
339
|
+
expect(invokePressableHandler(() => calls++, { busy: false, disabled: false })).toBe(true);
|
|
340
|
+
expect(calls).toBe(1);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("never fires when disabled or busy", () => {
|
|
344
|
+
let calls = 0;
|
|
345
|
+
const handler = () => calls++;
|
|
346
|
+
expect(invokePressableHandler(handler, { busy: false, disabled: true })).toBe(false);
|
|
347
|
+
expect(invokePressableHandler(handler, { busy: true, disabled: false })).toBe(false);
|
|
348
|
+
expect(invokePressableHandler(handler, { busy: true, disabled: true })).toBe(false);
|
|
349
|
+
expect(calls).toBe(0);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("a missing handler is a no-op", () => {
|
|
353
|
+
expect(invokePressableHandler(undefined, { busy: false, disabled: false })).toBe(false);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
describe("dispatchPressableAccessibilityAction", () => {
|
|
358
|
+
const routed = { routesActivate: true, routesLongPress: true };
|
|
359
|
+
|
|
360
|
+
function counters() {
|
|
361
|
+
const calls = { longPress: 0, press: 0 };
|
|
362
|
+
return {
|
|
363
|
+
calls,
|
|
364
|
+
onLongPress: () => {
|
|
365
|
+
calls.longPress++;
|
|
366
|
+
},
|
|
367
|
+
onPress: () => {
|
|
368
|
+
calls.press++;
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
test("'activate' fires onPress once and is consumed", () => {
|
|
374
|
+
const { calls, onLongPress, onPress } = counters();
|
|
375
|
+
const consumed = dispatchPressableAccessibilityAction({
|
|
376
|
+
actionName: "activate",
|
|
377
|
+
activation: routed,
|
|
378
|
+
busy: false,
|
|
379
|
+
disabled: false,
|
|
380
|
+
onLongPress,
|
|
381
|
+
onPress,
|
|
382
|
+
});
|
|
383
|
+
expect(consumed).toBe(true);
|
|
384
|
+
expect(calls).toEqual({ longPress: 0, press: 1 });
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("'longpress' fires onLongPress once, never onPress", () => {
|
|
388
|
+
const { calls, onLongPress, onPress } = counters();
|
|
389
|
+
const consumed = dispatchPressableAccessibilityAction({
|
|
390
|
+
actionName: "longpress",
|
|
391
|
+
activation: routed,
|
|
392
|
+
busy: false,
|
|
393
|
+
disabled: false,
|
|
394
|
+
onLongPress,
|
|
395
|
+
onPress,
|
|
396
|
+
});
|
|
397
|
+
expect(consumed).toBe(true);
|
|
398
|
+
expect(calls).toEqual({ longPress: 1, press: 0 });
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("a disabled or busy control consumes both actions and fires nothing", () => {
|
|
402
|
+
const { calls, onLongPress, onPress } = counters();
|
|
403
|
+
for (const gate of [
|
|
404
|
+
{ busy: false, disabled: true },
|
|
405
|
+
{ busy: true, disabled: false },
|
|
406
|
+
]) {
|
|
407
|
+
for (const actionName of ["activate", "longpress"]) {
|
|
408
|
+
expect(
|
|
409
|
+
dispatchPressableAccessibilityAction({ actionName, activation: routed, ...gate, onLongPress, onPress })
|
|
410
|
+
).toBe(true);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
expect(calls).toEqual({ longPress: 0, press: 0 });
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("an action the pressable does not route is forwarded, untouched", () => {
|
|
417
|
+
const { calls, onLongPress, onPress } = counters();
|
|
418
|
+
const unrouted = { routesActivate: false, routesLongPress: false };
|
|
419
|
+
for (const actionName of ["activate", "longpress", "increment"]) {
|
|
420
|
+
expect(
|
|
421
|
+
dispatchPressableAccessibilityAction({
|
|
422
|
+
actionName,
|
|
423
|
+
activation: unrouted,
|
|
424
|
+
busy: false,
|
|
425
|
+
disabled: false,
|
|
426
|
+
onLongPress,
|
|
427
|
+
onPress,
|
|
428
|
+
})
|
|
429
|
+
).toBe(false);
|
|
430
|
+
}
|
|
431
|
+
expect(
|
|
432
|
+
dispatchPressableAccessibilityAction({
|
|
433
|
+
actionName: "increment",
|
|
434
|
+
activation: routed,
|
|
435
|
+
busy: false,
|
|
436
|
+
disabled: false,
|
|
437
|
+
onLongPress,
|
|
438
|
+
onPress,
|
|
439
|
+
})
|
|
440
|
+
).toBe(false);
|
|
441
|
+
expect(calls).toEqual({ longPress: 0, press: 0 });
|
|
442
|
+
});
|
|
264
443
|
});
|
|
@@ -125,16 +125,19 @@ export function isPressableInteractive({ busy, disabled }: { busy: boolean; disa
|
|
|
125
125
|
type PressableAccessibilityAction = { name: string; label?: string };
|
|
126
126
|
|
|
127
127
|
export type PressableActivation<A extends PressableAccessibilityAction> = {
|
|
128
|
-
/** What the view's `accessibilityActions` becomes — the caller's own, plus `activate` on Android. */
|
|
129
|
-
accessibilityActions: ReadonlyArray<A | { name: "activate" }> | undefined;
|
|
128
|
+
/** What the view's `accessibilityActions` becomes — the caller's own, plus `activate` / `longpress` on Android. */
|
|
129
|
+
accessibilityActions: ReadonlyArray<A | { name: "activate" } | { name: "longpress" }> | undefined;
|
|
130
130
|
/** The pressable consumes `activate` in `onAccessibilityAction` and calls `onPress` itself. */
|
|
131
131
|
routesActivate: boolean;
|
|
132
|
+
/** The pressable consumes `longpress` in `onAccessibilityAction` and calls `onLongPress` itself. */
|
|
133
|
+
routesLongPress: boolean;
|
|
132
134
|
/** The pressable sets `onAccessibilityTap`, which is what VoiceOver's double-tap reaches. */
|
|
133
135
|
wiresAccessibilityTap: boolean;
|
|
134
136
|
};
|
|
135
137
|
|
|
136
138
|
/**
|
|
137
|
-
* How a screen reader's activation reaches `onPress
|
|
139
|
+
* How a screen reader's activation reaches `onPress`, and its long press
|
|
140
|
+
* `onLongPress`.
|
|
138
141
|
*
|
|
139
142
|
* The press is a Gesture Handler tap, which only ever sees touches. Each
|
|
140
143
|
* platform's activation is something else:
|
|
@@ -146,40 +149,54 @@ export type PressableActivation<A extends PressableAccessibilityAction> = {
|
|
|
146
149
|
* call return `YES`, which also stops UIKit synthesising the touch: one
|
|
147
150
|
* activation, one `onPress`. An `activate` entry in `accessibilityActions` is
|
|
148
151
|
* no use here: Fabric lists every entry as a rotor custom action, so it would
|
|
149
|
-
* add a literal "activate" to every button's rotor.
|
|
152
|
+
* add a literal "activate" to every button's rotor. The same is true of
|
|
153
|
+
* `longpress`, and VoiceOver needs none: double-tap-and-hold passes a held
|
|
154
|
+
* touch through to the view, which the long-press gesture recognises.
|
|
150
155
|
* - **Android.** A TalkBack double-tap performs `ACTION_CLICK`, which React
|
|
151
156
|
* Native routes to `onAccessibilityAction` only when `activate` is listed.
|
|
152
157
|
* Without it the click falls to `View.performClick`, with no listener behind
|
|
153
|
-
* it.
|
|
158
|
+
* it. Double-tap-and-hold performs `ACTION_LONG_CLICK`, which reaches
|
|
159
|
+
* `onAccessibilityAction` only when `longpress` is listed and otherwise falls
|
|
160
|
+
* to an equally empty `View.performLongClick`. React Native ignores
|
|
161
|
+
* `onAccessibilityTap` on Android.
|
|
154
162
|
*
|
|
155
163
|
* A caller that lists `activate` itself owns it on both platforms — a
|
|
156
164
|
* `NumberInput` stepper, a `Message`, a `FlipCard` — and the pressable wires
|
|
157
|
-
* nothing, so the press cannot fire twice.
|
|
158
|
-
*
|
|
165
|
+
* nothing for it, so the press cannot fire twice. `longpress` is owned the same
|
|
166
|
+
* way, by `SelectionMode.Item` and `Attachment`. A caller's own
|
|
167
|
+
* `onAccessibilityTap` wins too. With no handler there is nothing to reach.
|
|
159
168
|
*
|
|
160
169
|
* The `disabled` / `busy` guard is not decided here: activation stays wired on
|
|
161
|
-
* a gated control and the handler
|
|
162
|
-
* control's double-tap is consumed and does nothing rather than falling
|
|
163
|
-
* to a synthesised touch.
|
|
170
|
+
* a gated control and the handler goes through `invokePressableHandler`, so a
|
|
171
|
+
* gated control's double-tap is consumed and does nothing rather than falling
|
|
172
|
+
* back to a synthesised touch.
|
|
164
173
|
*/
|
|
165
174
|
export function resolvePressableActivation<A extends PressableAccessibilityAction>({
|
|
166
175
|
accessibilityActions,
|
|
167
176
|
hasAccessibilityTap,
|
|
177
|
+
hasLongPress = false,
|
|
168
178
|
hasPress,
|
|
169
179
|
platform,
|
|
170
180
|
}: {
|
|
171
181
|
accessibilityActions?: ReadonlyArray<A>;
|
|
172
182
|
hasAccessibilityTap: boolean;
|
|
183
|
+
hasLongPress?: boolean;
|
|
173
184
|
hasPress: boolean;
|
|
174
185
|
platform: string;
|
|
175
186
|
}): PressableActivation<A> {
|
|
176
|
-
const
|
|
177
|
-
const
|
|
187
|
+
const owns = (name: string) => accessibilityActions?.some((action) => action.name === name) === true;
|
|
188
|
+
const wiresPress = hasPress && !owns("activate");
|
|
189
|
+
const wiresLongPress = hasLongPress && !owns("longpress");
|
|
178
190
|
|
|
179
|
-
if (
|
|
191
|
+
if (platform === "android" && (wiresPress || wiresLongPress)) {
|
|
180
192
|
return {
|
|
181
|
-
accessibilityActions: [
|
|
182
|
-
|
|
193
|
+
accessibilityActions: [
|
|
194
|
+
...(accessibilityActions ?? []),
|
|
195
|
+
...(wiresPress ? [{ name: "activate" as const }] : []),
|
|
196
|
+
...(wiresLongPress ? [{ name: "longpress" as const }] : []),
|
|
197
|
+
],
|
|
198
|
+
routesActivate: wiresPress,
|
|
199
|
+
routesLongPress: wiresLongPress,
|
|
183
200
|
wiresAccessibilityTap: false,
|
|
184
201
|
};
|
|
185
202
|
}
|
|
@@ -187,6 +204,55 @@ export function resolvePressableActivation<A extends PressableAccessibilityActio
|
|
|
187
204
|
return {
|
|
188
205
|
accessibilityActions,
|
|
189
206
|
routesActivate: false,
|
|
190
|
-
|
|
207
|
+
routesLongPress: false,
|
|
208
|
+
wiresAccessibilityTap: wiresPress && platform === "ios" && !hasAccessibilityTap,
|
|
191
209
|
};
|
|
192
210
|
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Calls a pressable's handler behind `isPressableInteractive` — the one call
|
|
214
|
+
* every screen-reader path makes, so a disabled or busy control can never be
|
|
215
|
+
* reached by one. Returns whether the handler ran. Exactly one call per
|
|
216
|
+
* invocation: nothing here retries, queues or re-dispatches.
|
|
217
|
+
*/
|
|
218
|
+
export function invokePressableHandler(
|
|
219
|
+
handler: (() => void) | undefined,
|
|
220
|
+
gate: { busy: boolean; disabled: boolean }
|
|
221
|
+
): boolean {
|
|
222
|
+
if (!handler || !isPressableInteractive(gate)) return false;
|
|
223
|
+
handler();
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Routes one `onAccessibilityAction` event. `activate` and `longpress` are
|
|
229
|
+
* consumed only when `resolvePressableActivation` added them — returning `true`
|
|
230
|
+
* whether or not the gate let the handler run, so a gated control swallows the
|
|
231
|
+
* action. Anything else returns `false` and belongs to the caller's own
|
|
232
|
+
* `onAccessibilityAction`.
|
|
233
|
+
*/
|
|
234
|
+
export function dispatchPressableAccessibilityAction({
|
|
235
|
+
actionName,
|
|
236
|
+
activation,
|
|
237
|
+
busy,
|
|
238
|
+
disabled,
|
|
239
|
+
onLongPress,
|
|
240
|
+
onPress,
|
|
241
|
+
}: {
|
|
242
|
+
actionName: string;
|
|
243
|
+
activation: { routesActivate: boolean; routesLongPress: boolean };
|
|
244
|
+
busy: boolean;
|
|
245
|
+
disabled: boolean;
|
|
246
|
+
onLongPress?: () => void;
|
|
247
|
+
onPress?: () => void;
|
|
248
|
+
}): boolean {
|
|
249
|
+
if (activation.routesActivate && actionName === "activate") {
|
|
250
|
+
invokePressableHandler(onPress, { busy, disabled });
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
if (activation.routesLongPress && actionName === "longpress") {
|
|
254
|
+
invokePressableHandler(onLongPress, { busy, disabled });
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
@@ -25,7 +25,7 @@ whole-screen states.
|
|
|
25
25
|
| `screen-header.tsx` | `Screen.Header` |
|
|
26
26
|
| `screen-footer.tsx` | `Screen.Footer` |
|
|
27
27
|
| `screen-footer-background.tsx` | The footer's backing and its top hairline — internal |
|
|
28
|
-
| `screen-scroll-shadow.tsx` | `Screen.ScrollShadow`, and `SCROLL_SHADOW_EDGES` |
|
|
28
|
+
| `screen-scroll-shadow.tsx` | `Screen.ScrollShadow`, and its two axes — `SCROLL_SHADOW_EDGES` and `SCROLL_SHADOW_ANCHORS` |
|
|
29
29
|
| `screen-scroll-area.tsx` | `Screen.ScrollArea` |
|
|
30
30
|
| `screen-flat-list.tsx` | `Screen.FlatList` |
|
|
31
31
|
| `screen-section-list.tsx` | `Screen.SectionList` |
|
|
@@ -65,8 +65,10 @@ export type { ScreenNavbarTitleProps } from "./screen-navbar-title";
|
|
|
65
65
|
export type { ScreenRootProps } from "./screen-root";
|
|
66
66
|
export type { ScreenScrollAreaProps } from "./screen-scroll-area";
|
|
67
67
|
export {
|
|
68
|
+
SCROLL_SHADOW_ANCHORS,
|
|
68
69
|
SCROLL_SHADOW_EDGES,
|
|
69
70
|
ScreenScrollShadow,
|
|
71
|
+
type ScreenScrollShadowAnchor,
|
|
70
72
|
type ScreenScrollShadowEdge,
|
|
71
73
|
type ScreenScrollShadowProps,
|
|
72
74
|
} from "./screen-scroll-shadow";
|
|
@@ -80,3 +80,4 @@ components built on it (`Card`, `Alert`) can read the plane they sit on.
|
|
|
80
80
|
down; `SurfaceContext.material` is how that panel knows to take `rounded-xl`, one step under the
|
|
81
81
|
tray's corner. A single `shadow-*` utility sets one `box-shadow`, which is why light and dark are
|
|
82
82
|
two tokens joined by `dark:` and not one composed class. Opt-in: nothing in the default look uses it.
|
|
83
|
+
`SURFACE_MATERIALS` and `SurfaceMaterial` are exported from the index like every other axis.
|
|
@@ -22,7 +22,7 @@ scope — it is an input, not a text effect.
|
|
|
22
22
|
| `text-animation-caret.tsx` | Internal leaf: the typing caret |
|
|
23
23
|
| `text-animation-clock.ts` | `useTextAnimationClock` — a frame-callback elapsed-ms shared value, pausable, keyed by run |
|
|
24
24
|
| `text-animation.types.ts` | `TextAnimationBase`, the props every part shares |
|
|
25
|
-
| `text-animation.variants.ts` | The slotted `tv()` + every pure resolver: splitting, stagger, effect frames, typing timeline, number format, roll targets |
|
|
25
|
+
| `text-animation.variants.ts` | The slotted `tv()` + every pure resolver: splitting, stagger, effect frames, typing timeline, number format, roll targets — and the five axes (`TEXT_ANIMATION_UNITS`, `_ORDERS`, `_EFFECT_KINDS`, `_CARET_KINDS`, `_SLIDE_DIRECTIONS`), each exported with its union |
|
|
26
26
|
| `text-animation.variants.test.ts` | |
|
|
27
27
|
|
|
28
28
|
## The root is Reveal
|
|
@@ -19,11 +19,15 @@ export {
|
|
|
19
19
|
TEXT_ANIMATION_CARET_KINDS,
|
|
20
20
|
TEXT_ANIMATION_EFFECT_KINDS,
|
|
21
21
|
TEXT_ANIMATION_ORDERS,
|
|
22
|
+
TEXT_ANIMATION_SLIDE_DIRECTIONS,
|
|
22
23
|
TEXT_ANIMATION_UNITS,
|
|
23
24
|
type TextAnimationCaret,
|
|
25
|
+
type TextAnimationCaretKind,
|
|
24
26
|
type TextAnimationEffect,
|
|
27
|
+
type TextAnimationEffectKind,
|
|
25
28
|
type TextAnimationNumberFormat,
|
|
26
29
|
type TextAnimationOrder,
|
|
30
|
+
type TextAnimationSlideFrom,
|
|
27
31
|
type TextAnimationTransition,
|
|
28
32
|
type TextAnimationUnit,
|
|
29
33
|
textAnimationVariants,
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
splitText,
|
|
24
24
|
TEXT_ANIMATION_CARET_KINDS,
|
|
25
25
|
TEXT_ANIMATION_ORDERS,
|
|
26
|
+
TEXT_ANIMATION_SLIDE_DIRECTIONS,
|
|
26
27
|
TEXT_ANIMATION_UNITS,
|
|
27
28
|
textAnimationVariants,
|
|
28
29
|
} from "./text-animation.variants";
|
|
@@ -205,6 +206,14 @@ describe("resolveEffectFrame", () => {
|
|
|
205
206
|
expect(resolveEffectFrame({ kind: "fade" }, 0)).toEqual({ opacity: 0, translateX: 0, translateY: 0, scale: 1 });
|
|
206
207
|
});
|
|
207
208
|
|
|
209
|
+
// The array is the public list of `from` values, so every entry must move the unit.
|
|
210
|
+
test("every slide direction starts off its rest position", () => {
|
|
211
|
+
for (const from of TEXT_ANIMATION_SLIDE_DIRECTIONS) {
|
|
212
|
+
const frame = resolveEffectFrame({ kind: "slide", from }, 0);
|
|
213
|
+
expect(Math.abs(frame.translateX) + Math.abs(frame.translateY)).toBe(12);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
208
217
|
test("slide starts its distance away on the side it comes from", () => {
|
|
209
218
|
expect(resolveEffectFrame({ kind: "slide", from: "up" }, 0).translateY).toBe(-12);
|
|
210
219
|
expect(resolveEffectFrame({ kind: "slide", from: "down" }, 0).translateY).toBe(12);
|
|
@@ -5,6 +5,7 @@ export const TEXT_ANIMATION_UNITS = ["character", "word", "line"] as const;
|
|
|
5
5
|
export const TEXT_ANIMATION_ORDERS = ["forward", "reverse", "center", "random"] as const;
|
|
6
6
|
export const TEXT_ANIMATION_EFFECT_KINDS = ["fade", "slide", "scale"] as const;
|
|
7
7
|
export const TEXT_ANIMATION_CARET_KINDS = ["none", "bar", "block"] as const;
|
|
8
|
+
export const TEXT_ANIMATION_SLIDE_DIRECTIONS = ["up", "down", "left", "right"] as const;
|
|
8
9
|
|
|
9
10
|
/** What one entering piece of a `Reveal` is. */
|
|
10
11
|
export type TextAnimationUnit = (typeof TEXT_ANIMATION_UNITS)[number];
|
|
@@ -12,7 +13,8 @@ export type TextAnimationUnit = (typeof TEXT_ANIMATION_UNITS)[number];
|
|
|
12
13
|
export type TextAnimationOrder = (typeof TEXT_ANIMATION_ORDERS)[number];
|
|
13
14
|
export type TextAnimationEffectKind = (typeof TEXT_ANIMATION_EFFECT_KINDS)[number];
|
|
14
15
|
export type TextAnimationCaretKind = (typeof TEXT_ANIMATION_CARET_KINDS)[number];
|
|
15
|
-
|
|
16
|
+
/** Where a `slide` effect enters from. */
|
|
17
|
+
export type TextAnimationSlideFrom = (typeof TEXT_ANIMATION_SLIDE_DIRECTIONS)[number];
|
|
16
18
|
|
|
17
19
|
/** How a unit arrives. Every kind fades in; `slide` and `scale` add a transform on top. */
|
|
18
20
|
export type TextAnimationEffect =
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const COMPONENTS = join(import.meta.dirname, "components");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Every variant axis a component declares must reach its public entry point.
|
|
9
|
+
*
|
|
10
|
+
* An axis is a `const` array and the union read off it —
|
|
11
|
+
* `BADGE_MATERIALS` and `BadgeMaterial`. The union types a prop; the array is how
|
|
12
|
+
* a caller iterates the values, builds a picker or a story, or narrows a string
|
|
13
|
+
* from outside. `Badge`, `Button` and `Surface` each grew a public `material`
|
|
14
|
+
* prop whose pair stayed behind `index.ts`, so a caller could pass the prop but
|
|
15
|
+
* not name its type without reaching into a file the package does not export.
|
|
16
|
+
*
|
|
17
|
+
* Like `docs.test.ts`, this reads the tree as **text** and imports nothing from
|
|
18
|
+
* it: an axis is recognised by its declaration,
|
|
19
|
+
* `export type Name = (typeof NAME)[number]`, and both names must appear in the
|
|
20
|
+
* folder's `index.ts` export lists. Declaring an axis that way is declaring it
|
|
21
|
+
* public — an internal union is a plain string-literal type and is not checked.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
type Axis = { file: string; type: string; values: string };
|
|
25
|
+
|
|
26
|
+
const AXIS = /^export type ([A-Za-z0-9_]+) = \(typeof ([A-Z0-9_]+)\)\[number\];/gm;
|
|
27
|
+
|
|
28
|
+
/** Every component folder's non-test modules, as relative path → source. */
|
|
29
|
+
function modules(folder: string): Map<string, string> {
|
|
30
|
+
const found = new Map<string, string>();
|
|
31
|
+
for (const entry of readdirSync(join(COMPONENTS, folder), { withFileTypes: true })) {
|
|
32
|
+
if (!entry.isFile() || !/\.tsx?$/.test(entry.name) || entry.name.includes(".test.")) continue;
|
|
33
|
+
if (entry.name === "index.ts") continue;
|
|
34
|
+
found.set(`${folder}/${entry.name}`, readFileSync(join(COMPONENTS, folder, entry.name), "utf-8"));
|
|
35
|
+
}
|
|
36
|
+
return found;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The axes one module declares. */
|
|
40
|
+
function axesIn(file: string, source: string): Axis[] {
|
|
41
|
+
return [...source.matchAll(AXIS)].map(([, type, values]) => ({ file, type, values }));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Every name inside an `export { … }` or `export type { … }` list of an index. */
|
|
45
|
+
function exportedNames(index: string): Set<string> {
|
|
46
|
+
const names = new Set<string>();
|
|
47
|
+
for (const [, list] of index.matchAll(/export (?:type )?\{([^}]*)\}/g)) {
|
|
48
|
+
for (const raw of list.split(",")) {
|
|
49
|
+
const name = raw
|
|
50
|
+
.trim()
|
|
51
|
+
.replace(/^type\s+/, "")
|
|
52
|
+
.split(/\s+as\s+/)
|
|
53
|
+
.pop();
|
|
54
|
+
if (name) names.add(name);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return names;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const FOLDERS = readdirSync(COMPONENTS, { withFileTypes: true })
|
|
61
|
+
.filter((entry) => entry.isDirectory())
|
|
62
|
+
.map((entry) => entry.name)
|
|
63
|
+
.sort();
|
|
64
|
+
|
|
65
|
+
const AXES = FOLDERS.flatMap((folder) => {
|
|
66
|
+
const exported = exportedNames(readFileSync(join(COMPONENTS, folder, "index.ts"), "utf-8"));
|
|
67
|
+
return [...modules(folder)].flatMap(([file, source]) => axesIn(file, source).map((axis) => ({ ...axis, exported })));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("variant axes", () => {
|
|
71
|
+
// A matcher that found nothing would pass every assertion below on an empty set.
|
|
72
|
+
test("finds the axes", () => {
|
|
73
|
+
expect(AXES.length).toBeGreaterThan(50);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("every axis union is exported from its component's index", () => {
|
|
77
|
+
const missing = AXES.filter((axis) => !axis.exported.has(axis.type)).map((axis) => `${axis.file}: ${axis.type}`);
|
|
78
|
+
expect(missing).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("every axis array is exported from its component's index", () => {
|
|
82
|
+
const missing = AXES.filter((axis) => !axis.exported.has(axis.values)).map(
|
|
83
|
+
(axis) => `${axis.file}: ${axis.values}`
|
|
84
|
+
);
|
|
85
|
+
expect(missing).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
});
|