mobile-design 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.
Files changed (121) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSE-Apache-2.0.txt +55 -0
  3. package/NOTICE +11 -0
  4. package/README.md +96 -2
  5. package/assets/fonts/MaterialSymbolsRounded.ttf +0 -0
  6. package/assets/fonts/MaterialSymbolsRounded.woff2 +0 -0
  7. package/assets/fonts/roboto/OFL.txt +93 -0
  8. package/assets/fonts/roboto/Roboto_400Regular.ttf +0 -0
  9. package/assets/fonts/roboto/Roboto_400Regular.woff2 +0 -0
  10. package/assets/fonts/roboto/Roboto_400Regular_Italic.ttf +0 -0
  11. package/assets/fonts/roboto/Roboto_400Regular_Italic.woff2 +0 -0
  12. package/assets/fonts/roboto/Roboto_500Medium.ttf +0 -0
  13. package/assets/fonts/roboto/Roboto_500Medium.woff2 +0 -0
  14. package/assets/fonts/roboto/Roboto_500Medium_Italic.ttf +0 -0
  15. package/assets/fonts/roboto/Roboto_500Medium_Italic.woff2 +0 -0
  16. package/assets/fonts/roboto/Roboto_600SemiBold.ttf +0 -0
  17. package/assets/fonts/roboto/Roboto_600SemiBold.woff2 +0 -0
  18. package/assets/fonts/roboto/Roboto_600SemiBold_Italic.ttf +0 -0
  19. package/assets/fonts/roboto/Roboto_600SemiBold_Italic.woff2 +0 -0
  20. package/assets/fonts/roboto/Roboto_700Bold.ttf +0 -0
  21. package/assets/fonts/roboto/Roboto_700Bold.woff2 +0 -0
  22. package/assets/fonts/roboto/Roboto_700Bold_Italic.ttf +0 -0
  23. package/assets/fonts/roboto/Roboto_700Bold_Italic.woff2 +0 -0
  24. package/assets/fonts/roboto/Roboto_800ExtraBold.ttf +0 -0
  25. package/assets/fonts/roboto/Roboto_800ExtraBold.woff2 +0 -0
  26. package/assets/fonts/roboto/Roboto_800ExtraBold_Italic.ttf +0 -0
  27. package/assets/fonts/roboto/Roboto_800ExtraBold_Italic.woff2 +0 -0
  28. package/assets/logo.svg +13 -0
  29. package/package.json +62 -3
  30. package/src/catalog.ts +683 -0
  31. package/src/color-scheme.ts +68 -0
  32. package/src/color.ts +35 -0
  33. package/src/components/action-surface.tsx +83 -0
  34. package/src/components/appbar-layout.ts +85 -0
  35. package/src/components/appbar-scroll-behavior.tsx +386 -0
  36. package/src/components/appbar.tsx +474 -0
  37. package/src/components/avatar.tsx +135 -0
  38. package/src/components/bottom-sheet-text-field.tsx +13 -0
  39. package/src/components/bottom-sheet-text-input.tsx +2 -0
  40. package/src/components/bottom-sheet-text-input.web.tsx +2 -0
  41. package/src/components/button-group.tsx +996 -0
  42. package/src/components/button.tsx +504 -0
  43. package/src/components/card.tsx +284 -0
  44. package/src/components/carousel-full-screen.tsx +374 -0
  45. package/src/components/carousel-layout.ts +418 -0
  46. package/src/components/carousel-scroll-controller.tsx +303 -0
  47. package/src/components/carousel-vertical-layout.ts +92 -0
  48. package/src/components/carousel-vertical.tsx +505 -0
  49. package/src/components/carousel.tsx +678 -0
  50. package/src/components/choice-motion.tsx +108 -0
  51. package/src/components/choice-types.ts +52 -0
  52. package/src/components/choice.tsx +400 -0
  53. package/src/components/choice.web.ts +1 -0
  54. package/src/components/circular-progress-status.tsx +218 -0
  55. package/src/components/container-transform.tsx +304 -0
  56. package/src/components/content.tsx +1450 -0
  57. package/src/components/date-picker-calendar.tsx +933 -0
  58. package/src/components/date-picker-months.tsx +102 -0
  59. package/src/components/date-picker-utils.ts +96 -0
  60. package/src/components/date-picker.tsx +641 -0
  61. package/src/components/dialog.tsx +340 -0
  62. package/src/components/docked-date-picker.tsx +525 -0
  63. package/src/components/drawer.tsx +248 -0
  64. package/src/components/fab.tsx +1221 -0
  65. package/src/components/focus-order.ts +40 -0
  66. package/src/components/full-screen-dialog.tsx +234 -0
  67. package/src/components/icon-button.tsx +72 -0
  68. package/src/components/list-focus.tsx +182 -0
  69. package/src/components/list-reorder.tsx +349 -0
  70. package/src/components/loading-indicator.tsx +378 -0
  71. package/src/components/menu.tsx +1091 -0
  72. package/src/components/navigation-bar.tsx +390 -0
  73. package/src/components/navigation-focus.ts +45 -0
  74. package/src/components/navigation-rail-layout.ts +53 -0
  75. package/src/components/navigation.tsx +682 -0
  76. package/src/components/popup.tsx +624 -0
  77. package/src/components/press-scope.ts +17 -0
  78. package/src/components/progress-indicator-paths.ts +254 -0
  79. package/src/components/progress-indicator.tsx +570 -0
  80. package/src/components/progress.tsx +39 -0
  81. package/src/components/pull-to-refresh.tsx +379 -0
  82. package/src/components/rich-tooltip.tsx +183 -0
  83. package/src/components/search-icon-button.tsx +8 -0
  84. package/src/components/search-trigger.tsx +28 -0
  85. package/src/components/search-view.tsx +790 -0
  86. package/src/components/searchbar.tsx +190 -0
  87. package/src/components/segmented-buttons.tsx +456 -0
  88. package/src/components/selection-controls.tsx +760 -0
  89. package/src/components/sheets.tsx +1027 -0
  90. package/src/components/slider.tsx +1290 -0
  91. package/src/components/snackbar.tsx +430 -0
  92. package/src/components/split-button.tsx +525 -0
  93. package/src/components/swipe-list-item.tsx +793 -0
  94. package/src/components/switch.tsx +319 -0
  95. package/src/components/tabs-pager.tsx +241 -0
  96. package/src/components/tabs-pager.web.tsx +110 -0
  97. package/src/components/tabs.tsx +759 -0
  98. package/src/components/text-field.tsx +673 -0
  99. package/src/components/time-picker.tsx +1248 -0
  100. package/src/components/toolbar.tsx +590 -0
  101. package/src/components/tooltip.tsx +273 -0
  102. package/src/components/web-choice.tsx +572 -0
  103. package/src/core.tsx +169 -0
  104. package/src/font-assets.ts +28 -0
  105. package/src/font-assets.web.ts +25 -0
  106. package/src/index.ts +139 -0
  107. package/src/material-symbol-codepoints.ts +67 -0
  108. package/src/material-symbol-paths.ts +340 -0
  109. package/src/material-symbols.tsx +155 -0
  110. package/src/native-modal.tsx +50 -0
  111. package/src/outside-interaction.tsx +39 -0
  112. package/src/paper-icon-aliases.ts +147 -0
  113. package/src/paper-internals.d.ts +15 -0
  114. package/src/paper-motion-bridge.tsx +11 -0
  115. package/src/paper-motion.tsx +24 -0
  116. package/src/primitives.tsx +13 -0
  117. package/src/ripple.tsx +7 -0
  118. package/src/ripple.web.tsx +108 -0
  119. package/src/scroll-indicators.ts +12 -0
  120. package/src/typography.ts +9 -0
  121. package/src/web-accessibility.ts +19 -0
