@isoline-ui/react-native 0.0.0-stage → 1.0.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 (167) hide show
  1. package/LICENSE +22 -0
  2. package/NOTICE.md +12 -0
  3. package/README.md +104 -3
  4. package/dist/amount.d.ts +3 -0
  5. package/dist/calendar.d.ts +7 -0
  6. package/dist/components/action-chip.d.ts +9 -0
  7. package/dist/components/amount-field.d.ts +12 -0
  8. package/dist/components/avatar.d.ts +17 -0
  9. package/dist/components/back-button.d.ts +12 -0
  10. package/dist/components/back-icon.d.ts +3 -0
  11. package/dist/components/badge.d.ts +13 -0
  12. package/dist/components/banner.d.ts +14 -0
  13. package/dist/components/bottom-sheet-native.d.ts +11 -0
  14. package/dist/components/bottom-sheet-web.d.ts +18 -0
  15. package/dist/components/bottom-sheet.d.ts +3 -0
  16. package/dist/components/button-group.d.ts +26 -0
  17. package/dist/components/button.d.ts +48 -0
  18. package/dist/components/checkbox.d.ts +12 -0
  19. package/dist/components/color-picker.d.ts +21 -0
  20. package/dist/components/context-menu.d.ts +26 -0
  21. package/dist/components/date-picker.d.ts +64 -0
  22. package/dist/components/dialog-context.d.ts +2 -0
  23. package/dist/components/dialog.d.ts +20 -0
  24. package/dist/components/empty-state.d.ts +15 -0
  25. package/dist/components/expand-indicator.d.ts +27 -0
  26. package/dist/components/expressive-shape.d.ts +18 -0
  27. package/dist/components/fab-menu-legacy.d.ts +31 -0
  28. package/dist/components/fab-menu.d.ts +21 -0
  29. package/dist/components/filter-chip.d.ts +69 -0
  30. package/dist/components/floating-action-button.d.ts +15 -0
  31. package/dist/components/icon-button.d.ts +40 -0
  32. package/dist/components/icons.d.ts +18 -0
  33. package/dist/components/input.d.ts +9 -0
  34. package/dist/components/keyboard-inset-view.d.ts +15 -0
  35. package/dist/components/list-controls.d.ts +38 -0
  36. package/dist/components/loading-indicator.d.ts +9 -0
  37. package/dist/components/menu-sheet.d.ts +36 -0
  38. package/dist/components/panel-sheet.d.ts +8 -0
  39. package/dist/components/password-visibility.d.ts +5 -0
  40. package/dist/components/popover-layout.d.ts +25 -0
  41. package/dist/components/popover.d.ts +20 -0
  42. package/dist/components/pressable.d.ts +12 -0
  43. package/dist/components/radio-navigation.d.ts +11 -0
  44. package/dist/components/refresh-control.d.ts +16 -0
  45. package/dist/components/row-gap.d.ts +9 -0
  46. package/dist/components/search-field.d.ts +22 -0
  47. package/dist/components/select-field.d.ts +16 -0
  48. package/dist/components/settings-list.d.ts +98 -0
  49. package/dist/components/settings-row.d.ts +13 -0
  50. package/dist/components/sheet-frame.d.ts +27 -0
  51. package/dist/components/sheet-lifecycle.d.ts +36 -0
  52. package/dist/components/sheet-transition.d.ts +36 -0
  53. package/dist/components/slider.d.ts +21 -0
  54. package/dist/components/snackbar.d.ts +10 -0
  55. package/dist/components/split-button.d.ts +38 -0
  56. package/dist/components/squish.d.ts +20 -0
  57. package/dist/components/swipeable-row.d.ts +20 -0
  58. package/dist/components/switch.d.ts +8 -0
  59. package/dist/components/tab-bar.d.ts +20 -0
  60. package/dist/components/text-field.d.ts +31 -0
  61. package/dist/components/text.d.ts +9 -0
  62. package/dist/components/time-picker.d.ts +40 -0
  63. package/dist/components/toolbar.d.ts +77 -0
  64. package/dist/components/top-bar.d.ts +27 -0
  65. package/dist/components/undo-bar.d.ts +23 -0
  66. package/dist/components/wavy-progress.d.ts +21 -0
  67. package/dist/components/zoomable-image.d.ts +18 -0
  68. package/dist/expressive-shapes.d.ts +8 -0
  69. package/dist/index.d.ts +62 -0
  70. package/dist/keyboard.d.ts +2 -0
  71. package/dist/material.d.ts +11 -0
  72. package/dist/media.d.ts +11 -0
  73. package/dist/motion.d.ts +96 -0
  74. package/dist/platform.d.ts +3 -0
  75. package/dist/preset-tokens.d.ts +264 -0
  76. package/dist/provider.d.ts +59 -0
  77. package/dist/reduced-motion.d.ts +45 -0
  78. package/dist/selection.d.ts +2 -0
  79. package/dist/shape.d.ts +99 -0
  80. package/dist/sheet-physics.d.ts +37 -0
  81. package/dist/theme.d.ts +103 -0
  82. package/dist/tokens.d.ts +2 -0
  83. package/dist/use-keyboard-height.d.ts +10 -0
  84. package/dist/utils.d.ts +3 -0
  85. package/nativewind.cjs +68 -0
  86. package/package.json +63 -6
  87. package/src/amount.ts +53 -0
  88. package/src/calendar.ts +42 -0
  89. package/src/components/action-chip.tsx +64 -0
  90. package/src/components/amount-field.tsx +49 -0
  91. package/src/components/avatar.tsx +91 -0
  92. package/src/components/back-button.tsx +39 -0
  93. package/src/components/back-icon.tsx +25 -0
  94. package/src/components/badge.tsx +41 -0
  95. package/src/components/banner.tsx +63 -0
  96. package/src/components/bottom-sheet-native.tsx +52 -0
  97. package/src/components/bottom-sheet-web.tsx +193 -0
  98. package/src/components/bottom-sheet.tsx +5 -0
  99. package/src/components/button-group.tsx +326 -0
  100. package/src/components/button.tsx +337 -0
  101. package/src/components/checkbox.tsx +71 -0
  102. package/src/components/color-picker.tsx +115 -0
  103. package/src/components/context-menu.tsx +149 -0
  104. package/src/components/date-picker.tsx +466 -0
  105. package/src/components/dialog-context.ts +3 -0
  106. package/src/components/dialog.tsx +210 -0
  107. package/src/components/empty-state.tsx +49 -0
  108. package/src/components/expand-indicator.tsx +60 -0
  109. package/src/components/expressive-shape.tsx +37 -0
  110. package/src/components/fab-menu-legacy.tsx +181 -0
  111. package/src/components/fab-menu.tsx +90 -0
  112. package/src/components/filter-chip.tsx +136 -0
  113. package/src/components/floating-action-button.tsx +79 -0
  114. package/src/components/icon-button.tsx +127 -0
  115. package/src/components/icons.tsx +43 -0
  116. package/src/components/input.tsx +80 -0
  117. package/src/components/keyboard-inset-view.tsx +28 -0
  118. package/src/components/list-controls.tsx +134 -0
  119. package/src/components/loading-indicator.tsx +183 -0
  120. package/src/components/menu-sheet.tsx +133 -0
  121. package/src/components/panel-sheet.tsx +90 -0
  122. package/src/components/password-visibility.tsx +39 -0
  123. package/src/components/popover-layout.ts +47 -0
  124. package/src/components/popover.tsx +139 -0
  125. package/src/components/pressable.tsx +108 -0
  126. package/src/components/radio-navigation.ts +52 -0
  127. package/src/components/refresh-control.tsx +39 -0
  128. package/src/components/row-gap.tsx +13 -0
  129. package/src/components/search-field.tsx +92 -0
  130. package/src/components/select-field.tsx +105 -0
  131. package/src/components/settings-list.tsx +302 -0
  132. package/src/components/settings-row.tsx +61 -0
  133. package/src/components/sheet-frame.tsx +250 -0
  134. package/src/components/sheet-lifecycle.ts +233 -0
  135. package/src/components/sheet-transition.ts +105 -0
  136. package/src/components/slider.tsx +185 -0
  137. package/src/components/snackbar.tsx +97 -0
  138. package/src/components/split-button.tsx +212 -0
  139. package/src/components/squish.ts +41 -0
  140. package/src/components/swipeable-row.tsx +109 -0
  141. package/src/components/switch.tsx +77 -0
  142. package/src/components/tab-bar.tsx +195 -0
  143. package/src/components/text-field.tsx +264 -0
  144. package/src/components/text.tsx +58 -0
  145. package/src/components/time-picker.tsx +277 -0
  146. package/src/components/toolbar.tsx +406 -0
  147. package/src/components/top-bar.tsx +91 -0
  148. package/src/components/undo-bar.tsx +137 -0
  149. package/src/components/wavy-progress.tsx +150 -0
  150. package/src/components/zoomable-image.tsx +240 -0
  151. package/src/expressive-shapes.ts +25 -0
  152. package/src/index.ts +62 -0
  153. package/src/keyboard.ts +5 -0
  154. package/src/material.ts +16 -0
  155. package/src/media.ts +51 -0
  156. package/src/motion.ts +96 -0
  157. package/src/platform.ts +13 -0
  158. package/src/preset-tokens.ts +265 -0
  159. package/src/provider.tsx +201 -0
  160. package/src/reduced-motion.ts +50 -0
  161. package/src/selection.ts +11 -0
  162. package/src/shape.ts +217 -0
  163. package/src/sheet-physics.ts +73 -0
  164. package/src/theme.ts +180 -0
  165. package/src/tokens.ts +2 -0
  166. package/src/use-keyboard-height.ts +64 -0
  167. package/src/utils.ts +7 -0
