@expokit/cli 0.2.0 → 0.3.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/README.md +14 -0
- package/bin/expo-kit.js +86 -24
- package/bin/mcp.mjs +8 -1
- package/package.json +8 -3
- package/registry/_docs/accordion.json +16 -0
- package/registry/_docs/action-sheet.json +28 -0
- package/registry/_docs/alert.json +28 -0
- package/registry/_docs/avatar.json +24 -0
- package/registry/_docs/badge.json +35 -4
- package/registry/_docs/bottom-nav.json +24 -0
- package/registry/_docs/bottom-sheet.json +28 -0
- package/registry/_docs/bullet.json +3 -0
- package/registry/_docs/button-3d.json +16 -0
- package/registry/_docs/button.json +16 -0
- package/registry/_docs/calendar.json +16 -0
- package/registry/_docs/card-stack.json +16 -0
- package/registry/_docs/card.json +20 -0
- package/registry/_docs/carousel.json +20 -0
- package/registry/_docs/chat.json +184 -0
- package/registry/_docs/checkbox.json +16 -0
- package/registry/_docs/checkout.json +3 -0
- package/registry/_docs/chip-group.json +16 -0
- package/registry/_docs/color-swatch.json +16 -0
- package/registry/_docs/confetti.json +20 -0
- package/registry/_docs/coupon.json +16 -0
- package/registry/_docs/cube-pager.json +16 -0
- package/registry/_docs/dashboard.json +3 -0
- package/registry/_docs/date-picker.json +20 -0
- package/registry/_docs/dialog.json +24 -0
- package/registry/_docs/drawer.json +28 -0
- package/registry/_docs/fab.json +24 -0
- package/registry/_docs/flip-card.json +16 -0
- package/registry/_docs/floating-input.json +20 -0
- package/registry/_docs/gift-card.json +16 -0
- package/registry/_docs/glass-button.json +24 -0
- package/registry/_docs/glass-nav-bar.json +24 -0
- package/registry/_docs/glass-search-bar.json +24 -0
- package/registry/_docs/glass-segmented.json +28 -0
- package/registry/_docs/glass-sheet.json +28 -0
- package/registry/_docs/glass-tab-bar.json +24 -0
- package/registry/_docs/glass.json +16 -0
- package/registry/_docs/inbox.json +3 -0
- package/registry/_docs/isometric-bar.json +3 -0
- package/registry/_docs/kit.json +3 -0
- package/registry/_docs/kpi-card.json +3 -0
- package/registry/_docs/like-button.json +20 -0
- package/registry/_docs/liquidglass-tab.json +3 -0
- package/registry/_docs/list-item.json +16 -0
- package/registry/_docs/loyalty-card.json +16 -0
- package/registry/_docs/morph-button.json +20 -0
- package/registry/_docs/number-ticker.json +16 -0
- package/registry/_docs/offers.json +3 -0
- package/registry/_docs/onboarding.json +3 -0
- package/registry/_docs/otp-input.json +24 -0
- package/registry/_docs/pagination-dots.json +20 -0
- package/registry/_docs/parallax-layers.json +20 -0
- package/registry/_docs/parallax-scroll.json +24 -0
- package/registry/_docs/password-input.json +20 -0
- package/registry/_docs/payment-card.json +16 -0
- package/registry/_docs/pixel-bar.json +3 -0
- package/registry/_docs/polar-area.json +3 -0
- package/registry/_docs/profile.json +3 -0
- package/registry/_docs/progress-steps.json +24 -0
- package/registry/_docs/progress.json +20 -0
- package/registry/_docs/radio-group.json +20 -0
- package/registry/_docs/rating.json +24 -0
- package/registry/_docs/ring-carousel.json +20 -0
- package/registry/_docs/scratch-card.json +20 -0
- package/registry/_docs/search-bar.json +20 -0
- package/registry/_docs/search-results.json +3 -0
- package/registry/_docs/segmented-control.json +20 -0
- package/registry/_docs/select.json +26 -1
- package/registry/_docs/settings.json +3 -0
- package/registry/_docs/shimmer-button.json +20 -0
- package/registry/_docs/sign-in.json +3 -0
- package/registry/_docs/skeleton.json +20 -0
- package/registry/_docs/slide-to-confirm.json +24 -0
- package/registry/_docs/slider.json +21 -1
- package/registry/_docs/spin-wheel.json +24 -0
- package/registry/_docs/spinner.json +41 -11
- package/registry/_docs/stateful-button.json +184 -0
- package/registry/_docs/stepper.json +16 -0
- package/registry/_docs/swipe-deck.json +16 -0
- package/registry/_docs/swipeable-row.json +24 -1
- package/registry/_docs/switch.json +20 -0
- package/registry/_docs/tabs.json +24 -0
- package/registry/_docs/tag-input.json +20 -0
- package/registry/_docs/text-animation.json +141 -0
- package/registry/_docs/textarea.json +16 -0
- package/registry/_docs/ticket-card.json +20 -0
- package/registry/_docs/ticket-shape.json +3 -0
- package/registry/_docs/tilt-card.json +16 -0
- package/registry/_docs/time-picker.json +16 -0
- package/registry/_docs/timeline-chart.json +3 -0
- package/registry/_docs/timeline.json +16 -0
- package/registry/_docs/toast.json +24 -0
- package/registry/_docs/treemap.json +3 -0
- package/registry/_docs/waterfall.json +3 -0
- package/registry/_docs/wheel-list.json +20 -0
- package/registry/badge/files/src/components/ui/badge.tsx +327 -17
- package/registry/badge.json +1 -1
- package/registry/chat.json +18 -0
- package/registry/spinner/files/src/components/ui/spinner.tsx +435 -22
- package/registry/spinner.json +1 -1
- package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +532 -0
- package/registry/stateful-button/files/src/lib/kit.tsx +219 -0
- package/registry/stateful-button.json +18 -0
- package/registry/text-animation/files/src/components/ui/text-animation.tsx +650 -0
- package/registry/text-animation/files/src/lib/kit.tsx +219 -0
- package/registry/text-animation.json +15 -0
|
@@ -1,10 +1,20 @@
|
|
|
1
|
-
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
-
import {
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
AccessibilityInfo,
|
|
4
|
+
Animated,
|
|
5
|
+
Easing,
|
|
6
|
+
StyleSheet,
|
|
7
|
+
Text,
|
|
8
|
+
View,
|
|
9
|
+
type LayoutChangeEvent,
|
|
10
|
+
type StyleProp,
|
|
11
|
+
type ViewStyle,
|
|
12
|
+
} from 'react-native';
|
|
3
13
|
import Svg, { Circle, Path } from 'react-native-svg';
|
|
4
14
|
|
|
5
15
|
import { alpha, isTextNode, motion, readableOn, type Colors, useKit } from '@/lib/kit';
|
|
6
16
|
|
|
7
|
-
export type BadgeIconName = 'bell' | 'message' | 'package' | 'cart' | 'heart';
|
|
17
|
+
export type BadgeIconName = 'bell' | 'message' | 'package' | 'cart' | 'heart' | 'clock' | 'refresh' | 'check' | 'alert';
|
|
8
18
|
|
|
9
19
|
const ICON_STROKE = { strokeWidth: 1.85, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, fill: 'none' as const };
|
|
10
20
|
|
|
@@ -37,6 +47,20 @@ export function BadgeIcon({ name, color, size = 22 }: { name: BadgeIconName; col
|
|
|
37
47
|
<Path d="M6 6L5 3H2" {...p} />
|
|
38
48
|
</>
|
|
39
49
|
),
|
|
50
|
+
clock: (
|
|
51
|
+
<>
|
|
52
|
+
<Circle cx={12} cy={12} r={8.5} {...p} />
|
|
53
|
+
<Path d="M12 7.5V12l3 2" {...p} />
|
|
54
|
+
</>
|
|
55
|
+
),
|
|
56
|
+
refresh: <Path d="M19 12a7 7 0 1 1-2.05-4.95M19 4.5V8h-3.5" {...p} />,
|
|
57
|
+
check: <Path d="M5 12.5l4.5 4.5L19 7.5" {...p} />,
|
|
58
|
+
alert: (
|
|
59
|
+
<>
|
|
60
|
+
<Circle cx={12} cy={12} r={8.5} {...p} />
|
|
61
|
+
<Path d="M12 7.5v5M12 16.2v.3" {...p} />
|
|
62
|
+
</>
|
|
63
|
+
),
|
|
40
64
|
heart: <Path d="M12 20.5l-1.1-1C6.1 15.1 3 12.2 3 8.6A4.6 4.6 0 0 1 7.6 4c2 0 3.1 1.2 4.4 2.5C13.3 5.2 14.4 4 16.4 4A4.6 4.6 0 0 1 21 8.6c0 3.6-3.1 6.5-7.9 10.9L12 20.5z" {...p} />,
|
|
41
65
|
};
|
|
42
66
|
return (
|
|
@@ -101,6 +125,11 @@ export interface BadgeProps {
|
|
|
101
125
|
shape?: 'pill' | 'rounded';
|
|
102
126
|
/** Pulse the leading dot (dot variant) — "live" states. */
|
|
103
127
|
pulse?: boolean;
|
|
128
|
+
/**
|
|
129
|
+
* When the label, icon or colour changes: roll the old label up and the new one in, pop the icon,
|
|
130
|
+
* cross-fade the colours and spring the width (default true). Never animates on mount.
|
|
131
|
+
*/
|
|
132
|
+
animateChanges?: boolean;
|
|
104
133
|
style?: StyleProp<ViewStyle>;
|
|
105
134
|
}
|
|
106
135
|
|
|
@@ -114,7 +143,139 @@ function toneColor(colors: Colors, tone: BadgeTone) {
|
|
|
114
143
|
return { neutral: colors.mutedForeground, accent: colors.accent, success: colors.success, warning: colors.warning, danger: colors.danger }[tone];
|
|
115
144
|
}
|
|
116
145
|
|
|
117
|
-
|
|
146
|
+
function useReduceMotion() {
|
|
147
|
+
const [reduce, setReduce] = useState(false);
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
let alive = true;
|
|
150
|
+
AccessibilityInfo.isReduceMotionEnabled()
|
|
151
|
+
.then((r) => alive && setReduce(r))
|
|
152
|
+
.catch(() => {});
|
|
153
|
+
const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
|
|
154
|
+
return () => {
|
|
155
|
+
alive = false;
|
|
156
|
+
sub.remove();
|
|
157
|
+
};
|
|
158
|
+
}, []);
|
|
159
|
+
return reduce;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
interface Look {
|
|
163
|
+
bg: string;
|
|
164
|
+
fg: string;
|
|
165
|
+
border: string;
|
|
166
|
+
dot: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
interface Change {
|
|
170
|
+
/** Outgoing label rolling up and out; the current label rolls in under it. */
|
|
171
|
+
text?: { from: string; fg: string; perLetter: boolean; duration: number };
|
|
172
|
+
/** Outgoing icon (null when an icon was just added). */
|
|
173
|
+
icon?: { from: React.ReactNode };
|
|
174
|
+
/** Colours being faded from. */
|
|
175
|
+
look?: Look;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Short Latin labels roll letter by letter; anything else (CJK, emoji, long text) rolls as one piece. */
|
|
179
|
+
const LATIN = /^[A-Za-z0-9À-ɏ .,:;'’&+\-!?#%()/]{1,18}$/;
|
|
180
|
+
/** Roll timing in ms: delay between letters, one letter's travel, and how far the new label trails the old. */
|
|
181
|
+
const STAGGER = 24;
|
|
182
|
+
const ROLL = 320;
|
|
183
|
+
const LEAD = 70;
|
|
184
|
+
|
|
185
|
+
function labelText(node: React.ReactNode): string | null {
|
|
186
|
+
if (!isTextNode(node)) return null;
|
|
187
|
+
return Array.isArray(node) ? node.join('') : String(node);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function sameValue(a: unknown, b: unknown) {
|
|
191
|
+
if (Object.is(a, b)) return true;
|
|
192
|
+
if (!a || !b || typeof a !== 'object' || typeof b !== 'object' || React.isValidElement(a) || React.isValidElement(b)) return false;
|
|
193
|
+
try {
|
|
194
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
195
|
+
} catch {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Icons compare by type, key and props (ignoring colour), so an inline `<BadgeIcon />` only "changes" with its glyph. */
|
|
201
|
+
function sameIcon(a: React.ReactNode, b: React.ReactNode) {
|
|
202
|
+
if (Object.is(a, b)) return true;
|
|
203
|
+
if (!React.isValidElement(a) || !React.isValidElement(b)) return false;
|
|
204
|
+
if (a.type !== b.type || a.key !== b.key) return false;
|
|
205
|
+
const pa = a.props as Record<string, unknown>;
|
|
206
|
+
const pb = b.props as Record<string, unknown>;
|
|
207
|
+
for (const k of new Set([...Object.keys(pa), ...Object.keys(pb)])) {
|
|
208
|
+
if (k !== 'color' && !sameValue(pa[k], pb[k])) return false;
|
|
209
|
+
}
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Eased move from `from` to `to` while `v` runs start → end (held outside), sampled for the native driver. */
|
|
214
|
+
function span(v: Animated.Value, start: number, end: number, from: number, to: number, ease: (t: number) => number) {
|
|
215
|
+
const inputRange: number[] = [];
|
|
216
|
+
const outputRange: number[] = [];
|
|
217
|
+
for (let k = 0; k <= 10; k++) {
|
|
218
|
+
inputRange.push(start + ((end - start) * k) / 10);
|
|
219
|
+
outputRange.push(from + (to - from) * ease(k / 10));
|
|
220
|
+
}
|
|
221
|
+
return v.interpolate({ inputRange, outputRange, extrapolate: 'clamp' });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const easeIn = Easing.in(Easing.cubic);
|
|
225
|
+
const easeBack = Easing.out(Easing.back(1.7));
|
|
226
|
+
const easeLinear = Easing.linear;
|
|
227
|
+
|
|
228
|
+
/** One side of a label roll: letters (or the whole label) slide up out of / up into the clipped slot. */
|
|
229
|
+
function RollingLabel({
|
|
230
|
+
text,
|
|
231
|
+
color,
|
|
232
|
+
fontSize,
|
|
233
|
+
progress,
|
|
234
|
+
out,
|
|
235
|
+
perLetter,
|
|
236
|
+
duration,
|
|
237
|
+
}: {
|
|
238
|
+
text: string;
|
|
239
|
+
color: string;
|
|
240
|
+
fontSize: number;
|
|
241
|
+
progress: Animated.Value;
|
|
242
|
+
out: boolean;
|
|
243
|
+
perLetter: boolean;
|
|
244
|
+
duration: number;
|
|
245
|
+
}) {
|
|
246
|
+
const pieces = perLetter ? [...text].map((ch) => (ch === ' ' ? ' ' : ch)) : [text];
|
|
247
|
+
const distance = Math.ceil(fontSize * 1.35);
|
|
248
|
+
return (
|
|
249
|
+
<View pointerEvents="none" style={styles.roll}>
|
|
250
|
+
{pieces.map((piece, i) => {
|
|
251
|
+
const begin = ((out ? 0 : LEAD) + i * STAGGER) / duration;
|
|
252
|
+
const travel = (out ? ROLL * 0.7 : ROLL) / duration;
|
|
253
|
+
const end = Math.min(1, begin + travel);
|
|
254
|
+
const fade = Math.min(1, begin + travel * 0.5);
|
|
255
|
+
return (
|
|
256
|
+
<Animated.View
|
|
257
|
+
key={i}
|
|
258
|
+
style={{
|
|
259
|
+
opacity: out ? span(progress, begin, fade, 1, 0, easeLinear) : span(progress, begin, fade, 0, 1, easeLinear),
|
|
260
|
+
transform: [
|
|
261
|
+
{ translateY: out ? span(progress, begin, end, 0, -distance, easeIn) : span(progress, begin, end, distance, 0, easeBack) },
|
|
262
|
+
],
|
|
263
|
+
}}
|
|
264
|
+
>
|
|
265
|
+
<Text numberOfLines={1} allowFontScaling={false} style={[styles.label, { color, fontSize }]}>
|
|
266
|
+
{piece}
|
|
267
|
+
</Text>
|
|
268
|
+
</Animated.View>
|
|
269
|
+
);
|
|
270
|
+
})}
|
|
271
|
+
</View>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Small status label: solid, soft, outline or dot — five tones, three sizes. When its text, icon or
|
|
277
|
+
* tone changes, the label rolls over, the icon pops, the colour cross-fades and the pill springs to size.
|
|
278
|
+
*/
|
|
118
279
|
export function Badge({
|
|
119
280
|
children,
|
|
120
281
|
variant = 'soft',
|
|
@@ -124,9 +285,11 @@ export function Badge({
|
|
|
124
285
|
icon,
|
|
125
286
|
shape = 'pill',
|
|
126
287
|
pulse,
|
|
288
|
+
animateChanges = true,
|
|
127
289
|
style,
|
|
128
290
|
}: BadgeProps) {
|
|
129
291
|
const { colors, radius, dark } = useKit();
|
|
292
|
+
const reduce = useReduceMotion();
|
|
130
293
|
const s = SIZES[size];
|
|
131
294
|
const c = color ?? toneColor(colors, tone);
|
|
132
295
|
const neutral = !color && tone === 'neutral';
|
|
@@ -155,29 +318,167 @@ export function Badge({
|
|
|
155
318
|
};
|
|
156
319
|
const { bg, fg, border } = look[variant];
|
|
157
320
|
const dotColor = neutral ? colors.mutedForeground : c;
|
|
321
|
+
const text = labelText(children);
|
|
322
|
+
|
|
323
|
+
// Change tracking: diff against the last render and start a transition — never on mount.
|
|
324
|
+
const [last, setLast] = useState({ seq: 0, text, icon, look: { bg, fg, border, dot: dotColor } as Look });
|
|
325
|
+
const [change, setChange] = useState<Change | null>(null);
|
|
326
|
+
// While set, the pill keeps this width (number), then springs (Animated.Value) to the new content.
|
|
327
|
+
const [pin, setPin] = useState<number | Animated.Value | null>(null);
|
|
328
|
+
const natural = useRef<number | null>(null);
|
|
329
|
+
const rowWidth = useRef<number | null>(null);
|
|
330
|
+
const widthV = useRef(new Animated.Value(0)).current;
|
|
331
|
+
const flat = StyleSheet.flatten(style) ?? {};
|
|
332
|
+
const canSize = flat.width == null && flat.minWidth == null && flat.flex == null && flat.flexGrow == null && flat.alignSelf !== 'stretch';
|
|
333
|
+
|
|
334
|
+
const textChanged = text !== last.text;
|
|
335
|
+
const iconChanged = !sameIcon(icon, last.icon);
|
|
336
|
+
const lookChanged = bg !== last.look.bg || fg !== last.look.fg || border !== last.look.border || dotColor !== last.look.dot;
|
|
337
|
+
if (textChanged || iconChanged || lookChanged) {
|
|
338
|
+
setLast({ seq: last.seq + 1, text, icon, look: { bg, fg, border, dot: dotColor } });
|
|
339
|
+
const next: Change = {};
|
|
340
|
+
if (animateChanges && !reduce) {
|
|
341
|
+
if (textChanged && last.text != null && text != null) {
|
|
342
|
+
const perLetter = LATIN.test(last.text) && LATIN.test(text);
|
|
343
|
+
const n = perLetter ? Math.max(last.text.length, text.length) : 1;
|
|
344
|
+
next.text = { from: last.text, fg: last.look.fg, perLetter, duration: LEAD + (n - 1) * STAGGER + ROLL };
|
|
345
|
+
}
|
|
346
|
+
if (iconChanged && icon != null) next.icon = { from: last.icon ?? null };
|
|
347
|
+
if (lookChanged) next.look = last.look;
|
|
348
|
+
}
|
|
349
|
+
const animated = !!(next.text || next.icon || next.look);
|
|
350
|
+
setChange(animated ? next : null);
|
|
351
|
+
setPin(animated && (textChanged || iconChanged) && canSize && natural.current != null ? natural.current : null);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Fresh values per change, so the first frame of a transition already sits at its start.
|
|
355
|
+
const anim = useMemo(
|
|
356
|
+
() => ({ seq: last.seq, roll: new Animated.Value(0), pop: new Animated.Value(0), mix: new Animated.Value(0) }),
|
|
357
|
+
[last.seq]
|
|
358
|
+
);
|
|
158
359
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
360
|
+
useEffect(() => {
|
|
361
|
+
if (!change) return;
|
|
362
|
+
const runs: Animated.CompositeAnimation[] = [];
|
|
363
|
+
if (change.text) {
|
|
364
|
+
runs.push(Animated.timing(anim.roll, { toValue: 1, duration: change.text.duration, easing: easeLinear, useNativeDriver: true }));
|
|
365
|
+
}
|
|
366
|
+
if (change.icon) runs.push(Animated.spring(anim.pop, { toValue: 1, useNativeDriver: true, ...motion.bouncy }));
|
|
367
|
+
if (change.look) {
|
|
368
|
+
runs.push(Animated.timing(anim.mix, { toValue: 1, duration: motion.normal, easing: Easing.out(Easing.quad), useNativeDriver: false }));
|
|
369
|
+
}
|
|
370
|
+
let left = runs.length;
|
|
371
|
+
runs.forEach((r) =>
|
|
372
|
+
r.start(({ finished }) => {
|
|
373
|
+
left -= 1;
|
|
374
|
+
if (finished && left === 0) setChange((cur) => (cur === change ? null : cur));
|
|
375
|
+
})
|
|
376
|
+
);
|
|
377
|
+
return () => runs.forEach((r) => r.stop());
|
|
378
|
+
}, [change, anim]);
|
|
379
|
+
|
|
380
|
+
const onRoot = (e: LayoutChangeEvent) => {
|
|
381
|
+
if (pin == null) natural.current = e.nativeEvent.layout.width;
|
|
382
|
+
};
|
|
383
|
+
// The icon + label row remounts on every change, so this always reports the new content's width.
|
|
384
|
+
const onRow = (e: LayoutChangeEvent) => {
|
|
385
|
+
const w = e.nativeEvent.layout.width;
|
|
386
|
+
const prev = rowWidth.current;
|
|
387
|
+
rowWidth.current = w;
|
|
388
|
+
if (typeof pin !== 'number') return;
|
|
389
|
+
if (prev == null || Math.abs(w - prev) < 0.5) {
|
|
390
|
+
setPin(null);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const to = Math.max(0, pin + (w - prev));
|
|
394
|
+
widthV.setValue(pin);
|
|
395
|
+
natural.current = to;
|
|
396
|
+
setPin(widthV);
|
|
397
|
+
Animated.spring(widthV, { toValue: to, useNativeDriver: false, ...motion.snappy }).start(({ finished }) => {
|
|
398
|
+
if (finished) setPin((p) => (p === widthV ? null : p));
|
|
399
|
+
});
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const from = change?.look;
|
|
403
|
+
const mix = (a: string, b: string) => (from ? anim.mix.interpolate({ inputRange: [0, 1], outputRange: [a, b] }) : b);
|
|
404
|
+
const roll = change?.text;
|
|
405
|
+
|
|
406
|
+
let label: React.ReactNode = children;
|
|
407
|
+
if (text != null) {
|
|
408
|
+
label = (
|
|
409
|
+
<View style={roll ? styles.clip : undefined}>
|
|
410
|
+
<Animated.Text
|
|
411
|
+
numberOfLines={1}
|
|
412
|
+
allowFontScaling={false}
|
|
413
|
+
style={[styles.label, { color: from && !roll ? mix(from.fg, fg) : fg, fontSize: s.font }, roll ? styles.hidden : null]}
|
|
414
|
+
>
|
|
415
|
+
{text}
|
|
416
|
+
</Animated.Text>
|
|
417
|
+
{roll ? (
|
|
418
|
+
<>
|
|
419
|
+
<RollingLabel text={roll.from} color={roll.fg} fontSize={s.font} progress={anim.roll} out perLetter={roll.perLetter} duration={roll.duration} />
|
|
420
|
+
<RollingLabel text={text} color={fg} fontSize={s.font} progress={anim.roll} out={false} perLetter={roll.perLetter} duration={roll.duration} />
|
|
421
|
+
</>
|
|
422
|
+
) : null}
|
|
423
|
+
</View>
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const pop = anim.pop;
|
|
428
|
+
const iconNode =
|
|
429
|
+
icon != null && change?.icon ? (
|
|
430
|
+
<View>
|
|
431
|
+
{change.icon.from != null ? (
|
|
432
|
+
<Animated.View
|
|
433
|
+
pointerEvents="none"
|
|
434
|
+
style={[
|
|
435
|
+
styles.center,
|
|
436
|
+
{
|
|
437
|
+
opacity: pop.interpolate({ inputRange: [0, 0.4], outputRange: [1, 0], extrapolate: 'clamp' }),
|
|
438
|
+
transform: [
|
|
439
|
+
{ scale: pop.interpolate({ inputRange: [0, 0.5], outputRange: [1, 0.3], extrapolate: 'clamp' }) },
|
|
440
|
+
{ rotate: pop.interpolate({ inputRange: [0, 0.5], outputRange: ['0deg', '100deg'], extrapolate: 'clamp' }) },
|
|
441
|
+
],
|
|
442
|
+
},
|
|
443
|
+
]}
|
|
444
|
+
>
|
|
445
|
+
{change.icon.from}
|
|
446
|
+
</Animated.View>
|
|
447
|
+
) : null}
|
|
448
|
+
<Animated.View
|
|
449
|
+
style={{
|
|
450
|
+
opacity: pop.interpolate({ inputRange: [0, 0.35], outputRange: [0, 1], extrapolate: 'clamp' }),
|
|
451
|
+
transform: [
|
|
452
|
+
{ scale: pop.interpolate({ inputRange: [0, 1], outputRange: [0.3, 1] }) },
|
|
453
|
+
{ rotate: pop.interpolate({ inputRange: [0, 1], outputRange: ['-100deg', '0deg'] }) },
|
|
454
|
+
],
|
|
455
|
+
}}
|
|
456
|
+
>
|
|
457
|
+
{icon}
|
|
458
|
+
</Animated.View>
|
|
459
|
+
</View>
|
|
164
460
|
) : (
|
|
165
|
-
|
|
461
|
+
icon
|
|
166
462
|
);
|
|
167
463
|
|
|
168
464
|
return (
|
|
169
|
-
<View
|
|
465
|
+
<Animated.View
|
|
466
|
+
accessible={text != null ? true : undefined}
|
|
170
467
|
accessibilityRole="text"
|
|
468
|
+
accessibilityLabel={text ?? undefined}
|
|
469
|
+
accessibilityLiveRegion={animateChanges && text != null ? 'polite' : undefined}
|
|
470
|
+
onLayout={animateChanges ? onRoot : undefined}
|
|
171
471
|
style={[
|
|
172
472
|
styles.base,
|
|
173
473
|
{
|
|
174
474
|
height: s.h,
|
|
175
475
|
paddingHorizontal: s.px,
|
|
176
476
|
gap: s.gap,
|
|
177
|
-
backgroundColor: bg,
|
|
178
|
-
borderColor: border,
|
|
477
|
+
backgroundColor: from ? mix(from.bg, bg) : bg,
|
|
478
|
+
borderColor: from ? mix(from.border, border) : border,
|
|
179
479
|
borderRadius: shape === 'pill' ? radius.capsule(s.h) : radius.sm,
|
|
180
480
|
},
|
|
481
|
+
pin != null ? { width: pin, overflow: 'hidden' as const } : null,
|
|
181
482
|
style,
|
|
182
483
|
]}
|
|
183
484
|
>
|
|
@@ -196,12 +497,15 @@ export function Badge({
|
|
|
196
497
|
]}
|
|
197
498
|
/>
|
|
198
499
|
) : null}
|
|
199
|
-
|
|
500
|
+
{/* Colour fades on the JS driver, so it lives on this view — the pulse above owns the native transforms. */}
|
|
501
|
+
<Animated.View style={[styles.fill, { borderRadius: s.dot, backgroundColor: from ? mix(from.dot, dotColor) : dotColor }]} />
|
|
200
502
|
</View>
|
|
201
503
|
) : null}
|
|
202
|
-
{
|
|
203
|
-
|
|
204
|
-
|
|
504
|
+
<View key={last.seq} onLayout={animateChanges ? onRow : undefined} style={[styles.row, { gap: s.gap }]}>
|
|
505
|
+
{iconNode}
|
|
506
|
+
{label}
|
|
507
|
+
</View>
|
|
508
|
+
</Animated.View>
|
|
205
509
|
);
|
|
206
510
|
}
|
|
207
511
|
|
|
@@ -309,6 +613,12 @@ export function NotificationBadge({
|
|
|
309
613
|
const styles = StyleSheet.create({
|
|
310
614
|
base: { flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-start', borderWidth: 1 },
|
|
311
615
|
label: { fontWeight: '400', letterSpacing: 0.1 },
|
|
616
|
+
row: { flexDirection: 'row', alignItems: 'center' },
|
|
617
|
+
// Clips the roll; the 2pt slack (cancelled by negative margins) keeps overshoot and unkerned letters whole.
|
|
618
|
+
clip: { overflow: 'hidden', padding: 2, margin: -2 },
|
|
619
|
+
hidden: { opacity: 0 },
|
|
620
|
+
roll: { position: 'absolute', top: 2, left: 2, flexDirection: 'row' },
|
|
621
|
+
center: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, alignItems: 'center', justifyContent: 'center' },
|
|
312
622
|
fill: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 },
|
|
313
623
|
anchor: { alignSelf: 'flex-start' },
|
|
314
624
|
bubble: { alignItems: 'center', justifyContent: 'center', borderWidth: 2 },
|
package/registry/badge.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "badge",
|
|
3
3
|
"type": "component",
|
|
4
4
|
"title": "Badge",
|
|
5
|
-
"description": "Status labels in solid, soft, outline and dot styles across five tones —
|
|
5
|
+
"description": "Status labels in solid, soft, outline and dot styles across five tones — when the label changes it rolls over letter by letter, the icon pops, the colour cross-fades and the pill springs to its new width. Plus NotificationBadge with BadgeIconTile/BadgeIcon anchors (or your own children), spring-in counts, and pop on change.",
|
|
6
6
|
"tier": "free",
|
|
7
7
|
"dependencies": [
|
|
8
8
|
"react-native-svg"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "chat",
|
|
3
|
+
"type": "block",
|
|
4
|
+
"title": "Chat",
|
|
5
|
+
"description": "An AI assistant chat screen: replies stream in word by word with a soft fade and a blinking caret, a shimmering “Thinking…” waits for the first token, and the send button morphs into Stop. Light markdown (bold, inline code, lists, scrollable code blocks), suggestion chips, copy / regenerate / thumbs actions and a prompt field that grows to five lines and stays above the keyboard.",
|
|
6
|
+
"tier": "pro",
|
|
7
|
+
"dependencies": [
|
|
8
|
+
"react-native-svg",
|
|
9
|
+
"expo-haptics"
|
|
10
|
+
],
|
|
11
|
+
"registryDependencies": [
|
|
12
|
+
"kit"
|
|
13
|
+
],
|
|
14
|
+
"sharedFiles": [
|
|
15
|
+
"lib/kit.tsx"
|
|
16
|
+
],
|
|
17
|
+
"filesDir": null
|
|
18
|
+
}
|