@isoline-ui/react-native 0.0.0-stage → 0.1.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/NOTICE.md +12 -0
- package/README.md +104 -3
- package/dist/amount.d.ts +3 -0
- package/dist/calendar.d.ts +7 -0
- package/dist/components/action-chip.d.ts +9 -0
- package/dist/components/amount-field.d.ts +12 -0
- package/dist/components/avatar.d.ts +17 -0
- package/dist/components/back-button.d.ts +12 -0
- package/dist/components/back-icon.d.ts +3 -0
- package/dist/components/badge.d.ts +13 -0
- package/dist/components/banner.d.ts +14 -0
- package/dist/components/bottom-sheet-native.d.ts +11 -0
- package/dist/components/bottom-sheet-web.d.ts +18 -0
- package/dist/components/bottom-sheet.d.ts +3 -0
- package/dist/components/button-group.d.ts +26 -0
- package/dist/components/button.d.ts +48 -0
- package/dist/components/checkbox.d.ts +12 -0
- package/dist/components/color-picker.d.ts +21 -0
- package/dist/components/context-menu.d.ts +26 -0
- package/dist/components/date-picker.d.ts +64 -0
- package/dist/components/dialog-context.d.ts +2 -0
- package/dist/components/dialog.d.ts +20 -0
- package/dist/components/empty-state.d.ts +15 -0
- package/dist/components/expand-indicator.d.ts +27 -0
- package/dist/components/expressive-shape.d.ts +18 -0
- package/dist/components/fab-menu-legacy.d.ts +31 -0
- package/dist/components/fab-menu.d.ts +21 -0
- package/dist/components/filter-chip.d.ts +69 -0
- package/dist/components/floating-action-button.d.ts +15 -0
- package/dist/components/icon-button.d.ts +40 -0
- package/dist/components/icons.d.ts +18 -0
- package/dist/components/input.d.ts +9 -0
- package/dist/components/keyboard-inset-view.d.ts +15 -0
- package/dist/components/list-controls.d.ts +38 -0
- package/dist/components/loading-indicator.d.ts +9 -0
- package/dist/components/menu-sheet.d.ts +36 -0
- package/dist/components/panel-sheet.d.ts +8 -0
- package/dist/components/password-visibility.d.ts +5 -0
- package/dist/components/popover-layout.d.ts +25 -0
- package/dist/components/popover.d.ts +20 -0
- package/dist/components/pressable.d.ts +12 -0
- package/dist/components/radio-navigation.d.ts +11 -0
- package/dist/components/refresh-control.d.ts +16 -0
- package/dist/components/row-gap.d.ts +9 -0
- package/dist/components/search-field.d.ts +22 -0
- package/dist/components/select-field.d.ts +16 -0
- package/dist/components/settings-list.d.ts +98 -0
- package/dist/components/settings-row.d.ts +13 -0
- package/dist/components/sheet-frame.d.ts +27 -0
- package/dist/components/sheet-lifecycle.d.ts +36 -0
- package/dist/components/sheet-transition.d.ts +36 -0
- package/dist/components/slider.d.ts +21 -0
- package/dist/components/snackbar.d.ts +10 -0
- package/dist/components/split-button.d.ts +38 -0
- package/dist/components/squish.d.ts +20 -0
- package/dist/components/swipeable-row.d.ts +20 -0
- package/dist/components/switch.d.ts +8 -0
- package/dist/components/tab-bar.d.ts +20 -0
- package/dist/components/text-field.d.ts +31 -0
- package/dist/components/text.d.ts +9 -0
- package/dist/components/time-picker.d.ts +40 -0
- package/dist/components/toolbar.d.ts +77 -0
- package/dist/components/top-bar.d.ts +27 -0
- package/dist/components/undo-bar.d.ts +23 -0
- package/dist/components/wavy-progress.d.ts +21 -0
- package/dist/components/zoomable-image.d.ts +18 -0
- package/dist/expressive-shapes.d.ts +8 -0
- package/dist/index.d.ts +62 -0
- package/dist/keyboard.d.ts +2 -0
- package/dist/material.d.ts +11 -0
- package/dist/media.d.ts +11 -0
- package/dist/motion.d.ts +96 -0
- package/dist/platform.d.ts +3 -0
- package/dist/preset-tokens.d.ts +264 -0
- package/dist/provider.d.ts +59 -0
- package/dist/reduced-motion.d.ts +45 -0
- package/dist/selection.d.ts +2 -0
- package/dist/shape.d.ts +99 -0
- package/dist/sheet-physics.d.ts +37 -0
- package/dist/theme.d.ts +103 -0
- package/dist/tokens.d.ts +2 -0
- package/dist/use-keyboard-height.d.ts +10 -0
- package/dist/utils.d.ts +3 -0
- package/nativewind.cjs +68 -0
- package/package.json +63 -6
- package/src/amount.ts +53 -0
- package/src/calendar.ts +42 -0
- package/src/components/action-chip.tsx +64 -0
- package/src/components/amount-field.tsx +49 -0
- package/src/components/avatar.tsx +91 -0
- package/src/components/back-button.tsx +39 -0
- package/src/components/back-icon.tsx +25 -0
- package/src/components/badge.tsx +41 -0
- package/src/components/banner.tsx +63 -0
- package/src/components/bottom-sheet-native.tsx +52 -0
- package/src/components/bottom-sheet-web.tsx +193 -0
- package/src/components/bottom-sheet.tsx +5 -0
- package/src/components/button-group.tsx +326 -0
- package/src/components/button.tsx +337 -0
- package/src/components/checkbox.tsx +71 -0
- package/src/components/color-picker.tsx +115 -0
- package/src/components/context-menu.tsx +149 -0
- package/src/components/date-picker.tsx +466 -0
- package/src/components/dialog-context.ts +3 -0
- package/src/components/dialog.tsx +210 -0
- package/src/components/empty-state.tsx +49 -0
- package/src/components/expand-indicator.tsx +60 -0
- package/src/components/expressive-shape.tsx +37 -0
- package/src/components/fab-menu-legacy.tsx +181 -0
- package/src/components/fab-menu.tsx +90 -0
- package/src/components/filter-chip.tsx +136 -0
- package/src/components/floating-action-button.tsx +79 -0
- package/src/components/icon-button.tsx +127 -0
- package/src/components/icons.tsx +43 -0
- package/src/components/input.tsx +80 -0
- package/src/components/keyboard-inset-view.tsx +28 -0
- package/src/components/list-controls.tsx +134 -0
- package/src/components/loading-indicator.tsx +183 -0
- package/src/components/menu-sheet.tsx +133 -0
- package/src/components/panel-sheet.tsx +90 -0
- package/src/components/password-visibility.tsx +39 -0
- package/src/components/popover-layout.ts +47 -0
- package/src/components/popover.tsx +139 -0
- package/src/components/pressable.tsx +108 -0
- package/src/components/radio-navigation.ts +52 -0
- package/src/components/refresh-control.tsx +39 -0
- package/src/components/row-gap.tsx +13 -0
- package/src/components/search-field.tsx +92 -0
- package/src/components/select-field.tsx +105 -0
- package/src/components/settings-list.tsx +302 -0
- package/src/components/settings-row.tsx +61 -0
- package/src/components/sheet-frame.tsx +250 -0
- package/src/components/sheet-lifecycle.ts +233 -0
- package/src/components/sheet-transition.ts +105 -0
- package/src/components/slider.tsx +185 -0
- package/src/components/snackbar.tsx +97 -0
- package/src/components/split-button.tsx +212 -0
- package/src/components/squish.ts +41 -0
- package/src/components/swipeable-row.tsx +109 -0
- package/src/components/switch.tsx +77 -0
- package/src/components/tab-bar.tsx +195 -0
- package/src/components/text-field.tsx +264 -0
- package/src/components/text.tsx +58 -0
- package/src/components/time-picker.tsx +277 -0
- package/src/components/toolbar.tsx +406 -0
- package/src/components/top-bar.tsx +91 -0
- package/src/components/undo-bar.tsx +137 -0
- package/src/components/wavy-progress.tsx +150 -0
- package/src/components/zoomable-image.tsx +240 -0
- package/src/expressive-shapes.ts +25 -0
- package/src/index.ts +62 -0
- package/src/keyboard.ts +5 -0
- package/src/material.ts +16 -0
- package/src/media.ts +51 -0
- package/src/motion.ts +96 -0
- package/src/platform.ts +13 -0
- package/src/preset-tokens.ts +265 -0
- package/src/provider.tsx +201 -0
- package/src/reduced-motion.ts +50 -0
- package/src/selection.ts +11 -0
- package/src/shape.ts +217 -0
- package/src/sheet-physics.ts +73 -0
- package/src/theme.ts +180 -0
- package/src/tokens.ts +2 -0
- package/src/use-keyboard-height.ts +64 -0
- 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;
|