panelui-native 0.103.2 → 0.105.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +2 -0
  2. package/lib/module/components/countdown/countdown-time.js +96 -0
  3. package/lib/module/components/countdown/countdown-time.js.map +1 -0
  4. package/lib/module/components/countdown/index.js +345 -0
  5. package/lib/module/components/countdown/index.js.map +1 -0
  6. package/lib/module/components/flow/index.js +64 -55
  7. package/lib/module/components/flow/index.js.map +1 -1
  8. package/lib/module/components/leaderboard/index.js +442 -0
  9. package/lib/module/components/leaderboard/index.js.map +1 -0
  10. package/lib/module/components/leaderboard/leaderboard-rank.js +65 -0
  11. package/lib/module/components/leaderboard/leaderboard-rank.js.map +1 -0
  12. package/lib/module/components/map/index.js +114 -50
  13. package/lib/module/components/map/index.js.map +1 -1
  14. package/lib/module/index.js +2 -0
  15. package/lib/module/index.js.map +1 -1
  16. package/lib/typescript/src/components/countdown/countdown-time.d.ts +53 -0
  17. package/lib/typescript/src/components/countdown/countdown-time.d.ts.map +1 -0
  18. package/lib/typescript/src/components/countdown/index.d.ts +170 -0
  19. package/lib/typescript/src/components/countdown/index.d.ts.map +1 -0
  20. package/lib/typescript/src/components/flow/index.d.ts.map +1 -1
  21. package/lib/typescript/src/components/leaderboard/index.d.ts +63 -0
  22. package/lib/typescript/src/components/leaderboard/index.d.ts.map +1 -0
  23. package/lib/typescript/src/components/leaderboard/leaderboard-rank.d.ts +41 -0
  24. package/lib/typescript/src/components/leaderboard/leaderboard-rank.d.ts.map +1 -0
  25. package/lib/typescript/src/components/map/index.d.ts +30 -5
  26. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  27. package/lib/typescript/src/index.d.ts +3 -1
  28. package/lib/typescript/src/index.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/src/components/countdown/countdown-time.ts +105 -0
  31. package/src/components/countdown/index.tsx +335 -0
  32. package/src/components/flow/index.tsx +65 -51
  33. package/src/components/leaderboard/index.tsx +535 -0
  34. package/src/components/leaderboard/leaderboard-rank.ts +84 -0
  35. package/src/components/map/index.tsx +130 -44
  36. package/src/index.ts +15 -0
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Time arithmetic for `Countdown`, kept apart from the views so it can be
3
+ * tested without a clock.
4
+ *
5
+ * Whole seconds are rounded **up**. A countdown that floors shows 0:00 for the
6
+ * last second before it ends, so it reads as finished while it is not — and
7
+ * an "ends at 12:00" sale still taking orders under a zero is the bug people
8
+ * report. Rounding up shows 0:01 until the very end and reaches 0:00 at the
9
+ * moment the target passes.
10
+ */
11
+
12
+ export type CountdownUnit = 'days' | 'hours' | 'minutes' | 'seconds';
13
+
14
+ export const COUNTDOWN_UNITS: readonly CountdownUnit[] = ['days', 'hours', 'minutes', 'seconds'];
15
+
16
+ const SECONDS: Record<CountdownUnit, number> = {
17
+ days: 86400,
18
+ hours: 3600,
19
+ minutes: 60,
20
+ seconds: 1,
21
+ };
22
+
23
+ export interface CountdownPart {
24
+ unit: CountdownUnit;
25
+ value: number;
26
+ }
27
+
28
+ /** Whole seconds left until `target`, rounded up, never below zero. */
29
+ export function secondsLeft(target: number, now: number): number {
30
+ if (!Number.isFinite(target) || !Number.isFinite(now)) return 0;
31
+ return Math.max(0, Math.ceil((target - now) / 1000));
32
+ }
33
+
34
+ /**
35
+ * Milliseconds until the displayed second next changes.
36
+ *
37
+ * The tick is scheduled for that moment rather than every 1000ms from mount,
38
+ * so the display changes when the second actually turns over instead of
39
+ * drifting by however late the first timer fired.
40
+ */
41
+ export function msUntilNextTick(target: number, now: number): number {
42
+ const remaining = target - now;
43
+ if (remaining <= 0) return 0;
44
+ const into = remaining % 1000;
45
+ return into === 0 ? 1000 : into;
46
+ }
47
+
48
+ /**
49
+ * Splits a number of seconds across the given units, largest first.
50
+ *
51
+ * The largest unit absorbs everything above it, so with `['hours',
52
+ * 'minutes', 'seconds']` two days reads as 48 hours rather than losing the
53
+ * days. Units are always returned in largest-to-smallest order whatever order
54
+ * they were passed in.
55
+ */
56
+ export function splitSeconds(
57
+ total: number,
58
+ units: readonly CountdownUnit[] = COUNTDOWN_UNITS
59
+ ): CountdownPart[] {
60
+ const wanted = COUNTDOWN_UNITS.filter((unit) => units.includes(unit));
61
+ if (wanted.length === 0) return [];
62
+ let rest = Math.max(0, Math.floor(total));
63
+ return wanted.map((unit) => {
64
+ const value = Math.floor(rest / SECONDS[unit]);
65
+ rest -= value * SECONDS[unit];
66
+ return { unit, value };
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Drops leading units that are zero, keeping at least `keep` of them.
72
+ *
73
+ * A launch three hours away has no use for a "00 days" box, but a countdown
74
+ * should not shrink to a lone seconds field in its last minute either, so the
75
+ * smallest `keep` units always stay.
76
+ */
77
+ export function trimLeading(parts: CountdownPart[], keep = 2): CountdownPart[] {
78
+ let start = 0;
79
+ while (start < parts.length - keep && parts[start]!.value === 0) start += 1;
80
+ return parts.slice(start);
81
+ }
82
+
83
+ const NAMES: Record<CountdownUnit, [string, string]> = {
84
+ days: ['day', 'days'],
85
+ hours: ['hour', 'hours'],
86
+ minutes: ['minute', 'minutes'],
87
+ seconds: ['second', 'seconds'],
88
+ };
89
+
90
+ /**
91
+ * What a screen reader hears. Coarser than what is on screen.
92
+ *
93
+ * The label changes only when the minute does, until the last minute, when it
94
+ * counts seconds. A label that changed every second would be re-read every
95
+ * second by anything polling it, and a reader does not need the seconds of a
96
+ * launch two days away.
97
+ */
98
+ export function describeSeconds(total: number): string {
99
+ if (total <= 0) return 'Time is up';
100
+ if (total < 60) return `${total} ${NAMES.seconds[total === 1 ? 0 : 1]} left`;
101
+ const parts = splitSeconds(total, ['days', 'hours', 'minutes'])
102
+ .filter((part) => part.value > 0)
103
+ .map((part) => `${part.value} ${NAMES[part.unit][part.value === 1 ? 0 : 1]}`);
104
+ return `${parts.join(', ')} left`;
105
+ }
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Countdown — the time left until a moment, ticking.
3
+ *
4
+ * ```tsx
5
+ * <Countdown to={launch} />
6
+ * <Countdown to={saleEnds} variant="inline" urgentBelow={300} />
7
+ * ```
8
+ *
9
+ * The clock is read, not counted. Every tick works out what is left from
10
+ * `Date.now()` and the target, and the next tick is scheduled for the moment
11
+ * the displayed second turns over. A countdown that subtracted one per
12
+ * interval would drift by however late each timer fired, and would be wrong
13
+ * by the whole time the app spent in the background.
14
+ *
15
+ * Each digit is its own keyed view: when it changes, the old one slides out
16
+ * downward and the new one drops in from above, on the UI thread. Only the
17
+ * digits that changed move, so a ticking clock is one column moving, not the
18
+ * whole number re-rendering. Nothing moves on the first render, and nothing
19
+ * moves at all under reduce motion.
20
+ *
21
+ * The screen-reader label is coarser than the display — minutes until the
22
+ * last minute, then seconds — because a label that changed every second
23
+ * would be re-read every second. See `countdown-time.ts`.
24
+ */
25
+ import { forwardRef, useEffect, useRef, useState } from 'react';
26
+ import { View, type ViewProps } from 'react-native';
27
+ import Animated, {
28
+ Easing,
29
+ useReducedMotion,
30
+ withTiming,
31
+ type EntryAnimationsValues,
32
+ type ExitAnimationsValues,
33
+ } from 'react-native-reanimated';
34
+ import { tv, type VariantProps } from 'tailwind-variants';
35
+ import { Text } from '../../primitives/text';
36
+ import { cn } from '../../utils/cn';
37
+ import {
38
+ describeSeconds,
39
+ msUntilNextTick,
40
+ secondsLeft,
41
+ splitSeconds,
42
+ trimLeading,
43
+ type CountdownPart,
44
+ type CountdownUnit,
45
+ } from './countdown-time';
46
+
47
+ export type { CountdownUnit } from './countdown-time';
48
+
49
+ /** How long a digit takes to change. Well inside the second it is shown for. */
50
+ const DIGIT_MS = 260;
51
+ const EASE = Easing.out(Easing.cubic);
52
+
53
+ /*
54
+ * The new digit drops into its slot from one digit-height above while the old
55
+ * one drops out by one digit-height below, so the two travel together like an
56
+ * odometer wheel and are never on top of each other. A fixed distance would
57
+ * overlap them at large sizes and overshoot the window at small ones, so the
58
+ * distance is the measured height of the digit itself.
59
+ */
60
+ function enter(values: EntryAnimationsValues) {
61
+ 'worklet';
62
+ return {
63
+ initialValues: { originY: values.targetOriginY - values.targetHeight, opacity: 0 },
64
+ animations: {
65
+ originY: withTiming(values.targetOriginY, { duration: DIGIT_MS, easing: EASE }),
66
+ opacity: withTiming(1, { duration: DIGIT_MS, easing: EASE }),
67
+ },
68
+ };
69
+ }
70
+
71
+ function exit(values: ExitAnimationsValues) {
72
+ 'worklet';
73
+ return {
74
+ initialValues: { originY: values.currentOriginY, opacity: 1 },
75
+ animations: {
76
+ originY: withTiming(values.currentOriginY + values.currentHeight, {
77
+ duration: DIGIT_MS,
78
+ easing: EASE,
79
+ }),
80
+ opacity: withTiming(0, { duration: DIGIT_MS, easing: EASE }),
81
+ },
82
+ };
83
+ }
84
+
85
+ const countdownVariants = tv({
86
+ slots: {
87
+ root: 'flex-row items-start',
88
+ segment: 'items-center',
89
+ digits: 'flex-row',
90
+ digit: 'font-semibold tabular-nums text-foreground',
91
+ label: 'font-medium uppercase tracking-wider text-muted-foreground',
92
+ separator: 'font-semibold tabular-nums text-muted-foreground',
93
+ suffix: 'font-semibold text-muted-foreground',
94
+ },
95
+ variants: {
96
+ /**
97
+ * `segmented` puts each unit in its own box with its name under it — a
98
+ * launch page, a hero, anything the countdown is the point of.
99
+ * `inline` is a line of text, `2d 14:03:22`, for a banner, a button
100
+ * caption or a row.
101
+ */
102
+ variant: {
103
+ segmented: {
104
+ root: 'gap-2',
105
+ segment: 'gap-1.5 rounded-xl border border-border bg-card shadow-sm',
106
+ },
107
+ inline: {
108
+ root: 'items-baseline',
109
+ segment: 'flex-row items-baseline',
110
+ },
111
+ },
112
+ size: {
113
+ sm: { digit: 'text-lg', label: 'text-[10px]', separator: 'text-lg', suffix: 'text-sm' },
114
+ md: { digit: 'text-3xl', label: 'text-[10px]', separator: 'text-3xl', suffix: 'text-xl' },
115
+ lg: { digit: 'text-4xl', label: 'text-[11px]', separator: 'text-4xl', suffix: 'text-2xl' },
116
+ },
117
+ },
118
+ compoundVariants: [
119
+ { variant: 'segmented', size: 'sm', class: { segment: 'min-w-12 px-2 py-1.5' } },
120
+ { variant: 'segmented', size: 'md', class: { segment: 'min-w-16 px-3 py-2.5' } },
121
+ { variant: 'segmented', size: 'lg', class: { segment: 'min-w-16 px-3 py-3' } },
122
+ ],
123
+ defaultVariants: { variant: 'segmented', size: 'md' },
124
+ });
125
+
126
+ type CountdownVariantProps = VariantProps<typeof countdownVariants>;
127
+
128
+ /**
129
+ * The urgent colour. A class added on top rather than a `tv()` variant: it is
130
+ * derived from `urgentBelow` and the clock, not passed, and a variant here
131
+ * would be documented as a prop.
132
+ */
133
+ const URGENT = { digit: 'text-destructive', separator: 'text-destructive/60' };
134
+
135
+ const DEFAULT_LABELS: Record<CountdownUnit, string> = {
136
+ days: 'Days',
137
+ hours: 'Hours',
138
+ minutes: 'Minutes',
139
+ seconds: 'Seconds',
140
+ };
141
+
142
+ const SUFFIX: Record<CountdownUnit, string> = { days: 'd', hours: 'h', minutes: 'm', seconds: 's' };
143
+
144
+ export interface CountdownProps extends Omit<ViewProps, 'children'>, CountdownVariantProps {
145
+ className?: string;
146
+ /** The moment to count down to, as a `Date` or epoch milliseconds. */
147
+ to: Date | number;
148
+ /**
149
+ * Which units to show, largest to smallest. The largest one absorbs
150
+ * everything above it — `['hours', 'minutes', 'seconds']` shows two days as
151
+ * 48 hours. Defaults to all four.
152
+ */
153
+ units?: CountdownUnit[];
154
+ /**
155
+ * Drop leading units while they are zero, so a launch three hours away has
156
+ * no "00 days" box. The smallest two always stay.
157
+ */
158
+ trim?: boolean;
159
+ /** Names under each box in `segmented`. Pass any subset to translate them. */
160
+ labels?: Partial<Record<CountdownUnit, string>>;
161
+ /**
162
+ * Turn the digits to the destructive colour once this many seconds or fewer
163
+ * are left — the last five minutes of a sale, the last ten seconds of a bid.
164
+ */
165
+ urgentBelow?: number;
166
+ /** Called once when the time is up, including on mount if it already is. */
167
+ onComplete?: () => void;
168
+ /** Called with the whole seconds left each time the display changes. */
169
+ onTick?: (secondsLeft: number) => void;
170
+ }
171
+
172
+ /** One digit in a window its own height, swapped with a slide when it changes. */
173
+ function Digit({
174
+ value,
175
+ animate,
176
+ className,
177
+ }: {
178
+ value: string;
179
+ animate: boolean;
180
+ className: string;
181
+ }) {
182
+ return (
183
+ <View className="overflow-hidden">
184
+ {/* In flow and invisible: it gives the window a digit's width and height. */}
185
+ <Text className={cn(className, 'opacity-0')}>0</Text>
186
+ <Animated.View
187
+ key={value}
188
+ entering={animate ? enter : undefined}
189
+ exiting={animate ? exit : undefined}
190
+ style={{ position: 'absolute', top: 0, left: 0, right: 0 }}
191
+ >
192
+ <Text className={cn(className, 'text-center')}>{value}</Text>
193
+ </Animated.View>
194
+ </View>
195
+ );
196
+ }
197
+
198
+ function Digits({
199
+ part,
200
+ animate,
201
+ className,
202
+ }: {
203
+ part: CountdownPart;
204
+ animate: boolean;
205
+ className: string;
206
+ }) {
207
+ const slots = countdownVariants();
208
+ const text = String(part.value).padStart(2, '0');
209
+ return (
210
+ <View className={slots.digits()}>
211
+ {text.split('').map((char, index) => (
212
+ // Keyed by place from the right, so a days count going from 10 to 9
213
+ // keeps the ones column it already had.
214
+ <Digit
215
+ key={text.length - index}
216
+ value={char}
217
+ animate={animate}
218
+ className={className}
219
+ />
220
+ ))}
221
+ </View>
222
+ );
223
+ }
224
+
225
+ const CountdownRoot = forwardRef<View, CountdownProps>(function Countdown(
226
+ {
227
+ className,
228
+ to,
229
+ variant = 'segmented',
230
+ size = 'md',
231
+ units,
232
+ trim = true,
233
+ labels,
234
+ urgentBelow,
235
+ onComplete,
236
+ onTick,
237
+ ...props
238
+ },
239
+ ref
240
+ ) {
241
+ const target = typeof to === 'number' ? to : to.getTime();
242
+ const [left, setLeft] = useState(() => secondsLeft(target, Date.now()));
243
+
244
+ // Callbacks through refs, so a new arrow function from the parent on every
245
+ // render does not restart the clock.
246
+ const completeRef = useRef(onComplete);
247
+ const tickRef = useRef(onTick);
248
+ completeRef.current = onComplete;
249
+ tickRef.current = onTick;
250
+
251
+ // No animation on the first render: the numbers are arriving, not changing.
252
+ const mounted = useRef(false);
253
+ useEffect(() => {
254
+ mounted.current = true;
255
+ }, []);
256
+
257
+ useEffect(() => {
258
+ let timer: ReturnType<typeof setTimeout> | undefined;
259
+ let done = false;
260
+ const tick = () => {
261
+ const now = Date.now();
262
+ const next = secondsLeft(target, now);
263
+ setLeft(next);
264
+ tickRef.current?.(next);
265
+ if (next === 0) {
266
+ if (!done) {
267
+ done = true;
268
+ completeRef.current?.();
269
+ }
270
+ return;
271
+ }
272
+ // A few milliseconds past the turn, so the read lands in the new second.
273
+ timer = setTimeout(tick, msUntilNextTick(target, now) + 8);
274
+ };
275
+ tick();
276
+ return () => clearTimeout(timer);
277
+ }, [target]);
278
+
279
+ const urgent = urgentBelow != null && left > 0 && left <= urgentBelow;
280
+ const variants = countdownVariants({ variant, size });
281
+ const slots = {
282
+ ...variants,
283
+ digit: () => cn(variants.digit(), urgent && URGENT.digit),
284
+ separator: () => cn(variants.separator(), urgent && URGENT.separator),
285
+ };
286
+ const split = splitSeconds(left, units);
287
+ const parts = trim ? trimLeading(split) : split;
288
+ // Custom layout animations do not read the system setting on their own.
289
+ const reduced = useReducedMotion();
290
+ const animate = mounted.current && !reduced;
291
+ const names = { ...DEFAULT_LABELS, ...labels };
292
+
293
+ return (
294
+ <View
295
+ ref={ref}
296
+ accessible
297
+ accessibilityRole="timer"
298
+ accessibilityLabel={describeSeconds(left)}
299
+ className={slots.root({ className })}
300
+ {...props}
301
+ >
302
+ {parts.map((part, index) => {
303
+ if (variant === 'inline') {
304
+ // Days read as "2d" ahead of a clock; the rest as hh:mm:ss.
305
+ const isDays = part.unit === 'days';
306
+ const nextIsClock = parts[index + 1] && parts[index + 1]!.unit !== 'days';
307
+ return (
308
+ <View key={part.unit} className={slots.segment()}>
309
+ {isDays ? (
310
+ <>
311
+ <Text className={slots.digit()}>{part.value}</Text>
312
+ <Text className={slots.suffix()}>{SUFFIX.days} </Text>
313
+ </>
314
+ ) : (
315
+ <>
316
+ <Digits part={part} animate={animate} className={slots.digit()} />
317
+ {nextIsClock ? <Text className={slots.separator()}>:</Text> : null}
318
+ </>
319
+ )}
320
+ </View>
321
+ );
322
+ }
323
+
324
+ return (
325
+ <View key={part.unit} className={slots.segment()}>
326
+ <Digits part={part} animate={animate} className={slots.digit()} />
327
+ <Text className={slots.label()}>{names[part.unit]}</Text>
328
+ </View>
329
+ );
330
+ })}
331
+ </View>
332
+ );
333
+ });
334
+
335
+ export const Countdown = CountdownRoot;
@@ -89,7 +89,14 @@ import Svg, {
89
89
  Rect,
90
90
  } from 'react-native-svg';
91
91
  import { tv } from 'tailwind-variants';
92
- import { LockIcon, MaximizeIcon, MinusIcon, PlusIcon, UnlockIcon } from '../../icons';
92
+ import {
93
+ IconColorProvider,
94
+ LockIcon,
95
+ MaximizeIcon,
96
+ MinusIcon,
97
+ PlusIcon,
98
+ UnlockIcon,
99
+ } from '../../icons';
93
100
  import { AnimatedPressable } from '../../primitives/animated-pressable';
94
101
  import { Text, textChildren } from '../../primitives/text';
95
102
  import { cn } from '../../utils/cn';
@@ -2027,58 +2034,65 @@ function FlowControls({
2027
2034
  }: FlowControlsProps) {
2028
2035
  const { zoomBy, fitView, locked, setLocked } = useFlow('Flow.Controls');
2029
2036
  const slots = flowVariants();
2037
+ // One colour for every glyph in the stack. Without it each icon falls back
2038
+ // to its own default, and the minus sign's is white — drawn for a checked
2039
+ // checkbox, and invisible on the card.
2040
+ const token = useCSSVariable('--color-muted-foreground');
2041
+ const tint = typeof token === 'string' ? token : '#737373';
2030
2042
 
2031
2043
  return (
2032
- <View className={slots.controls({ className })}>
2033
- {showZoom ? (
2034
- <>
2035
- <AnimatedPressable
2036
- accessibilityRole="button"
2037
- accessibilityLabel="Zoom in"
2038
- className={slots.control()}
2039
- onPress={() => zoomBy(step)}
2040
- >
2041
- <PlusIcon size={16} />
2042
- </AnimatedPressable>
2043
- <View className="h-px bg-border" />
2044
- <AnimatedPressable
2045
- accessibilityRole="button"
2046
- accessibilityLabel="Zoom out"
2047
- className={slots.control()}
2048
- onPress={() => zoomBy(1 / step)}
2049
- >
2050
- <MinusIcon size={16} />
2051
- </AnimatedPressable>
2052
- </>
2053
- ) : null}
2054
- {fit ? (
2055
- <>
2056
- <View className="h-px bg-border" />
2057
- <AnimatedPressable
2058
- accessibilityRole="button"
2059
- accessibilityLabel="Fit the whole graph on screen"
2060
- className={slots.control()}
2061
- onPress={fitView}
2062
- >
2063
- <MaximizeIcon size={16} />
2064
- </AnimatedPressable>
2065
- </>
2066
- ) : null}
2067
- {lock ? (
2068
- <>
2069
- <View className="h-px bg-border" />
2070
- <AnimatedPressable
2071
- accessibilityRole="button"
2072
- accessibilityLabel={locked ? 'Unlock the canvas' : 'Lock the canvas'}
2073
- accessibilityState={{ selected: locked }}
2074
- className={slots.control()}
2075
- onPress={() => setLocked(!locked)}
2076
- >
2077
- {locked ? <LockIcon size={16} /> : <UnlockIcon size={16} />}
2078
- </AnimatedPressable>
2079
- </>
2080
- ) : null}
2081
- </View>
2044
+ <IconColorProvider color={tint}>
2045
+ <View className={slots.controls({ className })}>
2046
+ {showZoom ? (
2047
+ <>
2048
+ <AnimatedPressable
2049
+ accessibilityRole="button"
2050
+ accessibilityLabel="Zoom in"
2051
+ className={slots.control()}
2052
+ onPress={() => zoomBy(step)}
2053
+ >
2054
+ <PlusIcon size={16} />
2055
+ </AnimatedPressable>
2056
+ <View className="h-px bg-border" />
2057
+ <AnimatedPressable
2058
+ accessibilityRole="button"
2059
+ accessibilityLabel="Zoom out"
2060
+ className={slots.control()}
2061
+ onPress={() => zoomBy(1 / step)}
2062
+ >
2063
+ <MinusIcon size={16} strokeWidth={2} />
2064
+ </AnimatedPressable>
2065
+ </>
2066
+ ) : null}
2067
+ {fit ? (
2068
+ <>
2069
+ <View className="h-px bg-border" />
2070
+ <AnimatedPressable
2071
+ accessibilityRole="button"
2072
+ accessibilityLabel="Fit the whole graph on screen"
2073
+ className={slots.control()}
2074
+ onPress={fitView}
2075
+ >
2076
+ <MaximizeIcon size={16} />
2077
+ </AnimatedPressable>
2078
+ </>
2079
+ ) : null}
2080
+ {lock ? (
2081
+ <>
2082
+ <View className="h-px bg-border" />
2083
+ <AnimatedPressable
2084
+ accessibilityRole="button"
2085
+ accessibilityLabel={locked ? 'Unlock the canvas' : 'Lock the canvas'}
2086
+ accessibilityState={{ selected: locked }}
2087
+ className={slots.control()}
2088
+ onPress={() => setLocked(!locked)}
2089
+ >
2090
+ {locked ? <LockIcon size={16} /> : <UnlockIcon size={16} />}
2091
+ </AnimatedPressable>
2092
+ </>
2093
+ ) : null}
2094
+ </View>
2095
+ </IconColorProvider>
2082
2096
  );
2083
2097
  }
2084
2098
  FlowControls.displayName = 'Flow.Controls';