panelui-native 0.81.0 → 0.82.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/lib/module/components/bottom-sheet/index.js +90 -17
- package/lib/module/components/bottom-sheet/index.js.map +1 -1
- package/lib/module/components/button/index.js +13 -0
- package/lib/module/components/button/index.js.map +1 -1
- package/lib/module/components/button-group/index.js +3 -0
- package/lib/module/components/button-group/index.js.map +1 -1
- package/lib/module/components/image-generation/dot-field.js +210 -0
- package/lib/module/components/image-generation/dot-field.js.map +1 -0
- package/lib/module/components/image-generation/index.js +417 -0
- package/lib/module/components/image-generation/index.js.map +1 -0
- package/lib/module/components/kpi/index.js +2 -4
- package/lib/module/components/kpi/index.js.map +1 -1
- package/lib/module/components/menu/index.js +8 -16
- package/lib/module/components/menu/index.js.map +1 -1
- package/lib/module/components/panelside/index.js +340 -33
- package/lib/module/components/panelside/index.js.map +1 -1
- package/lib/module/components/select/index.js +127 -23
- package/lib/module/components/select/index.js.map +1 -1
- package/lib/module/components/slide-button/index.js +610 -0
- package/lib/module/components/slide-button/index.js.map +1 -0
- package/lib/module/components/slide-button/slide-button-track.js +98 -0
- package/lib/module/components/slide-button/slide-button-track.js.map +1 -0
- package/lib/module/icons/index.js +553 -1934
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +3 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/primitives/portal.js +15 -1
- package/lib/module/primitives/portal.js.map +1 -1
- package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/src/components/button/index.d.ts +12 -0
- package/lib/typescript/src/components/button/index.d.ts.map +1 -1
- package/lib/typescript/src/components/button-group/index.d.ts +9 -0
- package/lib/typescript/src/components/button-group/index.d.ts.map +1 -1
- package/lib/typescript/src/components/image-generation/dot-field.d.ts +61 -0
- package/lib/typescript/src/components/image-generation/dot-field.d.ts.map +1 -0
- package/lib/typescript/src/components/image-generation/index.d.ts +126 -0
- package/lib/typescript/src/components/image-generation/index.d.ts.map +1 -0
- package/lib/typescript/src/components/kpi/index.d.ts.map +1 -1
- package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
- package/lib/typescript/src/components/panelside/index.d.ts +194 -10
- package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
- package/lib/typescript/src/components/select/index.d.ts +75 -2
- package/lib/typescript/src/components/select/index.d.ts.map +1 -1
- package/lib/typescript/src/components/slide-button/index.d.ts +317 -0
- package/lib/typescript/src/components/slide-button/index.d.ts.map +1 -0
- package/lib/typescript/src/components/slide-button/slide-button-track.d.ts +70 -0
- package/lib/typescript/src/components/slide-button/slide-button-track.d.ts.map +1 -0
- package/lib/typescript/src/icons/index.d.ts +136 -189
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +4 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/primitives/portal.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/components/bottom-sheet/index.tsx +97 -17
- package/src/components/button/index.tsx +10 -2
- package/src/components/button-group/index.tsx +1 -0
- package/src/components/image-generation/dot-field.ts +226 -0
- package/src/components/image-generation/index.tsx +488 -0
- package/src/components/kpi/index.tsx +2 -4
- package/src/components/menu/index.tsx +10 -15
- package/src/components/panelside/index.tsx +498 -33
- package/src/components/select/index.tsx +158 -31
- package/src/components/slide-button/index.tsx +681 -0
- package/src/components/slide-button/slide-button-track.ts +100 -0
- package/src/icons/index.tsx +338 -1327
- package/src/index.ts +28 -0
- package/src/primitives/portal.tsx +15 -1
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ImageGeneration — the place an image will be, while it is being made.
|
|
3
|
+
*
|
|
4
|
+
* ```tsx
|
|
5
|
+
* <ImageGeneration status={status} prompt="a quiet mountain at sunset">
|
|
6
|
+
* <Image source={{ uri }} />
|
|
7
|
+
* </ImageGeneration>
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* A generated image arrives seconds after it is asked for, and arrives at a
|
|
11
|
+
* size nobody knew in advance. Left to itself that is a screen that reflows
|
|
12
|
+
* under the reader's thumb at the moment they were about to tap something —
|
|
13
|
+
* so this reserves the box first, at the aspect ratio the image will have, and
|
|
14
|
+
* fills it with something to watch.
|
|
15
|
+
*
|
|
16
|
+
* ## The dot field
|
|
17
|
+
*
|
|
18
|
+
* A grid of dots with a soft band of light travelling across it. Each column
|
|
19
|
+
* is one animated view holding its own dots, so a field of five hundred dots
|
|
20
|
+
* costs twenty-two worklets a frame rather than five hundred — the dots inside
|
|
21
|
+
* a column never move independently of it, and at this scale nobody can tell.
|
|
22
|
+
*
|
|
23
|
+
* The per-dot brightness underneath the band is fixed, and comes from the
|
|
24
|
+
* dot's own coordinates. Without it the field reads as clean vertical stripes
|
|
25
|
+
* sweeping past; with it, it reads as the individual dots lighting up.
|
|
26
|
+
*
|
|
27
|
+
* ## What arrives, and when
|
|
28
|
+
*
|
|
29
|
+
* `status` moves the picture through the work rather than switching it: the
|
|
30
|
+
* field fades as the image fades in, and for one step in the middle both are
|
|
31
|
+
* visible at once. That overlap is the point — an image that appears the
|
|
32
|
+
* instant the field vanishes has been swapped in, and one that surfaces
|
|
33
|
+
* through it has been developed.
|
|
34
|
+
*
|
|
35
|
+
* ## Reduced motion
|
|
36
|
+
*
|
|
37
|
+
* The band stops, and the field is drawn at one still frame of itself. A
|
|
38
|
+
* placeholder that shows nothing is indistinguishable from a component that
|
|
39
|
+
* failed to load, so this is a quieter picture rather than an empty one.
|
|
40
|
+
*/
|
|
41
|
+
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react';
|
|
42
|
+
import {
|
|
43
|
+
Pressable,
|
|
44
|
+
StyleSheet,
|
|
45
|
+
View,
|
|
46
|
+
type LayoutChangeEvent,
|
|
47
|
+
type ViewProps,
|
|
48
|
+
} from 'react-native';
|
|
49
|
+
import Svg, { Path } from 'react-native-svg';
|
|
50
|
+
import Animated, {
|
|
51
|
+
Easing,
|
|
52
|
+
cancelAnimation,
|
|
53
|
+
useAnimatedProps,
|
|
54
|
+
useAnimatedStyle,
|
|
55
|
+
useFrameCallback,
|
|
56
|
+
useReducedMotion,
|
|
57
|
+
useSharedValue,
|
|
58
|
+
withRepeat,
|
|
59
|
+
withTiming,
|
|
60
|
+
type SharedValue,
|
|
61
|
+
} from 'react-native-reanimated';
|
|
62
|
+
import { tv } from 'tailwind-variants';
|
|
63
|
+
import { useCSSVariable } from 'uniwind';
|
|
64
|
+
import { useDirectionSign } from '../../hooks/use-direction';
|
|
65
|
+
import { AlertTriangleIcon, CheckIcon, RotateCcwIcon } from '../../icons';
|
|
66
|
+
import { Text } from '../../primitives/text';
|
|
67
|
+
import { cn } from '../../utils/cn';
|
|
68
|
+
import {
|
|
69
|
+
DOT_REST_OPACITY,
|
|
70
|
+
frameAt,
|
|
71
|
+
gridPath,
|
|
72
|
+
litFrames,
|
|
73
|
+
type DotFieldAnimation,
|
|
74
|
+
} from './dot-field';
|
|
75
|
+
|
|
76
|
+
/** How far the generation has got. */
|
|
77
|
+
export type ImageGenerationStatus =
|
|
78
|
+
| 'queued'
|
|
79
|
+
| 'generating'
|
|
80
|
+
| 'refining'
|
|
81
|
+
| 'complete'
|
|
82
|
+
| 'error';
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The moment a held field is frozen at.
|
|
86
|
+
*
|
|
87
|
+
* Not zero: at zero the light sits dead centre, which reads as a target rather
|
|
88
|
+
* than as something passing through.
|
|
89
|
+
*/
|
|
90
|
+
const STILL_FRAME = 900;
|
|
91
|
+
|
|
92
|
+
/** How visible the field is at each step of the work. */
|
|
93
|
+
const FIELD_OPACITY: Record<ImageGenerationStatus, number> = {
|
|
94
|
+
queued: 1,
|
|
95
|
+
generating: 1,
|
|
96
|
+
refining: 0.48,
|
|
97
|
+
complete: 0,
|
|
98
|
+
error: 0,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* How visible the image is at each step.
|
|
103
|
+
*
|
|
104
|
+
* `refining` is the overlap: the field is still there at half strength and the
|
|
105
|
+
* image is already coming through it. Both fully on for one step is what makes
|
|
106
|
+
* the picture surface rather than get swapped in.
|
|
107
|
+
*/
|
|
108
|
+
const MEDIA_OPACITY: Record<ImageGenerationStatus, number> = {
|
|
109
|
+
queued: 0,
|
|
110
|
+
generating: 0,
|
|
111
|
+
refining: 0.62,
|
|
112
|
+
complete: 1,
|
|
113
|
+
error: 0.28,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* How much larger than final the image starts.
|
|
118
|
+
*
|
|
119
|
+
* Small, and it settles rather than zooms. A generated image is not arriving
|
|
120
|
+
* from anywhere; the scale is there to give the last of the fade somewhere to
|
|
121
|
+
* go, so it does not simply stop.
|
|
122
|
+
*/
|
|
123
|
+
const MEDIA_SCALE: Record<ImageGenerationStatus, number> = {
|
|
124
|
+
queued: 1.02,
|
|
125
|
+
generating: 1.02,
|
|
126
|
+
refining: 1.008,
|
|
127
|
+
complete: 1,
|
|
128
|
+
error: 1,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const STATUS_TEXT: Record<ImageGenerationStatus, string> = {
|
|
132
|
+
queued: 'Waiting to generate',
|
|
133
|
+
generating: 'Creating image',
|
|
134
|
+
refining: 'Refining details',
|
|
135
|
+
complete: 'Image ready',
|
|
136
|
+
error: 'Generation failed',
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const imageGenerationVariants = tv({
|
|
140
|
+
slots: {
|
|
141
|
+
root: 'w-full',
|
|
142
|
+
frame: 'relative w-full overflow-hidden rounded-2xl bg-muted',
|
|
143
|
+
media: 'absolute inset-0',
|
|
144
|
+
resolution:
|
|
145
|
+
'absolute end-2 top-2 rounded-full bg-background/75 px-2 py-0.5 text-[10px] text-muted-foreground',
|
|
146
|
+
status: 'mt-3 flex-row items-center gap-2',
|
|
147
|
+
statusText: 'text-sm font-medium text-foreground',
|
|
148
|
+
prompt: 'mt-0.5 text-xs text-muted-foreground',
|
|
149
|
+
retry: 'mt-3 min-h-12 flex-row items-center gap-2 self-start rounded-full px-3 active:bg-accent',
|
|
150
|
+
retryLabel: 'text-sm font-medium text-foreground',
|
|
151
|
+
},
|
|
152
|
+
variants: {
|
|
153
|
+
size: {
|
|
154
|
+
/** Held to a thumbnail, centred — a result inside a conversation. */
|
|
155
|
+
compact: { root: 'max-w-52 self-center' },
|
|
156
|
+
/** As wide as it is given. */
|
|
157
|
+
fluid: {},
|
|
158
|
+
},
|
|
159
|
+
error: {
|
|
160
|
+
true: { statusText: 'text-destructive' },
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
defaultVariants: {
|
|
164
|
+
size: 'compact',
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const AnimatedPath = Animated.createAnimatedComponent(Path);
|
|
169
|
+
|
|
170
|
+
export interface ImageGenerationFieldProps extends Omit<ViewProps, 'children'> {
|
|
171
|
+
className?: string;
|
|
172
|
+
/** Holds the light still, at one representative frame. */
|
|
173
|
+
paused?: boolean;
|
|
174
|
+
/**
|
|
175
|
+
* How the light moves through the field.
|
|
176
|
+
*
|
|
177
|
+
* `drift` wanders around the middle; `pulse` is a ring leaving the centre;
|
|
178
|
+
* `scan` crosses as a band. All three cost the same.
|
|
179
|
+
*/
|
|
180
|
+
animation?: DotFieldAnimation;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The dot field on its own, for a placeholder that is not an image.
|
|
185
|
+
*
|
|
186
|
+
* It fills its parent absolutely rather than sizing to its contents, because
|
|
187
|
+
* what it draws has no intrinsic height — laid out normally it measures to
|
|
188
|
+
* nothing and draws a single row of dots along the top.
|
|
189
|
+
*
|
|
190
|
+
* ## What it costs
|
|
191
|
+
*
|
|
192
|
+
* Two paths: the resting grid, and the lit dots over it. Both are built when
|
|
193
|
+
* the box is measured — every frame of the loop at once — so running it is
|
|
194
|
+
* handing over a string that already exists, and a page of these costs the
|
|
195
|
+
* same as a page of static drawings plus one property assignment each.
|
|
196
|
+
*/
|
|
197
|
+
function ImageGenerationField({
|
|
198
|
+
className,
|
|
199
|
+
paused = false,
|
|
200
|
+
animation = 'drift',
|
|
201
|
+
style,
|
|
202
|
+
...props
|
|
203
|
+
}: ImageGenerationFieldProps) {
|
|
204
|
+
const reducedMotion = useReducedMotion();
|
|
205
|
+
const [size, setSize] = useState({ width: 0, height: 0 });
|
|
206
|
+
const clock = useSharedValue(STILL_FRAME);
|
|
207
|
+
|
|
208
|
+
const tint = useCSSVariable('--color-muted-foreground');
|
|
209
|
+
const color = typeof tint === 'string' ? tint : '#737373';
|
|
210
|
+
|
|
211
|
+
const running = !paused && !reducedMotion;
|
|
212
|
+
|
|
213
|
+
const grid = useMemo(
|
|
214
|
+
() => gridPath(size.width, size.height),
|
|
215
|
+
[size.width, size.height]
|
|
216
|
+
);
|
|
217
|
+
const frames = useMemo(
|
|
218
|
+
() => litFrames(size.width, size.height, animation),
|
|
219
|
+
[animation, size.width, size.height]
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
const frame = useFrameCallback((info) => {
|
|
223
|
+
'worklet';
|
|
224
|
+
// Accumulated rather than read off the total, so pausing and resuming does
|
|
225
|
+
// not jump the light to wherever the clock would have carried it. A dropped
|
|
226
|
+
// frame is clamped: a 300ms hitch played back whole is a lurch.
|
|
227
|
+
clock.value += Math.min(info.timeSincePreviousFrame ?? 16, 48);
|
|
228
|
+
}, false);
|
|
229
|
+
|
|
230
|
+
const { setActive } = frame;
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
setActive(running);
|
|
233
|
+
return () => setActive(false);
|
|
234
|
+
}, [running, setActive]);
|
|
235
|
+
|
|
236
|
+
/*
|
|
237
|
+
* A still field is not an empty one.
|
|
238
|
+
*
|
|
239
|
+
* Held and reduced-motion fields keep whichever frame the clock is on, and
|
|
240
|
+
* the clock starts partway through the loop rather than at zero — so the
|
|
241
|
+
* light is somewhere legible rather than dead centre or absent, which is the
|
|
242
|
+
* difference between "not animating" and "failed to load".
|
|
243
|
+
*/
|
|
244
|
+
const animatedProps = useAnimatedProps(() => ({
|
|
245
|
+
d: frames[frameAt(clock.value, animation)] ?? '',
|
|
246
|
+
}));
|
|
247
|
+
|
|
248
|
+
const onLayout = (event: LayoutChangeEvent) => {
|
|
249
|
+
const { width, height } = event.nativeEvent.layout;
|
|
250
|
+
setSize((was) => (was.width === width && was.height === height ? was : { width, height }));
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
return (
|
|
254
|
+
<View
|
|
255
|
+
accessibilityElementsHidden
|
|
256
|
+
importantForAccessibility="no-hide-descendants"
|
|
257
|
+
pointerEvents="none"
|
|
258
|
+
onLayout={onLayout}
|
|
259
|
+
className={className}
|
|
260
|
+
style={[StyleSheet.absoluteFill, style]}
|
|
261
|
+
{...props}
|
|
262
|
+
>
|
|
263
|
+
{grid ? (
|
|
264
|
+
<Svg width={size.width} height={size.height}>
|
|
265
|
+
{/* The field at rest, under everything and never touched again. */}
|
|
266
|
+
<Path d={grid} fill={color} fillOpacity={DOT_REST_OPACITY} />
|
|
267
|
+
{/* The dots the light has reached, drawn larger over their own
|
|
268
|
+
resting copies rather than instead of them — so a dot brightens
|
|
269
|
+
in place instead of appearing to move. */}
|
|
270
|
+
<AnimatedPath animatedProps={animatedProps} fill={color} fillOpacity={0.9} />
|
|
271
|
+
</Svg>
|
|
272
|
+
) : null}
|
|
273
|
+
</View>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export interface ImageGenerationProps extends Omit<ViewProps, 'children'> {
|
|
278
|
+
className?: string;
|
|
279
|
+
/** How far the generation has got. Defaults to `generating`. */
|
|
280
|
+
status?: ImageGenerationStatus;
|
|
281
|
+
/**
|
|
282
|
+
* What the box is, for a screen reader. Defaults to the status text, with
|
|
283
|
+
* the prompt after it where there is one.
|
|
284
|
+
*/
|
|
285
|
+
label?: string;
|
|
286
|
+
/** The instruction the image was made from. Shown under the status. */
|
|
287
|
+
prompt?: string;
|
|
288
|
+
/** Shown in the corner of the frame. Pass an empty string to drop it. */
|
|
289
|
+
resolution?: string;
|
|
290
|
+
/**
|
|
291
|
+
* The box's shape, as width over height. Defaults to `1` — square, which is
|
|
292
|
+
* what most models return, and what the frame must be before there is an
|
|
293
|
+
* image to measure.
|
|
294
|
+
*/
|
|
295
|
+
aspectRatio?: number;
|
|
296
|
+
/** `compact` caps the width at a thumbnail and centres it; `fluid` fills. */
|
|
297
|
+
size?: 'compact' | 'fluid';
|
|
298
|
+
/**
|
|
299
|
+
* How the light behind the dots moves while there is work outstanding.
|
|
300
|
+
* `drift` wanders, `pulse` leaves the centre as a ring, `scan` crosses as a
|
|
301
|
+
* band. All three cost the same.
|
|
302
|
+
*/
|
|
303
|
+
animation?: DotFieldAnimation;
|
|
304
|
+
/** Replaces the sentence under the frame. */
|
|
305
|
+
statusText?: string;
|
|
306
|
+
/** Hides the status line, leaving the frame and the prompt. */
|
|
307
|
+
showStatus?: boolean;
|
|
308
|
+
/** Shown as a button under an `error`. Without it there is no button. */
|
|
309
|
+
onRetry?: () => void;
|
|
310
|
+
/** Extra classes for the frame — its radius, ground and aspect. */
|
|
311
|
+
frameClassName?: string;
|
|
312
|
+
/** Extra classes for the layer the image sits in. */
|
|
313
|
+
mediaClassName?: string;
|
|
314
|
+
/** Extra classes for the status line. */
|
|
315
|
+
statusClassName?: string;
|
|
316
|
+
/** The finished image. Anything that fills its parent — an `Image`, a video. */
|
|
317
|
+
children?: ReactNode;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** The mark beside the status: a turning square while it works, then an answer. */
|
|
321
|
+
function StatusMark({ status }: { status: ImageGenerationStatus }) {
|
|
322
|
+
const reducedMotion = useReducedMotion();
|
|
323
|
+
const spin = useSharedValue(0);
|
|
324
|
+
const working = status !== 'complete' && status !== 'error';
|
|
325
|
+
|
|
326
|
+
useEffect(() => {
|
|
327
|
+
if (!working || reducedMotion) {
|
|
328
|
+
cancelAnimation(spin);
|
|
329
|
+
spin.value = 0;
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
spin.value = 0;
|
|
333
|
+
spin.value = withRepeat(
|
|
334
|
+
withTiming(1, { duration: 2400, easing: Easing.inOut(Easing.quad) }),
|
|
335
|
+
-1
|
|
336
|
+
);
|
|
337
|
+
return () => cancelAnimation(spin);
|
|
338
|
+
}, [reducedMotion, spin, working]);
|
|
339
|
+
|
|
340
|
+
const style = useAnimatedStyle(() => ({
|
|
341
|
+
transform: [{ rotate: `${spin.value * 360}deg` }],
|
|
342
|
+
}));
|
|
343
|
+
|
|
344
|
+
const tint = useCSSVariable('--color-muted-foreground');
|
|
345
|
+
const color = typeof tint === 'string' ? tint : '#737373';
|
|
346
|
+
|
|
347
|
+
if (status === 'complete') return <CheckIcon size={14} />;
|
|
348
|
+
if (status === 'error') return <AlertTriangleIcon size={14} />;
|
|
349
|
+
|
|
350
|
+
// Four dots turning as a block: the same shape as the field it stands for,
|
|
351
|
+
// small enough to sit on a line of text.
|
|
352
|
+
return (
|
|
353
|
+
<Animated.View
|
|
354
|
+
accessibilityElementsHidden
|
|
355
|
+
importantForAccessibility="no-hide-descendants"
|
|
356
|
+
style={[{ width: 14, height: 14, flexDirection: 'row', flexWrap: 'wrap', gap: 2 }, style]}
|
|
357
|
+
>
|
|
358
|
+
{[1, 0.55, 0.55, 1].map((opacity, index) => (
|
|
359
|
+
<View
|
|
360
|
+
key={index}
|
|
361
|
+
style={{ width: 5, height: 5, borderRadius: 1, backgroundColor: color, opacity }}
|
|
362
|
+
/>
|
|
363
|
+
))}
|
|
364
|
+
</Animated.View>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function ImageGenerationRoot({
|
|
369
|
+
className,
|
|
370
|
+
status = 'generating',
|
|
371
|
+
label,
|
|
372
|
+
prompt,
|
|
373
|
+
resolution = '1024 × 1024',
|
|
374
|
+
aspectRatio = 1,
|
|
375
|
+
size = 'compact',
|
|
376
|
+
animation = 'drift',
|
|
377
|
+
statusText,
|
|
378
|
+
showStatus = true,
|
|
379
|
+
onRetry,
|
|
380
|
+
frameClassName,
|
|
381
|
+
mediaClassName,
|
|
382
|
+
statusClassName,
|
|
383
|
+
children,
|
|
384
|
+
...props
|
|
385
|
+
}: ImageGenerationProps) {
|
|
386
|
+
const reducedMotion = useReducedMotion();
|
|
387
|
+
const working = status === 'queued' || status === 'generating' || status === 'refining';
|
|
388
|
+
const slots = imageGenerationVariants({ size, error: status === 'error' });
|
|
389
|
+
const sentence = statusText ?? STATUS_TEXT[status];
|
|
390
|
+
|
|
391
|
+
const progress = useSharedValue(MEDIA_OPACITY[status]);
|
|
392
|
+
const fieldFade = useSharedValue(FIELD_OPACITY[status]);
|
|
393
|
+
const scale = useSharedValue(MEDIA_SCALE[status]);
|
|
394
|
+
|
|
395
|
+
useEffect(() => {
|
|
396
|
+
const duration = reducedMotion ? 0 : 400;
|
|
397
|
+
progress.value = withTiming(MEDIA_OPACITY[status], { duration });
|
|
398
|
+
fieldFade.value = withTiming(FIELD_OPACITY[status], { duration });
|
|
399
|
+
scale.value = withTiming(MEDIA_SCALE[status], { duration });
|
|
400
|
+
}, [fieldFade, progress, reducedMotion, scale, status]);
|
|
401
|
+
|
|
402
|
+
useEffect(
|
|
403
|
+
() => () => {
|
|
404
|
+
cancelAnimation(progress);
|
|
405
|
+
cancelAnimation(fieldFade);
|
|
406
|
+
cancelAnimation(scale);
|
|
407
|
+
},
|
|
408
|
+
[fieldFade, progress, scale]
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
const mediaStyle = useAnimatedStyle(() => ({
|
|
412
|
+
opacity: progress.value,
|
|
413
|
+
transform: [{ scale: scale.value }],
|
|
414
|
+
}));
|
|
415
|
+
const fieldStyle = useAnimatedStyle(() => ({ opacity: fieldFade.value }));
|
|
416
|
+
|
|
417
|
+
return (
|
|
418
|
+
<View {...props} className={slots.root({ className })}>
|
|
419
|
+
<View
|
|
420
|
+
accessible
|
|
421
|
+
accessibilityRole="image"
|
|
422
|
+
accessibilityLabel={label ?? (prompt ? `${sentence}: ${prompt}` : sentence)}
|
|
423
|
+
// `busy` is what tells a screen reader the box is not the answer yet.
|
|
424
|
+
accessibilityState={{ busy: working }}
|
|
425
|
+
style={{ aspectRatio }}
|
|
426
|
+
className={slots.frame({ className: frameClassName })}
|
|
427
|
+
>
|
|
428
|
+
<Animated.View
|
|
429
|
+
className={cn(slots.media(), mediaClassName)}
|
|
430
|
+
style={mediaStyle}
|
|
431
|
+
// Nothing to announce until there is something in it.
|
|
432
|
+
accessibilityElementsHidden={working}
|
|
433
|
+
importantForAccessibility={working ? 'no-hide-descendants' : 'auto'}
|
|
434
|
+
>
|
|
435
|
+
{children}
|
|
436
|
+
</Animated.View>
|
|
437
|
+
|
|
438
|
+
{/* Kept mounted at zero rather than unmounted, so the image is not
|
|
439
|
+
fading in over a box that is simultaneously being torn down. The
|
|
440
|
+
field stops drawing as soon as there is no work left, so an idle
|
|
441
|
+
one costs nothing but the views. */}
|
|
442
|
+
<Animated.View
|
|
443
|
+
style={[StyleSheet.absoluteFill, fieldStyle]}
|
|
444
|
+
pointerEvents="none"
|
|
445
|
+
>
|
|
446
|
+
<ImageGenerationField paused={!working} animation={animation} />
|
|
447
|
+
</Animated.View>
|
|
448
|
+
|
|
449
|
+
{resolution ? <Text className={slots.resolution()}>{resolution}</Text> : null}
|
|
450
|
+
</View>
|
|
451
|
+
|
|
452
|
+
{showStatus ? (
|
|
453
|
+
<View
|
|
454
|
+
accessibilityLiveRegion="polite"
|
|
455
|
+
className={slots.status({ className: statusClassName })}
|
|
456
|
+
>
|
|
457
|
+
<StatusMark status={status} />
|
|
458
|
+
<Text className={slots.statusText()}>{sentence}</Text>
|
|
459
|
+
</View>
|
|
460
|
+
) : null}
|
|
461
|
+
|
|
462
|
+
{prompt ? (
|
|
463
|
+
<Text numberOfLines={1} className={slots.prompt()}>
|
|
464
|
+
{prompt}
|
|
465
|
+
</Text>
|
|
466
|
+
) : null}
|
|
467
|
+
|
|
468
|
+
{status === 'error' && onRetry ? (
|
|
469
|
+
<Pressable
|
|
470
|
+
accessibilityRole="button"
|
|
471
|
+
accessibilityLabel="Try again"
|
|
472
|
+
onPress={onRetry}
|
|
473
|
+
className={slots.retry()}
|
|
474
|
+
>
|
|
475
|
+
<RotateCcwIcon size={16} />
|
|
476
|
+
<Text className={slots.retryLabel()}>Try again</Text>
|
|
477
|
+
</Pressable>
|
|
478
|
+
) : null}
|
|
479
|
+
</View>
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
ImageGenerationRoot.displayName = 'ImageGeneration';
|
|
484
|
+
ImageGenerationField.displayName = 'ImageGeneration.Field';
|
|
485
|
+
|
|
486
|
+
export const ImageGeneration = Object.assign(ImageGenerationRoot, {
|
|
487
|
+
Field: ImageGenerationField,
|
|
488
|
+
});
|
|
@@ -28,11 +28,9 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import { createContext, forwardRef, useContext, useMemo, type ReactNode } from 'react';
|
|
30
30
|
import { View, type ViewProps } from 'react-native';
|
|
31
|
-
import ArrowDown from 'lucide-react-native/icons/arrow-down';
|
|
32
|
-
import ArrowUp from 'lucide-react-native/icons/arrow-up';
|
|
33
|
-
import Minus from 'lucide-react-native/icons/minus';
|
|
34
31
|
import { tv, type VariantProps } from 'tailwind-variants';
|
|
35
32
|
import { useCSSVariable } from 'uniwind';
|
|
33
|
+
import { ArrowDownIcon, ArrowUpIcon, MinusIcon } from '../../icons';
|
|
36
34
|
import { Text } from '../../primitives/text';
|
|
37
35
|
import { cn } from '../../utils/cn';
|
|
38
36
|
import { type SeriesColorIndex } from '../../utils/chart';
|
|
@@ -442,7 +440,7 @@ function KpiTrend({
|
|
|
442
440
|
const raw = tone === 'good' ? goodTint : tone === 'bad' ? badTint : mutedTint;
|
|
443
441
|
const tint = typeof raw === 'string' ? raw : '#737373';
|
|
444
442
|
|
|
445
|
-
const Arrow = flat ?
|
|
443
|
+
const Arrow = flat ? MinusIcon : rising ? ArrowUpIcon : ArrowDownIcon;
|
|
446
444
|
|
|
447
445
|
const label = format
|
|
448
446
|
? format(value)
|
|
@@ -57,12 +57,9 @@ import Animated, {
|
|
|
57
57
|
} from 'react-native-reanimated';
|
|
58
58
|
import { tv } from 'tailwind-variants';
|
|
59
59
|
import { useCSSVariable } from 'uniwind';
|
|
60
|
-
import Check from 'lucide-react-native/icons/check';
|
|
61
|
-
import ChevronLeft from 'lucide-react-native/icons/chevron-left';
|
|
62
|
-
import ChevronRight from 'lucide-react-native/icons/chevron-right';
|
|
63
|
-
import Circle from 'lucide-react-native/icons/circle';
|
|
64
60
|
import { Text, textChildren } from '../../primitives/text';
|
|
65
|
-
import {
|
|
61
|
+
import { useDirectionSign } from '../../hooks/use-direction';
|
|
62
|
+
import { CheckIcon, ChevronRightIcon, CircleIcon } from '../../icons';
|
|
66
63
|
import { selectionTick } from '../../utils/haptics';
|
|
67
64
|
import { cn } from '../../utils/cn';
|
|
68
65
|
import { Popover, type PopoverContentProps, type PopoverProps } from '../popover';
|
|
@@ -570,7 +567,7 @@ export function MenuCheckboxItem({
|
|
|
570
567
|
// as a different row rather than the same one in a new state.
|
|
571
568
|
icon={
|
|
572
569
|
checked ? (
|
|
573
|
-
<
|
|
570
|
+
<CheckIcon
|
|
574
571
|
size={16}
|
|
575
572
|
strokeWidth={INDICATOR_STROKE}
|
|
576
573
|
color={tint ?? INDICATOR_FALLBACK}
|
|
@@ -654,14 +651,14 @@ export function MenuRadioItem({
|
|
|
654
651
|
// the tick beside it, and drawing one of the two as a rounded box
|
|
655
652
|
// is what makes them optically different sizes at the same nominal
|
|
656
653
|
// one.
|
|
657
|
-
<
|
|
654
|
+
<CircleIcon
|
|
658
655
|
size={8}
|
|
656
|
+
filled
|
|
659
657
|
strokeWidth={INDICATOR_STROKE}
|
|
660
658
|
color={tint ?? INDICATOR_FALLBACK}
|
|
661
|
-
fill={tint ?? INDICATOR_FALLBACK}
|
|
662
659
|
/>
|
|
663
660
|
) : (
|
|
664
|
-
<
|
|
661
|
+
<CheckIcon
|
|
665
662
|
size={16}
|
|
666
663
|
strokeWidth={INDICATOR_STROKE}
|
|
667
664
|
color={tint ?? INDICATOR_FALLBACK}
|
|
@@ -737,13 +734,8 @@ export interface MenuSubTriggerProps
|
|
|
737
734
|
export function MenuSubTrigger({ className, children, icon, onSelect, ...props }: MenuSubTriggerProps) {
|
|
738
735
|
const { open, toggle } = useMenuSub('Menu.SubTrigger');
|
|
739
736
|
const sign = useDirectionSign();
|
|
740
|
-
const direction = useDirection();
|
|
741
737
|
const reducedMotion = useReducedMotion();
|
|
742
738
|
const chevronTint = useTint('--color-muted-foreground');
|
|
743
|
-
// The glyph mirrors by being the other glyph. A `scaleX` flip would work on
|
|
744
|
-
// a chevron, but it also flips the rotation below with it — and this one is
|
|
745
|
-
// already being rotated.
|
|
746
|
-
const Chevron = direction === 'rtl' ? ChevronLeft : ChevronRight;
|
|
747
739
|
const progress = useSharedValue(open ? 1 : 0);
|
|
748
740
|
|
|
749
741
|
useEffect(() => {
|
|
@@ -769,7 +761,10 @@ export function MenuSubTrigger({ className, children, icon, onSelect, ...props }
|
|
|
769
761
|
className={className}
|
|
770
762
|
trailing={
|
|
771
763
|
<Animated.View style={chevronStyle}>
|
|
772
|
-
|
|
764
|
+
{/* The mirror and the rotation sit on different views, so they
|
|
765
|
+
compose: the glyph turns itself around under RTL, and the parent
|
|
766
|
+
turns the result through the same quarter the other way. */}
|
|
767
|
+
<ChevronRightIcon
|
|
773
768
|
size={16}
|
|
774
769
|
strokeWidth={INDICATOR_STROKE}
|
|
775
770
|
color={chevronTint ?? INDICATOR_FALLBACK}
|