panelui-native 0.104.0 → 0.105.1

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.
@@ -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;
package/src/index.ts CHANGED
@@ -872,6 +872,11 @@ export {
872
872
  type KpiGoodDirection,
873
873
  type KpiTone,
874
874
  } from './components/kpi';
875
+ export {
876
+ Countdown,
877
+ type CountdownProps,
878
+ type CountdownUnit,
879
+ } from './components/countdown';
875
880
  export {
876
881
  Leaderboard,
877
882
  rankEntries,