@@ -0,0 +1,91 @@
1
+ import * as React from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import { BackIcon } from './back-icon.tsx';
5
+ import { HEADER_ICON_BUTTON, IconButton } from './icon-button.tsx';
6
+ import { Text } from './text.tsx';
7
+ import {
8
+ type MaterialOptions,
9
+ useIsolinePlatform,
10
+ useLabels,
11
+ useSurface,
12
+ } from '../provider.tsx';
13
+ import { cn } from '../utils.ts';
14
+
15
+ /**
16
+ * The bar every screen below the tabs opens with: the back arrow 8 from the
17
+ * edge, the screen's own buttons at the other end, and, when a screen wants
18
+ * one, something small in between (a position, a step). One height, one
19
+ * inset, so moving between screens moves nothing at the top. The status
20
+ * bar's room is the screen's: it knows whether it draws under it.
21
+ */
22
+ export interface TopBarProps extends MaterialOptions {
23
+ onBack: () => void;
24
+ backLabel?: string;
25
+ center?: React.ReactNode;
26
+ actions?: React.ReactNode;
27
+ className?: string;
28
+ }
29
+ export function TopBar({
30
+ onBack,
31
+ center,
32
+ actions,
33
+ backLabel,
34
+ className,
35
+ material = 'auto',
36
+ glassStyle,
37
+ tintColor,
38
+ fallbackColor,
39
+ interactive,
40
+ }: TopBarProps) {
41
+ const labels = useLabels();
42
+ const ios = useIsolinePlatform() === 'ios';
43
+ const Surface = useSurface();
44
+ return (
45
+ <Surface
46
+ material={ios ? material : 'solid'}
47
+ glassStyle={glassStyle}
48
+ tintColor={tintColor}
49
+ fallbackColor={fallbackColor}
50
+ interactive={interactive}
51
+ className={className}
52
+ style={{
53
+ minHeight: 56,
54
+ flexDirection: 'row',
55
+ alignItems: 'center',
56
+ paddingHorizontal: 8,
57
+ }}
58
+ >
59
+ <IconButton
60
+ {...HEADER_ICON_BUTTON}
61
+ icon={BackIcon}
62
+ onPress={onBack}
63
+ accessibilityLabel={backLabel ?? labels.back}
64
+ />
65
+ <View className="flex-1 items-center px-2">{center}</View>
66
+ {actions}
67
+ </Surface>
68
+ );
69
+ }
70
+
71
+ /**
72
+ * A screen's name under its bar: the emphasized title, 16 from the edge, and
73
+ * a line about it. The same on every screen, so a title is where the eye
74
+ * expects it.
75
+ */
76
+ export function ScreenTitle({
77
+ title,
78
+ caption,
79
+ className,
80
+ }: {
81
+ title: string;
82
+ caption?: React.ReactNode;
83
+ className?: string;
84
+ }) {
85
+ return (
86
+ <View className={cn('gap-1 px-4', className)}>
87
+ <Text variant="title">{title}</Text>
88
+ {typeof caption === 'string' ? <Text variant="caption">{caption}</Text> : caption}
89
+ </View>
90
+ );
91
+ }
@@ -0,0 +1,137 @@
1
+ import * as React from 'react';
2
+ import { Platform, View } from 'react-native';
3
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
4
+ import {
5
+ Surface,
6
+ useIsolinePlatform,
7
+ useThemeColors,
8
+ type MaterialOptions,
9
+ } from '../provider.tsx';
10
+ import { Text } from './text.tsx';
11
+ import { Pressable } from './pressable.tsx';
12
+ import { Button } from './button.tsx';
13
+ import { WavyProgress, type WavyProgressProps } from './wavy-progress.tsx';
14
+
15
+ export interface UndoBarProps extends MaterialOptions {
16
+ message: string;
17
+ actionLabel: string;
18
+ dismissLabel: string;
19
+ onUndo: () => void;
20
+ onDismiss: () => void;
21
+ /** Time supplied by the app's undo clock. This component never executes/expiries undo entries. */
22
+ remainingMs: number;
23
+ totalMs: number;
24
+ progress?: WavyProgressProps['progress'];
25
+ paused?: boolean;
26
+ onPause?: () => void;
27
+ onResume?: () => void;
28
+ collapsed?: boolean;
29
+ onExpand?: () => void;
30
+ countLabel?: string;
31
+ showDismissButton?: boolean;
32
+ }
33
+ /** A feedback surface only; queue, expiry, collapse policy and screen clearance belong to the app. */
34
+ export function UndoBar({
35
+ message,
36
+ actionLabel,
37
+ dismissLabel,
38
+ onUndo,
39
+ onDismiss,
40
+ remainingMs,
41
+ totalMs,
42
+ progress: suppliedProgress,
43
+ paused,
44
+ onPause,
45
+ onResume,
46
+ collapsed,
47
+ onExpand,
48
+ countLabel,
49
+ showDismissButton = true,
50
+ material = 'solid',
51
+ glassStyle,
52
+ tintColor,
53
+ fallbackColor,
54
+ interactive,
55
+ }: UndoBarProps) {
56
+ const colors = useThemeColors();
57
+ const glass = useIsolinePlatform() === 'ios' && material !== 'solid';
58
+ const swipe = Gesture.Pan()
59
+ .activeOffsetX([-12, 12])
60
+ .runOnJS(true)
61
+ .onBegin(() => onPause?.())
62
+ .onEnd((event) => {
63
+ if (Math.abs(event.translationX) > 96 || Math.abs(event.velocityX) > 800) onDismiss();
64
+ })
65
+ .onFinalize(() => onResume?.());
66
+ const progress = totalMs > 0 ? Math.max(0, Math.min(1, remainingMs / totalMs)) : 0;
67
+ return (
68
+ <GestureDetector gesture={swipe}>
69
+ <Surface
70
+ material={glass ? material : 'solid'}
71
+ glassStyle={glassStyle}
72
+ tintColor={tintColor}
73
+ fallbackColor={fallbackColor}
74
+ interactive={interactive}
75
+ style={{ padding: 12, borderRadius: 24, gap: 6 }}
76
+ >
77
+ <Pressable
78
+ accessibilityRole="button"
79
+ accessibilityLabel={message}
80
+ accessibilityActions={[{ name: 'dismiss', label: dismissLabel }]}
81
+ onAccessibilityAction={(event) => {
82
+ if (event.nativeEvent.actionName === 'dismiss') onDismiss();
83
+ }}
84
+ onPress={onExpand}
85
+ onPressIn={onPause}
86
+ onPressOut={onResume}
87
+ {...(Platform.OS === 'web'
88
+ ? {
89
+ onKeyDown: (
90
+ event: Parameters<
91
+ NonNullable<React.ComponentProps<typeof Pressable>['onKeyDown']>
92
+ >[0],
93
+ ) => {
94
+ if (event.nativeEvent.key === 'Escape') {
95
+ event.preventDefault();
96
+ onDismiss();
97
+ }
98
+ },
99
+ }
100
+ : {})}
101
+ style={{ minHeight: 44, justifyContent: 'center' }}
102
+ >
103
+ {!collapsed ? (
104
+ <Text accessibilityLiveRegion="polite">{message}</Text>
105
+ ) : (
106
+ <Text>{countLabel ?? actionLabel}</Text>
107
+ )}
108
+ {countLabel ? <Text variant="caption">{countLabel}</Text> : null}
109
+ </Pressable>
110
+ <WavyProgress
111
+ progress={suppliedProgress ?? progress}
112
+ animated={!paused}
113
+ color={colors.primary}
114
+ trackColor={colors.surfaceContainerHighest}
115
+ />
116
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
117
+ <Button
118
+ size="s"
119
+ variant="ghost"
120
+ label={actionLabel}
121
+ accessibilityLabel={`${actionLabel}: ${message}`}
122
+ onPress={onUndo}
123
+ onPressIn={onPause}
124
+ onPressOut={onResume}
125
+ accessibilityActions={[{ name: 'dismiss', label: dismissLabel }]}
126
+ onAccessibilityAction={(event) => {
127
+ if (event.nativeEvent.actionName === 'dismiss') onDismiss();
128
+ }}
129
+ />
130
+ {showDismissButton ? (
131
+ <Button size="s" variant="ghost" label={dismissLabel} onPress={onDismiss} />
132
+ ) : null}
133
+ </View>
134
+ </Surface>
135
+ </GestureDetector>
136
+ );
137
+ }
@@ -0,0 +1,150 @@
1
+ import * as React from 'react';
2
+ import { View } from 'react-native';
3
+ import Animated, {
4
+ cancelAnimation,
5
+ Easing,
6
+ useAnimatedProps,
7
+ useSharedValue,
8
+ useAnimatedReaction,
9
+ runOnJS,
10
+ type SharedValue,
11
+ withRepeat,
12
+ withSpring,
13
+ withTiming,
14
+ } from 'react-native-reanimated';
15
+ import Svg, { Circle, Path } from 'react-native-svg';
16
+
17
+ import { spatial } from '../motion.ts';
18
+ import { useReducedMotion } from '../reduced-motion.ts';
19
+ import { useIsolinePlatform, useThemeColors } from '../provider.tsx';
20
+
21
+ const AnimatedPath = Animated.createAnimatedComponent(Path);
22
+
23
+ const STROKE = 4;
24
+ const WAVELENGTH = 28;
25
+ /** Room above and below the line for the wave to swing in. */
26
+ const HEIGHT = 12;
27
+ const GAP = 6;
28
+
29
+ /**
30
+ * The Expressive determinate progress indicator: the part done is a wave,
31
+ * the part left a flat track, with a gap between them.
32
+ *
33
+ * The wave calms down towards either end — it is flat at nothing done and
34
+ * flat again at all done, so a finished list reads as finished, not busy.
35
+ * While something is underway the wave travels slowly; under "Remove
36
+ * animations" it stands still, and a change of progress arrives at once.
37
+ */
38
+ export interface WavyProgressProps {
39
+ /** 0 to 1. */
40
+ progress: number | SharedValue<number>;
41
+ height?: number;
42
+ color?: string;
43
+ trackColor?: string;
44
+ /** A still wave, for small previews where motion would only distract. */
45
+ animated?: boolean;
46
+ }
47
+
48
+ export function WavyProgress({
49
+ progress,
50
+ color,
51
+ trackColor,
52
+ animated = true,
53
+ height = HEIGHT,
54
+ }: WavyProgressProps) {
55
+ const ios = useIsolinePlatform() === 'ios';
56
+ const colors = useThemeColors();
57
+ const reduced = useReducedMotion();
58
+ const [width, setWidth] = React.useState(0);
59
+ const numeric = typeof progress === 'number';
60
+ const value = useSharedValue(numeric ? progress : 0);
61
+ const source = numeric ? value : progress;
62
+ const [announced, setAnnounced] = React.useState<number | undefined>(
63
+ numeric ? Math.round(progress * 100) : undefined,
64
+ );
65
+ useAnimatedReaction(
66
+ () => Math.round(Math.max(0, Math.min(1, source.value)) * 100),
67
+ (now, before) => {
68
+ if (now !== before) runOnJS(setAnnounced)(now);
69
+ },
70
+ [source],
71
+ );
72
+ const phase = useSharedValue(0);
73
+ const moving = animated && !reduced && !ios;
74
+
75
+ React.useEffect(() => {
76
+ if (typeof progress === 'number')
77
+ value.value = moving ? withSpring(progress, spatial.slow) : progress;
78
+ }, [moving, progress, value]);
79
+
80
+ React.useEffect(() => {
81
+ if (!moving) {
82
+ cancelAnimation(phase);
83
+ return;
84
+ }
85
+ phase.value = 0;
86
+ phase.value = withRepeat(withTiming(1, { duration: 1800, easing: Easing.linear }), -1);
87
+ return () => cancelAnimation(phase);
88
+ }, [moving, phase]);
89
+
90
+ const wave = useAnimatedProps(() => {
91
+ const p = Math.max(0, Math.min(1, source.value));
92
+ const end = Math.max(0, (width - STROKE - GAP) * p);
93
+ const amplitude =
94
+ (reduced || ios ? 0 : Math.max(0, Math.min(3, (height - STROKE) / 2))) *
95
+ Math.min(1, p * 6, (1 - p) * 6);
96
+ const mid = height / 2;
97
+ const shift = phase.value * WAVELENGTH;
98
+ let d = `M ${STROKE / 2} ${mid}`;
99
+ for (let x = 2; x <= end; x += 2) {
100
+ const y = mid + amplitude * Math.sin(((x + shift) / WAVELENGTH) * 2 * Math.PI);
101
+ d += ` L ${x + STROKE / 2} ${y.toFixed(2)}`;
102
+ }
103
+ return { d, strokeOpacity: end < 1 ? 0 : 1 };
104
+ });
105
+
106
+ const track = useAnimatedProps(() => {
107
+ const p = Math.max(0, Math.min(1, source.value));
108
+ const start = (width - STROKE - GAP) * p + (p > 0 ? GAP : 0) + STROKE / 2;
109
+ const end = width - STROKE - GAP / 2;
110
+ return {
111
+ d: `M ${Math.min(start, end)} ${height / 2} L ${end} ${height / 2}`,
112
+ strokeOpacity: start >= end ? 0 : 1,
113
+ };
114
+ });
115
+
116
+ return (
117
+ <View
118
+ style={{ height }}
119
+ onLayout={(event) => setWidth(event.nativeEvent.layout.width)}
120
+ accessibilityRole="progressbar"
121
+ accessibilityValue={{ min: 0, max: 100, now: announced }}
122
+ >
123
+ {width > 0 ? (
124
+ <Svg width={width} height={height}>
125
+ <AnimatedPath
126
+ animatedProps={track}
127
+ stroke={trackColor ?? colors.secondaryContainer}
128
+ strokeWidth={STROKE}
129
+ strokeLinecap="round"
130
+ fill="none"
131
+ />
132
+ <AnimatedPath
133
+ animatedProps={wave}
134
+ stroke={color ?? colors.primary}
135
+ strokeWidth={STROKE}
136
+ strokeLinecap="round"
137
+ strokeLinejoin="round"
138
+ fill="none"
139
+ />
140
+ <Circle
141
+ cx={width - STROKE / 2}
142
+ cy={height / 2}
143
+ r={STROKE / 2}
144
+ fill={color ?? colors.primary}
145
+ />
146
+ </Svg>
147
+ ) : null}
148
+ </View>
149
+ );
150
+ }
@@ -0,0 +1,240 @@
1
+ import * as React from 'react';
2
+ import { Image, StyleSheet, View } from 'react-native';
3
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
4
+ import Animated, {
5
+ runOnJS,
6
+ useAnimatedStyle,
7
+ useSharedValue,
8
+ withTiming,
9
+ } from 'react-native-reanimated';
10
+ import { Button } from './button.tsx';
11
+ import { Toolbar as ToolbarContainer } from './toolbar.tsx';
12
+ import type { MaterialOptions } from '../provider.tsx';
13
+ import { Text } from './text.tsx';
14
+ import { clampZoom, imagePanBounds, imageDimensions } from '../media.ts';
15
+
16
+ /** Material options apply to the floating zoom controls, never the image. */
17
+ export interface ZoomableImageProps extends MaterialOptions {
18
+ uri: string;
19
+ accessibilityLabel: string;
20
+ onTap?: () => void;
21
+ onDismiss?: () => void;
22
+ maximumScale?: number;
23
+ labels: { zoomIn: string; zoomOut: string; reset: string; error?: string };
24
+ renderImage?: (
25
+ onLoad: (width: number, height: number) => void,
26
+ onError: () => void,
27
+ ) => React.ReactNode;
28
+ }
29
+ export function ZoomableImage({
30
+ uri,
31
+ accessibilityLabel,
32
+ onTap,
33
+ onDismiss,
34
+ maximumScale = 5,
35
+ labels,
36
+ renderImage,
37
+ material = 'auto',
38
+ glassStyle,
39
+ tintColor,
40
+ fallbackColor,
41
+ interactive,
42
+ }: ZoomableImageProps) {
43
+ const currentUri = React.useRef(uri);
44
+ currentUri.current = uri;
45
+ const mounted = React.useRef(true);
46
+ React.useEffect(() => {
47
+ mounted.current = true;
48
+ return () => {
49
+ mounted.current = false;
50
+ };
51
+ }, []);
52
+ const width = useSharedValue(1),
53
+ height = useSharedValue(1),
54
+ aspect = useSharedValue(1);
55
+ const scale = useSharedValue(1),
56
+ x = useSharedValue(0),
57
+ y = useSharedValue(0);
58
+ const startScale = useSharedValue(1),
59
+ startX = useSharedValue(0),
60
+ startY = useSharedValue(0);
61
+ const pinched = useSharedValue(false),
62
+ focalX = useSharedValue(0),
63
+ focalY = useSharedValue(0);
64
+ const [shown, setShown] = React.useState(1),
65
+ [failed, setFailed] = React.useState(false);
66
+ const maximum = Number.isFinite(maximumScale) ? Math.max(1, maximumScale) : 5;
67
+ const reset = React.useCallback(() => {
68
+ scale.value = 1;
69
+ x.value = 0;
70
+ y.value = 0;
71
+ setShown(1);
72
+ }, [scale, x, y]);
73
+ React.useEffect(() => {
74
+ reset();
75
+ setFailed(false);
76
+ }, [uri, reset]);
77
+ const settle = () => {
78
+ 'worklet';
79
+ const next = clampZoom(scale.value, maximum),
80
+ bounds = imagePanBounds(width.value, height.value, aspect.value, next);
81
+ scale.value = withTiming(next);
82
+ x.value = withTiming(Math.max(-bounds.x, Math.min(bounds.x, x.value)));
83
+ y.value = withTiming(Math.max(-bounds.y, Math.min(bounds.y, y.value)));
84
+ runOnJS(setShown)(next);
85
+ };
86
+ const pinch = Gesture.Pinch()
87
+ .onStart((event) => {
88
+ pinched.value = true;
89
+ startScale.value = scale.value;
90
+ startX.value = x.value;
91
+ startY.value = y.value;
92
+ focalX.value = event.focalX - width.value / 2;
93
+ focalY.value = event.focalY - height.value / 2;
94
+ })
95
+ .onUpdate((event) => {
96
+ if (event.numberOfPointers < 2) return;
97
+ const next = Math.max(0.75, Math.min(maximum * 1.25, startScale.value * event.scale));
98
+ const ratio = next / startScale.value;
99
+ x.value = event.focalX - width.value / 2 - (focalX.value - startX.value) * ratio;
100
+ y.value = event.focalY - height.value / 2 - (focalY.value - startY.value) * ratio;
101
+ scale.value = next;
102
+ })
103
+ .onEnd(settle);
104
+ const pan = Gesture.Pan()
105
+ .maxPointers(1)
106
+ .onBegin(() => {
107
+ pinched.value = false;
108
+ })
109
+ .onStart(() => {
110
+ startX.value = x.value;
111
+ startY.value = y.value;
112
+ })
113
+ .onUpdate((event) => {
114
+ if (pinched.value) return;
115
+ const limits = imagePanBounds(width.value, height.value, aspect.value, scale.value);
116
+ x.value = Math.max(-limits.x, Math.min(limits.x, startX.value + event.translationX));
117
+ y.value =
118
+ scale.value <= 1.01 && onDismiss
119
+ ? Math.max(0, event.translationY)
120
+ : Math.max(-limits.y, Math.min(limits.y, startY.value + event.translationY));
121
+ })
122
+ .onEnd((event) => {
123
+ if (
124
+ scale.value <= 1.01 &&
125
+ onDismiss &&
126
+ (event.translationY > 120 || event.velocityY > 900)
127
+ ) {
128
+ runOnJS(onDismiss)();
129
+ return;
130
+ }
131
+ settle();
132
+ });
133
+ const zoom = (next: number) => {
134
+ scale.value = clampZoom(next, maximum);
135
+ settle();
136
+ };
137
+ const double = Gesture.Tap()
138
+ .numberOfTaps(2)
139
+ .onEnd((event) => {
140
+ const next = scale.value > 1.01 ? 1 : Math.min(2.5, maximum);
141
+ const ratio = next / scale.value;
142
+ x.value = (event.x - width.value / 2) * (1 - ratio) + x.value * ratio;
143
+ y.value = (event.y - height.value / 2) * (1 - ratio) + y.value * ratio;
144
+ scale.value = next;
145
+ settle();
146
+ });
147
+ const single = Gesture.Tap()
148
+ .requireExternalGestureToFail(double)
149
+ .runOnJS(true)
150
+ .onEnd(() => onTap?.());
151
+ const motion = useAnimatedStyle(() => ({
152
+ transform: [{ translateX: x.value }, { translateY: y.value }, { scale: scale.value }],
153
+ }));
154
+ // Keep the floating material over the contained photo rather than its empty
155
+ // letterbox. This position stays stable while zooming and updates on resize.
156
+ const controlPosition = useAnimatedStyle(() => ({
157
+ bottom:
158
+ Math.max(0, (height.value - Math.min(height.value, width.value / aspect.value)) / 2) + 8,
159
+ }));
160
+ const imageLoaded = (w: number, h: number) => {
161
+ if (w > 0 && h > 0) aspect.value = w / h;
162
+ };
163
+ return (
164
+ <View style={{ flex: 1, minHeight: 180, gap: 8 }}>
165
+ <GestureDetector
166
+ gesture={Gesture.Simultaneous(pinch, pan, Gesture.Exclusive(double, single))}
167
+ >
168
+ <View
169
+ testID="isoline-image-viewport"
170
+ onLayout={(event) => {
171
+ width.value = Math.max(1, event.nativeEvent.layout.width);
172
+ height.value = Math.max(1, event.nativeEvent.layout.height);
173
+ }}
174
+ style={{ flex: 1, minHeight: 140, overflow: 'hidden', borderRadius: 16 }}
175
+ >
176
+ <Animated.View style={[StyleSheet.absoluteFill, motion]}>
177
+ {renderImage ? (
178
+ renderImage(imageLoaded, () => setFailed(true))
179
+ ) : (
180
+ <Image
181
+ source={{ uri }}
182
+ resizeMode="contain"
183
+ accessibilityLabel={accessibilityLabel}
184
+ style={StyleSheet.absoluteFill}
185
+ onLoad={(event) => {
186
+ const size = imageDimensions(event);
187
+ if (size) imageLoaded(size.width, size.height);
188
+ else {
189
+ // Deferred browser load events can lose their DOM target.
190
+ // Ask the standard image loader for cached dimensions instead.
191
+ Image.getSize(
192
+ uri,
193
+ (w, h) => {
194
+ if (mounted.current && currentUri.current === uri) imageLoaded(w, h);
195
+ },
196
+ () => {},
197
+ );
198
+ }
199
+ }}
200
+ onError={() => setFailed(true)}
201
+ />
202
+ )}
203
+ </Animated.View>
204
+ {failed && labels.error ? <Text>{labels.error}</Text> : null}
205
+ </View>
206
+ </GestureDetector>
207
+ <Animated.View
208
+ pointerEvents="box-none"
209
+ style={[{ position: 'absolute', left: 8, right: 8 }, controlPosition]}
210
+ >
211
+ <ToolbarContainer
212
+ testID="isoline-image-controls"
213
+ material={material}
214
+ glassStyle={glassStyle}
215
+ tintColor={tintColor}
216
+ fallbackColor={fallbackColor}
217
+ interactive={interactive}
218
+ style={{ flexWrap: 'wrap', justifyContent: 'center', gap: 4 }}
219
+ >
220
+ <Button
221
+ size="s"
222
+ variant="ghost"
223
+ label={labels.zoomOut}
224
+ accessibilityLabel={labels.zoomOut}
225
+ disabled={shown <= 1}
226
+ onPress={() => zoom(scale.value / 1.5)}
227
+ />
228
+ <Button
229
+ size="s"
230
+ variant="ghost"
231
+ label={labels.zoomIn}
232
+ disabled={shown >= maximum}
233
+ onPress={() => zoom(scale.value * 1.5)}
234
+ />
235
+ <Button size="s" variant="ghost" label={labels.reset} onPress={reset} />
236
+ </ToolbarContainer>
237
+ </Animated.View>
238
+ </View>
239
+ );
240
+ }
@@ -0,0 +1,25 @@
1
+ import { lobedRadii, radiiToPath, shapeRadii } from './shape.ts';
2
+
3
+ /** Still expressive shapes as SVG paths on a 100-unit box. */
4
+
5
+ /** Sampled densely: a still shape is drawn once, so smoothness is free. */
6
+ const SAMPLES = 96;
7
+
8
+ export const EXPRESSIVE_SHAPES = {
9
+ clover: radiiToPath(lobedRadii(4, 0.22, SAMPLES, Math.PI / 4), 50, 50),
10
+ cookie: radiiToPath(lobedRadii(9, 0.1, SAMPLES, 0), 50, 50),
11
+ flower: radiiToPath(lobedRadii(6, 0.16, SAMPLES, Math.PI / 6), 50, 50),
12
+ // Pointing up: the angle runs clockwise from three o'clock on screen.
13
+ gable: radiiToPath(
14
+ shapeRadii({ vertices: 5, roundness: 0.55, rotation: Math.PI / 2 }, SAMPLES),
15
+ 50,
16
+ 52,
17
+ ),
18
+ square: radiiToPath(
19
+ shapeRadii({ vertices: 4, roundness: 0.75, rotation: Math.PI / 4 }, SAMPLES),
20
+ 50,
21
+ 58,
22
+ ),
23
+ } as const;
24
+
25
+ export type ExpressiveShapeName = keyof typeof EXPRESSIVE_SHAPES;