@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.
- 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
package/src/provider.tsx
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Platform, View, type ViewProps, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import { vars } from 'nativewind';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
resolveThemeColors,
|
|
7
|
+
rippleColors,
|
|
8
|
+
elevation,
|
|
9
|
+
type IsolineTheme,
|
|
10
|
+
type ThemeColors,
|
|
11
|
+
type RippleColors,
|
|
12
|
+
} from './theme.ts';
|
|
13
|
+
|
|
14
|
+
import type { MaterialOptions } from './material.ts';
|
|
15
|
+
export type { MaterialOptions } from './material.ts';
|
|
16
|
+
import { resolvePlatform, type IsolinePlatform } from './platform.ts';
|
|
17
|
+
export type { IsolinePlatform } from './platform.ts';
|
|
18
|
+
export { elevation };
|
|
19
|
+
export interface IsolineSurfaceProps extends ViewProps, MaterialOptions {}
|
|
20
|
+
export interface IsolineSurfaceGroupProps extends ViewProps {
|
|
21
|
+
spacing?: number;
|
|
22
|
+
gap?: number;
|
|
23
|
+
animate?: boolean;
|
|
24
|
+
}
|
|
25
|
+
export type { ThemeColors, RippleColors };
|
|
26
|
+
export interface IsolineLabels {
|
|
27
|
+
close: string;
|
|
28
|
+
back: string;
|
|
29
|
+
showPassword: string;
|
|
30
|
+
hidePassword: string;
|
|
31
|
+
}
|
|
32
|
+
export interface IsolineHaptics {
|
|
33
|
+
selectionHaptic: () => void;
|
|
34
|
+
impactHaptic: (style: 'light' | 'medium') => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const noHaptics: IsolineHaptics = { selectionHaptic: () => {}, impactHaptic: () => {} };
|
|
38
|
+
|
|
39
|
+
interface UIContext {
|
|
40
|
+
platform: IsolinePlatform;
|
|
41
|
+
surfaceComponent?: React.ComponentType<IsolineSurfaceProps>;
|
|
42
|
+
surfaceGroupComponent?: React.ComponentType<IsolineSurfaceGroupProps>;
|
|
43
|
+
theme: IsolineTheme;
|
|
44
|
+
colors: ThemeColors;
|
|
45
|
+
ripple: RippleColors;
|
|
46
|
+
labels: IsolineLabels;
|
|
47
|
+
haptics: IsolineHaptics;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const Context = React.createContext<UIContext | null>(null);
|
|
51
|
+
export interface IsolineProviderProps {
|
|
52
|
+
/** Browser design preview; native devices always follow their OS. */
|
|
53
|
+
platform?: IsolinePlatform;
|
|
54
|
+
/** Supply MaterialSurface from @isoline-ui/native for native/browser glass. */
|
|
55
|
+
surfaceComponent?: React.ComponentType<IsolineSurfaceProps>;
|
|
56
|
+
surfaceGroupComponent?: React.ComponentType<IsolineSurfaceGroupProps>;
|
|
57
|
+
theme: IsolineTheme;
|
|
58
|
+
surface?: React.ComponentType<IsolineSurfaceProps>;
|
|
59
|
+
labels?: Partial<IsolineLabels>;
|
|
60
|
+
haptics?: Partial<IsolineHaptics>;
|
|
61
|
+
children: React.ReactNode;
|
|
62
|
+
className?: string;
|
|
63
|
+
style?: StyleProp<ViewStyle>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Supplies component contracts and CSS roles together. Preferences, translation,
|
|
68
|
+
* font loading and native haptic implementations remain in the consuming app.
|
|
69
|
+
* A nested provider inherits labels/haptics; its theme is explicit.
|
|
70
|
+
*/
|
|
71
|
+
export function IsolineProvider({
|
|
72
|
+
theme,
|
|
73
|
+
platform,
|
|
74
|
+
surface,
|
|
75
|
+
surfaceComponent,
|
|
76
|
+
surfaceGroupComponent,
|
|
77
|
+
labels,
|
|
78
|
+
haptics,
|
|
79
|
+
children,
|
|
80
|
+
className,
|
|
81
|
+
style,
|
|
82
|
+
}: IsolineProviderProps) {
|
|
83
|
+
const parent = React.useContext(Context);
|
|
84
|
+
const value = React.useMemo<UIContext>(
|
|
85
|
+
() => ({
|
|
86
|
+
theme,
|
|
87
|
+
platform: resolvePlatform(Platform.OS, platform, parent?.platform),
|
|
88
|
+
surfaceComponent: surfaceComponent ?? surface ?? parent?.surfaceComponent,
|
|
89
|
+
surfaceGroupComponent: surfaceGroupComponent ?? parent?.surfaceGroupComponent,
|
|
90
|
+
colors: resolveThemeColors(theme),
|
|
91
|
+
ripple: rippleColors(theme.scheme),
|
|
92
|
+
labels: {
|
|
93
|
+
close: 'Close',
|
|
94
|
+
back: 'Back',
|
|
95
|
+
showPassword: 'Show password',
|
|
96
|
+
hidePassword: 'Hide password',
|
|
97
|
+
...parent?.labels,
|
|
98
|
+
...labels,
|
|
99
|
+
},
|
|
100
|
+
haptics: { ...noHaptics, ...parent?.haptics, ...haptics },
|
|
101
|
+
}),
|
|
102
|
+
[
|
|
103
|
+
theme,
|
|
104
|
+
platform,
|
|
105
|
+
surface,
|
|
106
|
+
surfaceComponent,
|
|
107
|
+
surfaceGroupComponent,
|
|
108
|
+
labels,
|
|
109
|
+
haptics,
|
|
110
|
+
parent,
|
|
111
|
+
],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<Context.Provider value={value}>
|
|
116
|
+
<View className={className} style={[{ flex: 1 }, vars(theme.tokens), style]}>
|
|
117
|
+
{children}
|
|
118
|
+
</View>
|
|
119
|
+
</Context.Provider>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function useUI() {
|
|
124
|
+
const value = React.useContext(Context);
|
|
125
|
+
if (!value) throw new Error('Isoline UI components must be inside IsolineProvider');
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
export function useIsolineTheme() {
|
|
129
|
+
return useUI().theme;
|
|
130
|
+
}
|
|
131
|
+
export function useThemeColors() {
|
|
132
|
+
return useUI().colors;
|
|
133
|
+
}
|
|
134
|
+
export function useRipple() {
|
|
135
|
+
return useUI().ripple;
|
|
136
|
+
}
|
|
137
|
+
export function useLabels() {
|
|
138
|
+
return useUI().labels;
|
|
139
|
+
}
|
|
140
|
+
export function useHaptics() {
|
|
141
|
+
return useUI().haptics;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function useIsolinePlatform() {
|
|
145
|
+
return useUI().platform;
|
|
146
|
+
}
|
|
147
|
+
/** Surface adapter keeps optional Expo/native dependencies out of the UI package. */
|
|
148
|
+
export function Surface({
|
|
149
|
+
material = 'auto',
|
|
150
|
+
interactive,
|
|
151
|
+
glassStyle,
|
|
152
|
+
tintColor,
|
|
153
|
+
fallbackColor,
|
|
154
|
+
style,
|
|
155
|
+
...props
|
|
156
|
+
}: IsolineSurfaceProps) {
|
|
157
|
+
const { surfaceComponent: Component, colors } = useUI();
|
|
158
|
+
if (Component)
|
|
159
|
+
return (
|
|
160
|
+
<Component
|
|
161
|
+
material={material}
|
|
162
|
+
interactive={interactive}
|
|
163
|
+
glassStyle={glassStyle}
|
|
164
|
+
tintColor={tintColor}
|
|
165
|
+
fallbackColor={fallbackColor}
|
|
166
|
+
style={style}
|
|
167
|
+
{...props}
|
|
168
|
+
/>
|
|
169
|
+
);
|
|
170
|
+
return (
|
|
171
|
+
<View
|
|
172
|
+
{...props}
|
|
173
|
+
style={[{ backgroundColor: fallbackColor ?? colors.surfaceContainer }, style]}
|
|
174
|
+
/>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function useSurface() {
|
|
179
|
+
return Surface;
|
|
180
|
+
}
|
|
181
|
+
/** Reapply CSS variables inside web modal portals, outside the provider DOM node. */
|
|
182
|
+
export function ThemeScope({ style, ...props }: ViewProps) {
|
|
183
|
+
const { theme } = useUI();
|
|
184
|
+
return <View {...props} style={[vars(theme.tokens), style]} />;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Native adapters can merge neighboring glass; solid/web adapters retain layout. */
|
|
188
|
+
export function SurfaceGroup({
|
|
189
|
+
spacing,
|
|
190
|
+
gap,
|
|
191
|
+
animate = true,
|
|
192
|
+
style,
|
|
193
|
+
...props
|
|
194
|
+
}: IsolineSurfaceGroupProps) {
|
|
195
|
+
const { surfaceGroupComponent: Component } = useUI();
|
|
196
|
+
return Component ? (
|
|
197
|
+
<Component spacing={spacing} gap={gap} animate={animate} style={style} {...props} />
|
|
198
|
+
) : (
|
|
199
|
+
<View {...props} style={[gap !== undefined && { gap }, style]} />
|
|
200
|
+
);
|
|
201
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { ReduceMotion, useReducedMotion } from 'react-native-reanimated';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Honouring "Remove animations".
|
|
5
|
+
*
|
|
6
|
+
* Android's accessibility settings carry a switch that asks the whole system
|
|
7
|
+
* to stop moving things, and people turn it on for reasons that are not about
|
|
8
|
+
* taste: vestibular disorders, migraine, motion sickness, or simply not being
|
|
9
|
+
* able to read a screen that keeps sliding. This app is unusually animated —
|
|
10
|
+
* springs everywhere, a wave travelling along every progress bar — so ignoring
|
|
11
|
+
* that switch is not a small omission.
|
|
12
|
+
*
|
|
13
|
+
* The rule is not "turn every animation off". It is: **motion goes, meaning
|
|
14
|
+
* stays.** Three kinds of animation live in this app and they are treated
|
|
15
|
+
* differently:
|
|
16
|
+
*
|
|
17
|
+
* - **Decoration** — a wave travelling along a bar, a toolbar's items
|
|
18
|
+
* unfolding one after another, a card springing in. All of this goes.
|
|
19
|
+
* - **Orientation** — a sheet rising from the edge it belongs to, a row
|
|
20
|
+
* sliding out of a list. The movement goes; the end state arrives at once.
|
|
21
|
+
* - **Information** — the undo bar's countdown, which is the only thing
|
|
22
|
+
* telling the reader how long they have left to change their mind. This one
|
|
23
|
+
* *must not* go. Turning it off would not remove a distraction, it would
|
|
24
|
+
* remove the answer to "how long do I have", and the reader who needs
|
|
25
|
+
* reduced motion is not a reader who needs less information.
|
|
26
|
+
*
|
|
27
|
+
* Reanimated already defaults its own animations to `ReduceMotion.System`, so
|
|
28
|
+
* most of the first two categories are handled by the platform once
|
|
29
|
+
* `ReducedMotionConfig` states the policy. What this module is for is the
|
|
30
|
+
* third: the places that have to say `ReduceMotion.Never` on purpose, and the
|
|
31
|
+
* places where the app's own decorative loops have to be stopped by hand
|
|
32
|
+
* because Reanimated has no way to know they are decorative.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export { useReducedMotion };
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* For an animation that carries information rather than polish.
|
|
39
|
+
*
|
|
40
|
+
* Spelled out at each call site rather than defaulted, so that opting a piece
|
|
41
|
+
* of motion out of the accessibility setting is a decision somebody made and
|
|
42
|
+
* can be argued with — there should only ever be a handful.
|
|
43
|
+
*/
|
|
44
|
+
export const KEEP_MOTION = ReduceMotion.Never;
|
|
45
|
+
|
|
46
|
+
/** For everything else: the platform's answer, whatever it is. */
|
|
47
|
+
export const SYSTEM_MOTION = ReduceMotion.System;
|
|
48
|
+
|
|
49
|
+
/** Re-exported so a call site needs one import, not two. See `~/lib/motion`. */
|
|
50
|
+
export { staggerFor } from './motion.ts';
|
package/src/selection.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Clamp and quantize controlled slider values without accumulating decimal drift. */
|
|
2
|
+
export function sliderValue(value: number, min = 0, max = 1, step = 0) {
|
|
3
|
+
if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min)
|
|
4
|
+
return Number.isFinite(min) ? min : 0;
|
|
5
|
+
const finite = Number.isFinite(value) ? value : min;
|
|
6
|
+
const snapped =
|
|
7
|
+
step > 0 && Number.isFinite(step)
|
|
8
|
+
? Number((min + Math.round((finite - min) / step) * step).toPrecision(12))
|
|
9
|
+
: finite;
|
|
10
|
+
return Math.max(min, Math.min(max, snapped));
|
|
11
|
+
}
|
package/src/shape.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Material 3 Expressive shape system.
|
|
3
|
+
*
|
|
4
|
+
* Expressive treats shape as a *state*, not a constant. A component's corners
|
|
5
|
+
* change when it is pressed, selected or busy, and the change is animated by a
|
|
6
|
+
* spatial spring (see `motion.ts`). The two halves of that idea live here:
|
|
7
|
+
*
|
|
8
|
+
* - `corner`, the shape scale every radius in the app is drawn from, so a
|
|
9
|
+
* round-to-square morph moves between two named steps rather than two magic
|
|
10
|
+
* numbers;
|
|
11
|
+
* - the polygon helpers, which make a *shape sequence* possible — morphing a
|
|
12
|
+
* 4-sided shape into a 7-sided one, which is how the Expressive loading
|
|
13
|
+
* indicator is drawn.
|
|
14
|
+
*
|
|
15
|
+
* Everything here is pure maths with no `expo-*` or React imports, so it is
|
|
16
|
+
* covered by `node --test`. The functions used inside animated props are
|
|
17
|
+
* marked `'worklet'` so they can run on the UI thread.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The Expressive corner scale, in dp. `full` is a pill, sized at runtime. */
|
|
21
|
+
export const corner = {
|
|
22
|
+
none: 0,
|
|
23
|
+
extraSmall: 4,
|
|
24
|
+
small: 8,
|
|
25
|
+
medium: 12,
|
|
26
|
+
large: 16,
|
|
27
|
+
largeIncreased: 20,
|
|
28
|
+
extraLarge: 28,
|
|
29
|
+
extraLargeIncreased: 32,
|
|
30
|
+
extraExtraLarge: 48,
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Number of points a shape is sampled at.
|
|
35
|
+
*
|
|
36
|
+
* Every shape in a morph sequence is resampled to this many points so two
|
|
37
|
+
* shapes with different vertex counts can be interpolated pairwise. 48 is
|
|
38
|
+
* enough that a 9-sided polygon still reads as one, and cheap enough to
|
|
39
|
+
* rebuild every frame on the UI thread.
|
|
40
|
+
*/
|
|
41
|
+
export const SHAPE_SAMPLES = 48;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Samples a regular polygon as a radius per angle.
|
|
45
|
+
*
|
|
46
|
+
* Describing a shape radially — rather than as a vertex list — is what makes
|
|
47
|
+
* shapes with different vertex counts morphable: two radius arrays of equal
|
|
48
|
+
* length always interpolate, whereas two vertex lists of different lengths do
|
|
49
|
+
* not. Radii are normalised so the polygon's corners touch the unit circle.
|
|
50
|
+
*/
|
|
51
|
+
export function polygonRadii(
|
|
52
|
+
vertices: number,
|
|
53
|
+
samples: number = SHAPE_SAMPLES,
|
|
54
|
+
rotation = 0,
|
|
55
|
+
): number[] {
|
|
56
|
+
const step = (Math.PI * 2) / vertices;
|
|
57
|
+
// Centre-to-edge distance. A corner sits at 1, an edge midpoint at `apothem`.
|
|
58
|
+
const apothem = Math.cos(Math.PI / vertices);
|
|
59
|
+
const radii: number[] = [];
|
|
60
|
+
|
|
61
|
+
for (let index = 0; index < samples; index += 1) {
|
|
62
|
+
const theta = (index / samples) * Math.PI * 2 + rotation;
|
|
63
|
+
// Angle from the nearest edge midpoint, folded into one polygon segment.
|
|
64
|
+
const local = (((theta % step) + step) % step) - step / 2;
|
|
65
|
+
radii.push(apothem / Math.cos(local));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return radii;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Rounds a shape's corners by averaging its radii with their neighbours.
|
|
73
|
+
*
|
|
74
|
+
* A circular moving average over the radial function is a corner rounding:
|
|
75
|
+
* the sharp maxima at the vertices flatten while the edges stay put. `amount`
|
|
76
|
+
* runs 0 (the raw polygon) to 1 (nearly a circle), which is the "roundness"
|
|
77
|
+
* dimension of the Expressive shape library.
|
|
78
|
+
*/
|
|
79
|
+
export function roundRadii(radii: number[], amount: number): number[] {
|
|
80
|
+
const span = Math.max(0, Math.round((radii.length / 8) * amount));
|
|
81
|
+
if (span === 0) return radii.slice();
|
|
82
|
+
|
|
83
|
+
const rounded: number[] = [];
|
|
84
|
+
for (let index = 0; index < radii.length; index += 1) {
|
|
85
|
+
let total = 0;
|
|
86
|
+
for (let offset = -span; offset <= span; offset += 1) {
|
|
87
|
+
total += radii[(index + offset + radii.length * 2) % radii.length]!;
|
|
88
|
+
}
|
|
89
|
+
rounded.push(total / (span * 2 + 1));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return rounded;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A shape in a morph sequence: how many corners, how round, how turned. */
|
|
96
|
+
export interface ShapeSpec {
|
|
97
|
+
vertices: number;
|
|
98
|
+
roundness: number;
|
|
99
|
+
rotation: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Builds the radius array for a shape, ready to be interpolated. */
|
|
103
|
+
export function shapeRadii(shape: ShapeSpec, samples: number = SHAPE_SAMPLES): number[] {
|
|
104
|
+
return roundRadii(polygonRadii(shape.vertices, samples, shape.rotation), shape.roundness);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A lobed shape: a circle with `lobes` soft bumps around its edge.
|
|
109
|
+
*
|
|
110
|
+
* The scalloped members of the Expressive shape library — the 4-leaf clover,
|
|
111
|
+
* the 9-sided cookie — are not rounded polygons: their edges dip *inward*
|
|
112
|
+
* between the lobes, which averaging a polygon's radii can never produce. A
|
|
113
|
+
* cosine around the circle can. `depth` is how far each dip reaches toward the
|
|
114
|
+
* centre, as a fraction of the radius; the lobes still touch the unit circle.
|
|
115
|
+
*/
|
|
116
|
+
export function lobedRadii(
|
|
117
|
+
lobes: number,
|
|
118
|
+
depth: number,
|
|
119
|
+
samples: number = SHAPE_SAMPLES,
|
|
120
|
+
rotation = 0,
|
|
121
|
+
): number[] {
|
|
122
|
+
const radii: number[] = [];
|
|
123
|
+
for (let index = 0; index < samples; index += 1) {
|
|
124
|
+
const theta = (index / samples) * Math.PI * 2 + rotation;
|
|
125
|
+
radii.push(1 - depth * (0.5 - 0.5 * Math.cos(lobes * theta)));
|
|
126
|
+
}
|
|
127
|
+
return radii;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Blends two sampled shapes. `t` runs 0 (all `from`) to 1 (all `to`).
|
|
132
|
+
*
|
|
133
|
+
* Runs on the UI thread once per frame, which is why it takes prebuilt arrays
|
|
134
|
+
* rather than `ShapeSpec`s — sampling is done once at module load.
|
|
135
|
+
*/
|
|
136
|
+
export function blendRadii(from: number[], to: number[], t: number): number[] {
|
|
137
|
+
'worklet';
|
|
138
|
+
const blended: number[] = [];
|
|
139
|
+
for (let index = 0; index < from.length; index += 1) {
|
|
140
|
+
const a = from[index]!;
|
|
141
|
+
const b = to[index] ?? a;
|
|
142
|
+
blended.push(a + (b - a) * t);
|
|
143
|
+
}
|
|
144
|
+
return blended;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Turns a radius array into a closed SVG path.
|
|
149
|
+
*
|
|
150
|
+
* The points are joined with a Catmull-Rom spline converted to cubic Béziers,
|
|
151
|
+
* so the outline stays smooth as the radii move under it. Drawing straight
|
|
152
|
+
* segments instead would make a morph visibly snap between sample points.
|
|
153
|
+
*/
|
|
154
|
+
export function radiiToPath(radii: number[], center: number, scale: number): string {
|
|
155
|
+
'worklet';
|
|
156
|
+
const count = radii.length;
|
|
157
|
+
const points: { x: number; y: number }[] = [];
|
|
158
|
+
|
|
159
|
+
for (let index = 0; index < count; index += 1) {
|
|
160
|
+
const theta = (index / count) * Math.PI * 2;
|
|
161
|
+
const radius = radii[index]! * scale;
|
|
162
|
+
points.push({ x: center + Math.cos(theta) * radius, y: center + Math.sin(theta) * radius });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let path = `M${points[0]!.x.toFixed(2)} ${points[0]!.y.toFixed(2)}`;
|
|
166
|
+
|
|
167
|
+
for (let index = 0; index < count; index += 1) {
|
|
168
|
+
const previous = points[(index - 1 + count) % count]!;
|
|
169
|
+
const current = points[index]!;
|
|
170
|
+
const next = points[(index + 1) % count]!;
|
|
171
|
+
const after = points[(index + 2) % count]!;
|
|
172
|
+
|
|
173
|
+
// Catmull-Rom → Bézier: the control points are a sixth of the way along
|
|
174
|
+
// the chord through each endpoint's neighbours.
|
|
175
|
+
const c1x = current.x + (next.x - previous.x) / 6;
|
|
176
|
+
const c1y = current.y + (next.y - previous.y) / 6;
|
|
177
|
+
const c2x = next.x - (after.x - current.x) / 6;
|
|
178
|
+
const c2y = next.y - (after.y - current.y) / 6;
|
|
179
|
+
|
|
180
|
+
path += `C${c1x.toFixed(2)} ${c1y.toFixed(2)} ${c2x.toFixed(2)} ${c2y.toFixed(2)} ${next.x.toFixed(2)} ${next.y.toFixed(2)}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return `${path}Z`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The Expressive wavy progress track.
|
|
188
|
+
*
|
|
189
|
+
* The active half of an Expressive progress indicator is a sine wave rather
|
|
190
|
+
* than a bar: the wave is what distinguishes "working" from "a coloured
|
|
191
|
+
* rectangle" at a glance, and its amplitude can fall to zero to signal that
|
|
192
|
+
* the indicator has settled.
|
|
193
|
+
*/
|
|
194
|
+
export function wavePath(
|
|
195
|
+
width: number,
|
|
196
|
+
height: number,
|
|
197
|
+
amplitude: number,
|
|
198
|
+
wavelength: number,
|
|
199
|
+
phase: number,
|
|
200
|
+
): string {
|
|
201
|
+
'worklet';
|
|
202
|
+
const middle = height / 2;
|
|
203
|
+
if (width <= 0) return `M0 ${middle}`;
|
|
204
|
+
|
|
205
|
+
// A sample every 2dp keeps the curve smooth without emitting a path so long
|
|
206
|
+
// it costs more to parse than to draw.
|
|
207
|
+
const steps = Math.max(2, Math.ceil(width / 2));
|
|
208
|
+
let path = '';
|
|
209
|
+
|
|
210
|
+
for (let index = 0; index <= steps; index += 1) {
|
|
211
|
+
const x = (index / steps) * width;
|
|
212
|
+
const y = middle + Math.sin((x / wavelength) * Math.PI * 2 + phase) * amplitude;
|
|
213
|
+
path += `${index === 0 ? 'M' : 'L'}${x.toFixed(2)} ${y.toFixed(2)}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return path;
|
|
217
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The arithmetic of a bottom sheet under a finger, kept apart from the views
|
|
3
|
+
* so it can be tested under `node --test`. Every function is a worklet: the
|
|
4
|
+
* pull runs on the UI thread.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** A pull past this share of the panel's height closes it on release. */
|
|
8
|
+
export const CLOSE_SHARE = 0.3;
|
|
9
|
+
/** A flick down faster than this (points a second) closes it however short. */
|
|
10
|
+
export const CLOSE_VELOCITY = 900;
|
|
11
|
+
/** How far a pull up can lift the panel, at most: it gives, but not much. */
|
|
12
|
+
export const LIFT_LIMIT = 24;
|
|
13
|
+
|
|
14
|
+
/** Whether letting go `pull` points below the rest, moving at `velocity`, closes the sheet. */
|
|
15
|
+
export function releaseCloses(pull: number, velocity: number, height: number): boolean {
|
|
16
|
+
'worklet';
|
|
17
|
+
if (pull <= 0) return false;
|
|
18
|
+
return pull > height * CLOSE_SHARE || velocity > CLOSE_VELOCITY;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* How far the panel rises for a finger `pull` points up: it follows at first
|
|
23
|
+
* and gives less the further it goes, never more than `limit`. A panel that
|
|
24
|
+
* does not move at all under an upward pull feels stuck rather than anchored.
|
|
25
|
+
*/
|
|
26
|
+
export function rubberBand(pull: number, limit?: number): number {
|
|
27
|
+
'worklet';
|
|
28
|
+
// Not a default parameter: a worklet unpacks the constants it captured at the
|
|
29
|
+
// top of its body, after the defaults have run, so `limit = LIFT_LIMIT` looks
|
|
30
|
+
// for a global that does not exist on the UI thread and the pull crashes.
|
|
31
|
+
const most = limit ?? LIFT_LIMIT;
|
|
32
|
+
if (pull <= 0) return 0;
|
|
33
|
+
return most * (1 - 1 / ((pull * 0.55) / most + 1));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* How dark the scrim is, 0–1, with the panel `offset` points below its rest:
|
|
38
|
+
* full while it rests (or is lifted), clear once it is all the way down. The
|
|
39
|
+
* scrim follows the panel rather than a clock of its own, so the two arrive
|
|
40
|
+
* and leave together and a pull lightens it as it goes.
|
|
41
|
+
*/
|
|
42
|
+
export function scrimShare(offset: number, height: number): number {
|
|
43
|
+
'worklet';
|
|
44
|
+
if (height <= 0) return 0;
|
|
45
|
+
return Math.min(1, Math.max(0, 1 - offset / height));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Where a scrolling list inside a sheet is, as the sheet's pull needs to know it. */
|
|
49
|
+
export interface SheetScroll {
|
|
50
|
+
/** How far it is scrolled from its top. */
|
|
51
|
+
y: number;
|
|
52
|
+
/** Whether its content is taller than it is, so that it scrolls at all. */
|
|
53
|
+
scrollable: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Whether every list in the sheet is at its top, so a pull down moves the sheet. */
|
|
57
|
+
export function listsAtTop(scrolls: Record<string, SheetScroll>): boolean {
|
|
58
|
+
'worklet';
|
|
59
|
+
for (const key of Object.keys(scrolls)) {
|
|
60
|
+
const scroll = scrolls[key];
|
|
61
|
+
if (scroll && scroll.scrollable && scroll.y > 0.5) return false;
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Whether a list in the sheet scrolls, so a pull up belongs to it and not to the panel. */
|
|
67
|
+
export function anyScrollable(scrolls: Record<string, SheetScroll>): boolean {
|
|
68
|
+
'worklet';
|
|
69
|
+
for (const key of Object.keys(scrolls)) {
|
|
70
|
+
if (scrolls[key]?.scrollable) return true;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|