@@ -0,0 +1,793 @@
1
+ import { TouchableRipple } from '../ripple';
2
+ import {
3
+ createContext,
4
+ forwardRef,
5
+ useContext,
6
+ useEffect,
7
+ useImperativeHandle,
8
+ useLayoutEffect,
9
+ useRef,
10
+ useState,
11
+ type ComponentProps,
12
+ type ReactNode,
13
+ } from 'react';
14
+ import { Platform, Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
15
+ import { Icon, useLocale, type Theme } from 'react-native-paper';
16
+ import { GestureDetector, usePanGesture } from 'react-native-gesture-handler';
17
+ import Animated, {
18
+ cancelAnimation,
19
+ ReduceMotion,
20
+ useAnimatedProps,
21
+ useAnimatedReaction,
22
+ useAnimatedStyle,
23
+ useSharedValue,
24
+ withSpring,
25
+ type SharedValue,
26
+ } from 'react-native-reanimated';
27
+ import { scheduleOnRN, scheduleOnUI } from 'react-native-worklets';
28
+ import { useMobileTheme } from '../core';
29
+ import { withAlpha } from '../color';
30
+
31
+ export type SwipeListSide = 'leading' | 'trailing';
32
+ export type SwipeListAction = {
33
+ key: string;
34
+ label: string;
35
+ icon: ComponentProps<typeof Icon>['source'];
36
+ onPress: () => void;
37
+ variant?: 'filled' | 'tonal' | 'outlined' | 'standard';
38
+ destructive?: boolean;
39
+ disabled?: boolean;
40
+ };
41
+ export type SwipeListItemHandle = { open: (side: SwipeListSide) => void; close: () => void; reset: () => void };
42
+ export type SwipeListGroupHandle = { closeAll: () => void };
43
+ export type SwipeListItemProps = {
44
+ id: string;
45
+ children: ReactNode;
46
+ /** Actions retain 64dp targets plus 2dp margins and must fit the measured row. Put additional actions in a consumer Menu. */
47
+ leadingActions?: readonly SwipeListAction[];
48
+ /** Actions retain 64dp targets plus 2dp margins and must fit the measured row. Put additional actions in a consumer Menu. */
49
+ trailingActions?: readonly SwipeListAction[];
50
+ leadingSwipeMode?: 'disabled' | 'direct' | 'indirect';
51
+ trailingSwipeMode?: 'disabled' | 'direct' | 'indirect';
52
+ disabled?: boolean;
53
+ onOpenChange?: (side: SwipeListSide | null) => void;
54
+ closeAccessibilityLabel?: string;
55
+ style?: StyleProp<ViewStyle>;
56
+ testID?: string;
57
+ };
58
+ type SwipeCoordinator = { active: SharedValue<string>; closeRows: Map<string, () => void> };
59
+ const GroupContext = createContext<SwipeCoordinator | null>(null);
60
+ /** One shared UI-thread coordinator; opening a row closes its peers without JS-frame callbacks. */
61
+ export const SwipeListGroup = forwardRef<SwipeListGroupHandle, { children: ReactNode }>(function SwipeListGroup({ children }, ref) {
62
+ const active = useSharedValue('');
63
+ const coordinator = useRef<SwipeCoordinator>({ active, closeRows: new Map() }).current;
64
+ useImperativeHandle(
65
+ ref,
66
+ () => ({
67
+ closeAll() {
68
+ // Invalidate RN callbacks now, before the coordinator's native
69
+ // shared-value write reaches UI. Peers still settle on UI.
70
+ coordinator.closeRows.forEach((close) => close());
71
+ active.set('');
72
+ },
73
+ }),
74
+ [active, coordinator]
75
+ );
76
+ return <GroupContext.Provider value={coordinator}>{children}</GroupContext.Provider>;
77
+ });
78
+ const EMPTY: readonly SwipeListAction[] = [];
79
+ const ACTION_WIDTH = 64,
80
+ ACTION_MARGIN = 2,
81
+ CELL_WIDTH = ACTION_WIDTH + ACTION_MARGIN * 2;
82
+ function clamp(value: number, min: number, max: number) {
83
+ 'worklet';
84
+ return Math.max(min, Math.min(max, value));
85
+ }
86
+ /** MDC ListItemLayout: velocity500, nearest stable offsets and direct/indirect primary states. */
87
+ export function swipeListTarget(
88
+ distance: number,
89
+ velocity: number,
90
+ reveal: number,
91
+ width: number,
92
+ mode: 'disabled' | 'direct' | 'indirect',
93
+ wasOpen: boolean
94
+ ): 'closed' | 'open' | 'primary' {
95
+ 'worklet';
96
+ if (reveal <= 0 || width <= 0) return 'closed';
97
+ if (velocity > 500) return mode === 'disabled' || (!wasOpen && mode !== 'direct') ? 'open' : 'primary';
98
+ if (velocity < -500) return 'closed';
99
+ if (mode !== 'disabled' && Math.abs(distance - width) < Math.abs(distance - reveal)) return 'primary';
100
+ const target = mode === 'direct' ? width : reveal;
101
+ return Math.abs(distance - target) < distance ? (mode === 'direct' ? 'primary' : 'open') : 'closed';
102
+ }
103
+ /** Pure geometry for drawing backgrounds/icons; hit targets retain64dp at the stable open state. */
104
+ export function swipeActionGeometry(revealed: number, width: number, count: number, index: number, leading: boolean) {
105
+ 'worklet';
106
+ const intrinsic = count * CELL_WIDTH;
107
+ const normal = Math.min(1, revealed / Math.max(1, intrinsic));
108
+ const primary = leading ? index === 0 : index === count - 1;
109
+ const full = clamp((revealed - intrinsic) / Math.max(1, width - intrinsic), 0, 1);
110
+ const small = Math.max(6, ACTION_WIDTH * normal);
111
+ const secondaryWidth = small + (6 - small) * full;
112
+ const primaryWidth =
113
+ revealed <= intrinsic
114
+ ? small
115
+ : ACTION_WIDTH + (width - (count - 1) * (6 + ACTION_MARGIN * 2) - ACTION_MARGIN * 2 - ACTION_WIDTH) * full;
116
+ let start = leading ? 0 : width - revealed;
117
+ for (let before = 0; before < index; before++)
118
+ start += ((leading ? before === 0 : before === count - 1) ? primaryWidth : secondaryWidth) + ACTION_MARGIN * 2 * normal;
119
+ const actionWidth = primary ? primaryWidth : secondaryWidth;
120
+ start += ACTION_MARGIN * normal;
121
+ const fade = clamp((revealed / Math.max(1, intrinsic) - 0.25) / 0.25, 0, 1) * (primary ? 1 : 1 - full);
122
+ return { start, width: actionWidth, center: start + actionWidth / 2, opacity: fade };
123
+ }
124
+
125
+ /** MD3 swipe-to-reveal, with opt-in full swipe. The last trailing/first leading action is primary. */
126
+ export const SwipeListItem = forwardRef<SwipeListItemHandle, SwipeListItemProps>(function SwipeListItem(
127
+ {
128
+ id,
129
+ children,
130
+ leadingActions = EMPTY,
131
+ trailingActions = EMPTY,
132
+ leadingSwipeMode = 'disabled',
133
+ trailingSwipeMode = 'disabled',
134
+ disabled = false,
135
+ onOpenChange,
136
+ closeAccessibilityLabel = 'Close actions',
137
+ style,
138
+ testID = `swipe-list-${id}`,
139
+ },
140
+ ref
141
+ ) {
142
+ const theme = useMobileTheme(),
143
+ { direction } = useLocale();
144
+ const group = useContext(GroupContext),
145
+ ownCoordinator = useSharedValue('');
146
+ const active = group?.active ?? ownCoordinator;
147
+ const rtl = direction === 'rtl',
148
+ sign = rtl ? -1 : 1;
149
+ const offset = useSharedValue(0),
150
+ width = useSharedValue(0),
151
+ height = useSharedValue(72);
152
+ const start = useSharedValue(0),
153
+ stable = useSharedValue(0),
154
+ revision = useSharedValue(0),
155
+ aliveUI = useSharedValue(true),
156
+ dragging = useSharedValue(false),
157
+ uiCommandRevision = useSharedValue(0),
158
+ settling = useSharedValue(false),
159
+ settlingTarget = useSharedValue(0),
160
+ settlingPrimary = useSharedValue(false);
161
+ const [open, setOpen] = useState<SwipeListSide | null>(null),
162
+ [measuredWidth, setMeasuredWidth] = useState(0);
163
+ for (const [side, actions, mode] of [
164
+ ['leading', leadingActions, leadingSwipeMode],
165
+ ['trailing', trailingActions, trailingSwipeMode],
166
+ ] as const) {
167
+ if (mode === 'direct' && actions.length > 1)
168
+ throw new RangeError(`SwipeListItem ${side} direct swipe requires exactly one action.`);
169
+ if (measuredWidth > 0 && actions.length * CELL_WIDTH > measuredWidth)
170
+ throw new RangeError(
171
+ `SwipeListItem ${side} actions need ${actions.length * CELL_WIDTH}dp but the row has ${measuredWidth}dp. Put additional actions in a Menu.`
172
+ );
173
+ }
174
+ const openRef = useRef<SwipeListSide | null>(null),
175
+ mounted = useRef(true),
176
+ commandRevision = useRef(0),
177
+ settledCommand = useRef<number | null>(null);
178
+ const latest = useRef({ leadingActions, trailingActions, disabled, onOpenChange });
179
+ useLayoutEffect(() => {
180
+ latest.current = { leadingActions, trailingActions, disabled, onOpenChange };
181
+ }, [leadingActions, trailingActions, disabled, onOpenChange]);
182
+ const setSettled = (target: number, token: number, command: number) => {
183
+ if (!mounted.current || commandRevision.current !== command || revision.get() !== token) return;
184
+ const side = target === 0 ? null : target > 0 ? 'leading' : 'trailing';
185
+ settledCommand.current = side ? command : null;
186
+ setOpen(side);
187
+ if (side !== openRef.current) {
188
+ openRef.current = side;
189
+ latest.current.onOpenChange?.(side);
190
+ }
191
+ };
192
+ const resetUI = (command: number) => {
193
+ 'worklet';
194
+ uiCommandRevision.set(command);
195
+ revision.set(revision.get() + 1);
196
+ cancelAnimation(offset);
197
+ offset.set(0);
198
+ stable.set(0);
199
+ dragging.set(false);
200
+ settling.set(false);
201
+ if (active.get() === id) active.set('');
202
+ scheduleOnRN(setSettled, 0, revision.get(), command);
203
+ };
204
+ const reset = () => {
205
+ if (!mounted.current) return;
206
+ const command = ++commandRevision.current;
207
+ settledCommand.current = null;
208
+ setOpen(null);
209
+ if (openRef.current !== null) {
210
+ openRef.current = null;
211
+ latest.current.onOpenChange?.(null);
212
+ }
213
+ scheduleOnUI(resetUI, command);
214
+ };
215
+ const invoke = (side: SwipeListSide, key: string, token?: number, command?: number) => {
216
+ if (
217
+ !mounted.current ||
218
+ (token !== undefined && (revision.get() !== token || commandRevision.current !== command)) ||
219
+ active.get() !== id ||
220
+ latest.current.disabled
221
+ )
222
+ return;
223
+ if (
224
+ token === undefined &&
225
+ (settledCommand.current !== commandRevision.current || openRef.current !== side || active.get() !== id || dragging.get())
226
+ )
227
+ return;
228
+ const action = latest.current[side === 'leading' ? 'leadingActions' : 'trailingActions'].find((item) => item.key === key);
229
+ if (!action || action.disabled) {
230
+ reset();
231
+ return;
232
+ }
233
+ reset();
234
+ action.onPress();
235
+ };
236
+ const leadingPrimary = leadingActions[0];
237
+ const trailingPrimary = trailingActions[trailingActions.length - 1];
238
+ const leadingPrimaryKey = leadingPrimary?.key,
239
+ trailingPrimaryKey = trailingPrimary?.key;
240
+ const leadingCount = leadingActions.length,
241
+ trailingCount = trailingActions.length;
242
+ const leadingMode = !leadingPrimary || leadingPrimary.disabled ? 'disabled' : leadingSwipeMode;
243
+ const trailingMode = !trailingPrimary || trailingPrimary.disabled ? 'disabled' : trailingSwipeMode;
244
+ const spring = theme.motion.spring.fast.spatial;
245
+ const settle = (target: number, velocity = 0, primary = false) => {
246
+ 'worklet';
247
+ revision.set(revision.get() + 1);
248
+ const token = revision.get(),
249
+ command = uiCommandRevision.get();
250
+ dragging.set(false);
251
+ settling.set(true);
252
+ settlingTarget.set(target);
253
+ settlingPrimary.set(primary);
254
+ cancelAnimation(offset);
255
+ const complete = (finished?: boolean) => {
256
+ 'worklet';
257
+ if (!finished || !aliveUI.get() || revision.get() !== token) return;
258
+ settling.set(false);
259
+ if (primary) {
260
+ const side = target > 0 ? 'leading' : 'trailing';
261
+ const key = target > 0 ? leadingPrimaryKey : trailingPrimaryKey;
262
+ if (key) scheduleOnRN(invoke, side, key, token, command);
263
+ } else {
264
+ stable.set(target);
265
+ if (target === 0 && active.get() === id) active.set('');
266
+ scheduleOnRN(setSettled, target, token, command);
267
+ }
268
+ };
269
+ if (!theme.animation.scale) {
270
+ offset.set(target);
271
+ complete(true);
272
+ } else
273
+ offset.set(
274
+ withSpring(
275
+ target,
276
+ {
277
+ stiffness: spring.stiffness,
278
+ damping: 2 * spring.damping * Math.sqrt(spring.stiffness),
279
+ mass: 1,
280
+ velocity,
281
+ overshootClamping: target === 0 || primary,
282
+ reduceMotion: ReduceMotion.Never,
283
+ },
284
+ complete
285
+ )
286
+ );
287
+ };
288
+ // Reanimated native writes from RN are asynchronous. Keep increment/read,
289
+ // cancellation and spring creation in one UI transaction; otherwise an RN
290
+ // increment followed by get() captures the old revision and rejects its own
291
+ // completion. The RN epoch also rejects callbacks already queued before a
292
+ // newer command has reached the UI runtime.
293
+ const close = () => {
294
+ if (!mounted.current) return;
295
+ const command = ++commandRevision.current;
296
+ scheduleOnUI((nextCommand: number) => {
297
+ 'worklet';
298
+ if (!aliveUI.get()) return;
299
+ uiCommandRevision.set(nextCommand);
300
+ settle(0);
301
+ }, command);
302
+ };
303
+ useLayoutEffect(() => {
304
+ if (!group) return;
305
+ group.closeRows.set(id, close);
306
+ return () => {
307
+ group.closeRows.delete(id);
308
+ };
309
+ }, [group, id, theme, leadingPrimaryKey, trailingPrimaryKey]);
310
+ useImperativeHandle(
311
+ ref,
312
+ () => ({
313
+ open(side) {
314
+ if (!mounted.current || disabled) return;
315
+ const count = side === 'leading' ? leadingCount : trailingCount;
316
+ if (!count) return;
317
+ group?.closeRows.forEach((closePeer, peerID) => {
318
+ if (peerID !== id) closePeer();
319
+ });
320
+ const command = ++commandRevision.current,
321
+ polarity = side === 'leading' ? 1 : -1;
322
+ scheduleOnUI(
323
+ (nextCommand: number, nextSign: number, nextCount: number) => {
324
+ 'worklet';
325
+ if (!aliveUI.get() || width.get() <= 0) return;
326
+ uiCommandRevision.set(nextCommand);
327
+ active.set(id);
328
+ settle(nextSign * Math.min(width.get(), nextCount * CELL_WIDTH));
329
+ },
330
+ command,
331
+ polarity,
332
+ count
333
+ );
334
+ },
335
+ close,
336
+ reset,
337
+ }),
338
+ [disabled, leadingActions, trailingActions, active, group, id, theme]
339
+ );
340
+ useAnimatedReaction(
341
+ () => active.get(),
342
+ (activeID) => {
343
+ if (aliveUI.get() && activeID !== id && (offset.get() !== 0 || dragging.get())) {
344
+ dragging.set(false);
345
+ settle(0);
346
+ }
347
+ },
348
+ Platform.OS === 'web' ? [id, theme, leadingPrimary?.key, trailingPrimary?.key] : undefined
349
+ );
350
+ const configuration = `${id}:${disabled}:${direction}:${leadingSwipeMode}:${trailingSwipeMode}:${leadingActions.map((item) => `${item.key}:${Boolean(item.disabled)}`).join(',')}:${trailingActions.map((item) => `${item.key}:${Boolean(item.disabled)}`).join(',')}`;
351
+ useLayoutEffect(() => {
352
+ reset();
353
+ }, [configuration]);
354
+ useEffect(() => {
355
+ if (theme.animation.scale) return;
356
+ scheduleOnUI(() => {
357
+ 'worklet';
358
+ // Keep the user's selected destination; dropping motion while an
359
+ // opening/full-swipe spring runs must neither reverse nor replay it.
360
+ if (aliveUI.get() && settling.get() && !dragging.get()) settle(settlingTarget.get(), 0, settlingPrimary.get());
361
+ });
362
+ }, [theme.animation.scale]);
363
+ useEffect(() => {
364
+ mounted.current = true;
365
+ scheduleOnUI(() => {
366
+ 'worklet';
367
+ aliveUI.set(true);
368
+ });
369
+ return () => {
370
+ mounted.current = false;
371
+ const command = ++commandRevision.current;
372
+ scheduleOnUI((nextCommand: number) => {
373
+ 'worklet';
374
+ uiCommandRevision.set(nextCommand);
375
+ aliveUI.set(false);
376
+ settling.set(false);
377
+ revision.set(revision.get() + 1);
378
+ cancelAnimation(offset);
379
+ if (active.get() === id) active.set('');
380
+ }, command);
381
+ };
382
+ }, [id, active, aliveUI, offset, revision, uiCommandRevision]);
383
+ const pan = usePanGesture({
384
+ enabled: !disabled && (leadingActions.length > 0 || trailingActions.length > 0),
385
+ activeOffsetX: [-10, 10],
386
+ failOffsetY: [-12, 12],
387
+ maxPointers: 1,
388
+ onActivate: () => {
389
+ 'worklet';
390
+ if (!aliveUI.get()) return;
391
+ revision.set(revision.get() + 1);
392
+ cancelAnimation(offset);
393
+ settling.set(false);
394
+ start.set(offset.get());
395
+ dragging.set(true);
396
+ active.set(id);
397
+ },
398
+ onUpdate: (event) => {
399
+ 'worklet';
400
+ if (!aliveUI.get() || !dragging.get()) return;
401
+ const next = start.get() + event.translationX * sign;
402
+ const leadingMax = leadingCount
403
+ ? leadingMode === 'disabled'
404
+ ? Math.min(width.get(), leadingCount * CELL_WIDTH)
405
+ : width.get()
406
+ : 0;
407
+ const trailingMax = trailingCount
408
+ ? trailingMode === 'disabled'
409
+ ? Math.min(width.get(), trailingCount * CELL_WIDTH)
410
+ : width.get()
411
+ : 0;
412
+ // Resistance beyond stable reveal/full edges without jumping when a settling row is grabbed.
413
+ const clamped = clamp(next, -trailingMax, leadingMax);
414
+ offset.set(clamped + (next - clamped) * 0.12);
415
+ },
416
+ onDeactivate: (event) => {
417
+ 'worklet';
418
+ if (!aliveUI.get() || !dragging.get()) return;
419
+ dragging.set(false);
420
+ if (event.canceled) {
421
+ settle(stable.get());
422
+ return;
423
+ }
424
+ const current = offset.get();
425
+ if (Math.abs(current) < 0.01) {
426
+ settle(0);
427
+ return;
428
+ }
429
+ const leading = current > 0,
430
+ count = leading ? leadingCount : trailingCount;
431
+ const reveal = Math.min(width.get(), count * CELL_WIDTH);
432
+ const mode = leading ? leadingMode : trailingMode;
433
+ const target = swipeListTarget(
434
+ Math.abs(current),
435
+ event.velocityX * sign * (leading ? 1 : -1),
436
+ reveal,
437
+ width.get(),
438
+ mode,
439
+ Math.sign(stable.get()) === Math.sign(current) && stable.get() !== 0
440
+ );
441
+ settle(
442
+ target === 'closed' ? 0 : (leading ? 1 : -1) * (target === 'primary' ? width.get() : reveal),
443
+ event.velocityX * sign,
444
+ target === 'primary'
445
+ );
446
+ },
447
+ onFinalize: () => {
448
+ 'worklet';
449
+ // GH3 finalization has base coordinates only. Release velocity is
450
+ // available onDeactivate; an interrupted/failing handler returns
451
+ // to its prior stable position if it never deactivated normally.
452
+ if (aliveUI.get() && dragging.get()) {
453
+ dragging.set(false);
454
+ settle(stable.get());
455
+ }
456
+ },
457
+ });
458
+ const foreground = useAnimatedStyle(() => {
459
+ const count = offset.get() >= 0 ? leadingCount : trailingCount;
460
+ const reveal = Math.min(width.get(), count * CELL_WIDTH);
461
+ const fadeAt = (reveal + width.get()) / 2;
462
+ return {
463
+ transform: [{ translateX: offset.get() * sign }],
464
+ opacity: clamp(1 - (Math.abs(offset.get()) - fadeAt) / Math.max(1, width.get() - fadeAt), 0, 1),
465
+ };
466
+ });
467
+ return (
468
+ <View
469
+ style={[{ overflow: 'hidden', minHeight: 48 }, style]}
470
+ testID={testID}
471
+ onAccessibilityEscape={open ? close : undefined}
472
+ {...(Platform.OS === 'web'
473
+ ? {
474
+ onKeyDown: (event: Parameters<NonNullable<React.ComponentProps<typeof View>['onKeyDown']>>[0]) => {
475
+ if (open && event.nativeEvent.key === 'Escape') {
476
+ event.preventDefault();
477
+ event.stopPropagation();
478
+ close();
479
+ }
480
+ },
481
+ }
482
+ : {})}
483
+ onLayout={(event) => {
484
+ const size = event.nativeEvent.layout;
485
+ if (size.width !== width.get()) {
486
+ reset();
487
+ width.set(size.width);
488
+ setMeasuredWidth(size.width);
489
+ }
490
+ height.set(size.height);
491
+ }}
492
+ >
493
+ <ActionBar
494
+ actions={leadingActions}
495
+ leading
496
+ offset={offset}
497
+ width={width}
498
+ height={height}
499
+ dragging={dragging}
500
+ rtl={rtl}
501
+ open={open === 'leading'}
502
+ disabled={disabled}
503
+ theme={theme}
504
+ invoke={invoke}
505
+ testID={testID}
506
+ />
507
+ <ActionBar
508
+ actions={trailingActions}
509
+ leading={false}
510
+ offset={offset}
511
+ width={width}
512
+ height={height}
513
+ dragging={dragging}
514
+ rtl={rtl}
515
+ open={open === 'trailing'}
516
+ disabled={disabled}
517
+ theme={theme}
518
+ invoke={invoke}
519
+ testID={testID}
520
+ />
521
+ <GestureDetector gesture={pan} touchAction="pan-y">
522
+ <Animated.View style={foreground} collapsable={false}>
523
+ <View
524
+ {...(Platform.OS === 'web' ? { 'aria-hidden': open != null, inert: open != null } : {})}
525
+ accessibilityElementsHidden={open != null}
526
+ importantForAccessibility={open ? 'no-hide-descendants' : 'auto'}
527
+ >
528
+ {children}
529
+ </View>
530
+ {open ? (
531
+ <Pressable
532
+ accessibilityRole="button"
533
+ accessibilityLabel={closeAccessibilityLabel}
534
+ onPress={close}
535
+ style={StyleSheet.absoluteFill}
536
+ testID={`${testID}-close`}
537
+ />
538
+ ) : null}
539
+ </Animated.View>
540
+ </GestureDetector>
541
+ </View>
542
+ );
543
+ });
544
+ function ActionBar({
545
+ actions,
546
+ leading,
547
+ offset,
548
+ width,
549
+ height,
550
+ dragging,
551
+ rtl,
552
+ open,
553
+ disabled,
554
+ theme,
555
+ invoke,
556
+ testID,
557
+ }: {
558
+ actions: readonly SwipeListAction[];
559
+ leading: boolean;
560
+ offset: SharedValue<number>;
561
+ width: SharedValue<number>;
562
+ height: SharedValue<number>;
563
+ dragging: SharedValue<boolean>;
564
+ rtl: boolean;
565
+ open: boolean;
566
+ disabled: boolean;
567
+ theme: Theme;
568
+ invoke: (side: SwipeListSide, key: string) => void;
569
+ testID: string;
570
+ }) {
571
+ const count = actions.length;
572
+ const interaction = useAnimatedProps(() => ({
573
+ pointerEvents:
574
+ open && !disabled && !dragging.get() && Math.abs(offset.get()) >= Math.min(width.get(), count * CELL_WIDTH) - 0.1
575
+ ? ('box-none' as const)
576
+ : ('none' as const),
577
+ }));
578
+ return (
579
+ <Animated.View
580
+ style={StyleSheet.absoluteFill}
581
+ animatedProps={interaction}
582
+ {...(Platform.OS === 'web' ? { 'aria-hidden': !open || disabled, inert: !open || disabled } : {})}
583
+ accessibilityElementsHidden={!open || disabled}
584
+ importantForAccessibility={open && !disabled ? 'auto' : 'no-hide-descendants'}
585
+ >
586
+ {actions.map((action, index) => (
587
+ <RevealAction
588
+ key={action.key}
589
+ action={action}
590
+ index={index}
591
+ count={count}
592
+ leading={leading}
593
+ offset={offset}
594
+ width={width}
595
+ height={height}
596
+ rtl={rtl}
597
+ disabled={disabled || Boolean(action.disabled)}
598
+ open={open}
599
+ theme={theme}
600
+ onPress={() => invoke(leading ? 'leading' : 'trailing', action.key)}
601
+ testID={`${testID}-${leading ? 'leading' : 'trailing'}-${action.key}`}
602
+ />
603
+ ))}
604
+ </Animated.View>
605
+ );
606
+ }
607
+ function actionColors(action: SwipeListAction, theme: Theme) {
608
+ const variant = action.variant ?? 'tonal';
609
+ const fill =
610
+ variant === 'filled'
611
+ ? action.destructive
612
+ ? theme.colors.error
613
+ : theme.colors.primary
614
+ : variant === 'tonal'
615
+ ? action.destructive
616
+ ? theme.colors.errorContainer
617
+ : theme.colors.secondaryContainer
618
+ : 'transparent';
619
+ const ink =
620
+ variant === 'filled'
621
+ ? action.destructive
622
+ ? theme.colors.onError
623
+ : theme.colors.onPrimary
624
+ : variant === 'tonal'
625
+ ? action.destructive
626
+ ? theme.colors.onErrorContainer
627
+ : theme.colors.onSecondaryContainer
628
+ : action.destructive
629
+ ? theme.colors.error
630
+ : theme.colors.onSurfaceVariant;
631
+ return { fill, ink };
632
+ }
633
+ function RevealAction({
634
+ action,
635
+ index,
636
+ count,
637
+ leading,
638
+ offset,
639
+ width,
640
+ height,
641
+ rtl,
642
+ disabled,
643
+ open,
644
+ theme,
645
+ onPress,
646
+ testID,
647
+ }: {
648
+ action: SwipeListAction;
649
+ index: number;
650
+ count: number;
651
+ leading: boolean;
652
+ offset: SharedValue<number>;
653
+ width: SharedValue<number>;
654
+ height: SharedValue<number>;
655
+ rtl: boolean;
656
+ disabled: boolean;
657
+ open: boolean;
658
+ theme: Theme;
659
+ onPress: () => void;
660
+ testID: string;
661
+ }) {
662
+ const [pressed, setPressed] = useState(false),
663
+ [focused, setFocused] = useState(false),
664
+ [hovered, setHovered] = useState(false);
665
+ const interactive = useRef(false);
666
+ useLayoutEffect(() => {
667
+ interactive.current = open && !disabled;
668
+ return () => {
669
+ interactive.current = false;
670
+ };
671
+ }, [open, disabled]);
672
+ useEffect(() => {
673
+ if (disabled || !open) {
674
+ setPressed(false);
675
+ setFocused(false);
676
+ setHovered(false);
677
+ }
678
+ }, [disabled, open]);
679
+ // Interpolate a press fraction against the live UI height, rather than
680
+ // capturing the first measured height in an RN effect.
681
+ const radius = useSharedValue(0);
682
+ useEffect(() => {
683
+ const s = theme.motion.spring.fast.spatial;
684
+ radius.set(
685
+ !theme.animation.scale
686
+ ? pressed
687
+ ? 1
688
+ : 0
689
+ : withSpring(pressed ? 1 : 0, {
690
+ stiffness: s.stiffness,
691
+ damping: 2 * s.damping * Math.sqrt(s.stiffness),
692
+ reduceMotion: ReduceMotion.Never,
693
+ })
694
+ );
695
+ }, [pressed, theme, radius, height, theme.animation.scale]);
696
+ useEffect(() => () => cancelAnimation(radius), [radius]);
697
+ const variant = action.variant ?? 'tonal';
698
+ const { fill, ink: enabledInk } = actionColors(action, theme),
699
+ ink = disabled ? theme.colors.onSurface : enabledInk;
700
+ const background = useAnimatedStyle(() => {
701
+ const revealed = Math.max(0, leading ? offset.get() : -offset.get());
702
+ const geometry = swipeActionGeometry(revealed, width.get(), count, index, leading);
703
+ return {
704
+ width: geometry.width,
705
+ left: rtl ? width.get() - geometry.start - geometry.width : geometry.start,
706
+ borderRadius: Math.max(0, Math.min(height.get() / 2 + (8 - height.get() / 2) * radius.get(), geometry.width / 2)),
707
+ opacity: revealed > 0 ? (disabled ? 0.12 : 1) : 0,
708
+ };
709
+ });
710
+ const content = useAnimatedStyle(() => {
711
+ const revealed = Math.max(0, leading ? offset.get() : -offset.get());
712
+ const geometry = swipeActionGeometry(revealed, width.get(), count, index, leading);
713
+ const center = rtl ? width.get() - geometry.center : geometry.center;
714
+ return {
715
+ transform: [{ translateX: center - ACTION_WIDTH / 2 }],
716
+ opacity: geometry.opacity * (disabled ? 0.38 : 1),
717
+ };
718
+ });
719
+ const targetShape = useAnimatedStyle(() => ({
720
+ borderRadius: Math.max(0, Math.min(height.get() / 2 + (8 - height.get() / 2) * radius.get(), ACTION_WIDTH / 2)),
721
+ }));
722
+ return (
723
+ <>
724
+ <Animated.View
725
+ pointerEvents="none"
726
+ style={[
727
+ styles.actionBackground,
728
+ {
729
+ backgroundColor: disabled ? theme.colors.onSurface : fill,
730
+ borderWidth: variant === 'outlined' ? 1 : 0,
731
+ borderColor: disabled ? theme.colors.onSurface : theme.colors.outline,
732
+ },
733
+ background,
734
+ ]}
735
+ />
736
+ <Animated.View style={[styles.actionTarget, content, targetShape]}>
737
+ <TouchableRipple
738
+ disabled={disabled}
739
+ onPress={onPress}
740
+ onPressIn={() => {
741
+ if (interactive.current) setPressed(true);
742
+ }}
743
+ onPressOut={() => {
744
+ if (interactive.current) setPressed(false);
745
+ }}
746
+ onFocus={() => {
747
+ if (interactive.current) setFocused(true);
748
+ }}
749
+ onBlur={() => {
750
+ if (interactive.current) {
751
+ setFocused(false);
752
+ setPressed(false);
753
+ }
754
+ }}
755
+ onHoverIn={() => {
756
+ if (interactive.current) setHovered(true);
757
+ }}
758
+ onHoverOut={() => {
759
+ if (interactive.current) setHovered(false);
760
+ }}
761
+ accessibilityRole="button"
762
+ accessibilityLabel={action.label}
763
+ accessibilityState={{ disabled }}
764
+ rippleColor={typeof ink === 'string' ? withAlpha(ink, 0.1) : theme.colors.stateLayerPressed}
765
+ style={styles.actionTouch}
766
+ testID={testID}
767
+ >
768
+ <View pointerEvents="none" style={styles.actionContent}>
769
+ <View
770
+ style={[
771
+ StyleSheet.absoluteFill,
772
+ { backgroundColor: ink, opacity: !disabled && open && !pressed ? (focused ? 0.1 : hovered ? 0.08 : 0) : 0 },
773
+ ]}
774
+ />
775
+ <Icon source={action.icon} color={ink} size={24} />
776
+ </View>
777
+ </TouchableRipple>
778
+ {focused && open && !disabled ? (
779
+ <Animated.View
780
+ pointerEvents="none"
781
+ style={[StyleSheet.absoluteFill, { borderWidth: 3, borderColor: theme.colors.secondary }, targetShape]}
782
+ />
783
+ ) : null}
784
+ </Animated.View>
785
+ </>
786
+ );
787
+ }
788
+ const styles = StyleSheet.create({
789
+ actionBackground: { position: 'absolute', top: 0, bottom: 0 },
790
+ actionTarget: { position: 'absolute', top: 0, bottom: 0, width: ACTION_WIDTH, overflow: 'hidden' },
791
+ actionTouch: { flex: 1, minHeight: 48, alignItems: 'center', justifyContent: 'center', overflow: 'hidden' },
792
+ actionContent: { flex: 1, alignSelf: 'stretch', alignItems: 'center', justifyContent: 'center' },
793
+ });