@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-ui",
3
- "version": "0.4.1-alpha.20261020200151",
3
+ "version": "0.4.1-alpha.20261021071622",
4
4
  "description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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 activation matrices |
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 — behind the same `disabled` / `busy` guard. See
146
- * `resolvePressableActivation`.
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` is consumed only when the
232
- // pressable added it; every other action reaches the caller's handler.
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
- if (activation.routesActivate && event.nativeEvent.actionName === "activate") {
237
- activate();
238
- return;
239
- }
240
- onAccessibilityAction?.(event);
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 ? activate : onAccessibilityTap,
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({ accessibilityActions: actions, routesActivate: false, wiresAccessibilityTap: false });
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. React Native ignores `onAccessibilityTap` on Android.
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. A caller's own `onAccessibilityTap`
158
- * wins the same way. With no `onPress` there is nothing to reach.
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 checks `isPressableInteractive`, so a gated
162
- * control's double-tap is consumed and does nothing rather than falling back
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 ownedByCaller = accessibilityActions?.some((action) => action.name === "activate") === true;
177
- const wires = hasPress && !ownedByCaller;
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 (wires && platform === "android") {
191
+ if (platform === "android" && (wiresPress || wiresLongPress)) {
180
192
  return {
181
- accessibilityActions: [...(accessibilityActions ?? []), { name: "activate" }],
182
- routesActivate: true,
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
- wiresAccessibilityTap: wires && platform === "ios" && !hasAccessibilityTap,
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.
@@ -5,8 +5,10 @@ export {
5
5
  resolveSurfaceVariant,
6
6
  SURFACE_FILLED_VARIANTS,
7
7
  SURFACE_FOREGROUND_TOKENS,
8
+ SURFACE_MATERIALS,
8
9
  SURFACE_PADDINGS,
9
10
  SURFACE_VARIANTS,
11
+ type SurfaceMaterial,
10
12
  type SurfacePadding,
11
13
  type SurfacePlane,
12
14
  type SurfaceVariant,
@@ -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
- export type TextAnimationSlideFrom = "up" | "down" | "left" | "right";
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
+ });