@expokit/cli 0.4.0 → 0.4.2
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 +17 -0
- package/bin/lib.mjs +1 -1
- package/bin/liquidglass.mjs +281 -0
- package/package.json +17 -3
- package/registry/_docs/accordion.json +1 -1
- package/registry/_docs/avatar.json +6 -6
- package/registry/_docs/badge.json +3 -3
- package/registry/_docs/bottom-nav.json +6 -6
- package/registry/_docs/bottom-sheet.json +3 -3
- package/registry/_docs/bullet.json +6 -0
- package/registry/_docs/button-3d.json +2 -2
- package/registry/_docs/button.json +2 -2
- package/registry/_docs/calendar.json +5 -5
- package/registry/_docs/card.json +1 -1
- package/registry/_docs/chip-group.json +1 -1
- package/registry/_docs/confetti.json +3 -3
- package/registry/_docs/date-picker.json +4 -4
- package/registry/_docs/drawer.json +4 -4
- package/registry/_docs/flip-card.json +4 -4
- package/registry/_docs/floating-input.json +1 -1
- package/registry/_docs/grain.json +2 -2
- package/registry/_docs/isometric-bar.json +6 -0
- package/registry/_docs/liquidglass-tab.json +2 -1
- package/registry/_docs/otp-input.json +1 -1
- package/registry/_docs/payment-card.json +3 -3
- package/registry/_docs/pixel-bar.json +6 -0
- package/registry/_docs/polar-area.json +6 -0
- package/registry/_docs/progress-steps.json +4 -4
- package/registry/_docs/progress.json +2 -2
- package/registry/_docs/radio-group.json +1 -1
- package/registry/_docs/search-bar.json +3 -3
- package/registry/_docs/segmented-control.json +1 -1
- package/registry/_docs/select.json +6 -6
- package/registry/_docs/skeleton.json +1 -1
- package/registry/_docs/slider.json +6 -6
- package/registry/_docs/spinner.json +2 -2
- package/registry/_docs/stateful-button.json +1 -1
- package/registry/_docs/stepper.json +3 -3
- package/registry/_docs/tabs.json +2 -2
- package/registry/_docs/text-animation.json +4 -4
- package/registry/_docs/textarea.json +1 -1
- package/registry/_docs/tilt-card.json +3 -3
- package/registry/_docs/time-picker.json +6 -6
- package/registry/_docs/timeline-chart.json +6 -0
- package/registry/_docs/timeline.json +4 -4
- package/registry/_docs/treemap.json +6 -0
- package/registry/_docs/waterfall.json +6 -0
- package/registry/accordion/files/src/components/ui/accordion.tsx +31 -3
- package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +4 -1
- package/registry/alert/files/src/components/ui/alert.tsx +5 -2
- package/registry/alert/files/src/components/ui/button.tsx +32 -4
- package/registry/badge/files/src/components/ui/badge.tsx +2 -2
- package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +11 -5
- package/registry/button/files/src/components/ui/button.tsx +32 -4
- package/registry/button-3d/files/src/components/ui/button-3d.tsx +33 -6
- package/registry/calendar/files/src/components/ui/calendar.tsx +62 -17
- package/registry/card/files/src/components/ui/card.tsx +25 -5
- package/registry/checkbox/files/src/components/ui/checkbox.tsx +26 -4
- package/registry/chip-group/files/src/components/ui/chip-group.tsx +35 -5
- package/registry/coupon/files/src/components/ui/coupon.tsx +45 -21
- package/registry/coupon/files/src/components/ui/flip-card.tsx +22 -3
- package/registry/coupon/files/src/components/ui/tilt-card.tsx +57 -16
- package/registry/date-picker/files/src/components/ui/calendar.tsx +62 -17
- package/registry/date-picker/files/src/components/ui/date-picker.tsx +96 -42
- package/registry/dialog/files/src/components/ui/dialog.tsx +9 -5
- package/registry/drawer/files/src/components/ui/drawer.tsx +8 -4
- package/registry/flip-card/files/src/components/ui/flip-card.tsx +22 -3
- package/registry/floating-input/files/src/components/ui/floating-input.tsx +75 -26
- package/registry/liquidglass-tab/files/src/app/(tabs)/_layout.tsx +3 -94
- package/registry/liquidglass-tab/files/src/app/(tabs)/alerts.tsx +2 -1
- package/registry/liquidglass-tab/files/src/app/_layout.tsx +2 -2
- package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +2 -1
- package/registry/liquidglass-tab/files/src/components/LiquidGlassTabs.tsx +110 -0
- package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +31 -6
- package/registry/liquidglass-tab/files/src/lib/tab-bar.ts +12 -4
- package/registry/liquidglass-tab/files/src/lib/tab-icons.ts +6 -3
- package/registry/liquidglass-tab/files/src/lib/tabs.ts +4 -2
- package/registry/list-item/files/src/components/ui/list-item.tsx +10 -3
- package/registry/otp-input/files/src/components/ui/otp-input.tsx +50 -19
- package/registry/password-input/files/src/components/ui/floating-input.tsx +75 -26
- package/registry/password-input/files/src/components/ui/password-input.tsx +53 -19
- package/registry/payment-card/files/src/components/ui/flip-card.tsx +22 -3
- package/registry/payment-card/files/src/components/ui/payment-card.tsx +19 -15
- package/registry/payment-card/files/src/components/ui/tilt-card.tsx +57 -16
- package/registry/progress/files/src/components/ui/progress.tsx +9 -2
- package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +11 -4
- package/registry/radio-group/files/src/components/ui/radio-group.tsx +39 -9
- package/registry/search-bar/files/src/components/ui/search-bar.tsx +70 -17
- package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +37 -9
- package/registry/select/files/src/components/ui/select.tsx +158 -83
- package/registry/slider/files/src/components/ui/slider.tsx +53 -25
- package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +12 -4
- package/registry/stepper/files/src/components/ui/stepper.tsx +76 -26
- package/registry/switch/files/src/components/ui/switch.tsx +56 -19
- package/registry/tabs/files/src/components/ui/tabs.tsx +7 -4
- package/registry/text-animation/files/src/components/ui/text-animation.tsx +7 -2
- package/registry/textarea/files/src/components/ui/textarea.tsx +104 -36
- package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +57 -16
- package/registry/time-picker/files/src/components/ui/time-picker.tsx +154 -73
- package/registry/timeline/files/src/components/ui/timeline.tsx +9 -4
- package/registry/toast/files/src/components/ui/button.tsx +32 -4
- package/registry/toast/files/src/components/ui/toast.tsx +15 -6
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState, type ComponentRef } from 'react';
|
|
2
2
|
import {
|
|
3
|
+
AccessibilityInfo,
|
|
3
4
|
Animated,
|
|
4
5
|
Easing,
|
|
5
6
|
Platform,
|
|
@@ -85,6 +86,9 @@ export const FLOATING_INPUT_SIZES = {
|
|
|
85
86
|
md: { height: 54, font: 16, floated: 12.5, padX: 16, icon: 18, radius: 0 },
|
|
86
87
|
lg: { height: 64, font: 18, floated: 13.5, padX: 18, icon: 20, radius: 4 },
|
|
87
88
|
};
|
|
89
|
+
// Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape;
|
|
90
|
+
// content text scales all the way.
|
|
91
|
+
const FONT_CAP = 1.35;
|
|
88
92
|
const HALO = 4;
|
|
89
93
|
/** Font size of the static label used when `floatingLabel` is false. */
|
|
90
94
|
const LABEL_ABOVE_FONT = { sm: 13, md: 14, lg: 14.5 };
|
|
@@ -166,6 +170,7 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
|
|
|
166
170
|
ref
|
|
167
171
|
) {
|
|
168
172
|
const { colors, radius, font } = useKit();
|
|
173
|
+
const reduce = useReduceMotion();
|
|
169
174
|
const ff = font.family ? { fontFamily: font.family } : null;
|
|
170
175
|
const S = FLOATING_INPUT_SIZES[size];
|
|
171
176
|
// The user's border colour only replaces the resting one; numeric height / radius drive the layout maths.
|
|
@@ -200,29 +205,36 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
|
|
|
200
205
|
const check = useRef(new Animated.Value(ok ? 1 : 0)).current; // native
|
|
201
206
|
|
|
202
207
|
useEffect(() => {
|
|
203
|
-
|
|
204
|
-
|
|
208
|
+
if (reduce) float.setValue(floating ? 1 : 0);
|
|
209
|
+
else Animated.spring(float, { toValue: floating ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
|
|
210
|
+
}, [floating, float, reduce]);
|
|
205
211
|
|
|
206
212
|
useEffect(() => {
|
|
207
213
|
Animated.timing(focus, { toValue: isFocused ? 1 : 0, duration: 180, easing: Easing.out(Easing.quad), useNativeDriver: false }).start();
|
|
208
|
-
|
|
209
|
-
|
|
214
|
+
// Reduce Motion: halo and indicator bar only fade (their scales are pinned below).
|
|
215
|
+
if (reduce) Animated.timing(glow, { toValue: isFocused ? 1 : 0, duration: 150, useNativeDriver: true }).start();
|
|
216
|
+
else Animated.spring(glow, { toValue: isFocused ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
|
|
217
|
+
}, [isFocused, focus, glow, reduce]);
|
|
210
218
|
|
|
211
219
|
useEffect(() => {
|
|
212
|
-
|
|
213
|
-
|
|
220
|
+
const to = clearable && text && editable ? 1 : 0;
|
|
221
|
+
if (reduce) Animated.timing(clear, { toValue: to, duration: 150, useNativeDriver: true }).start();
|
|
222
|
+
else Animated.spring(clear, { toValue: to, useNativeDriver: true, ...motion.bouncy }).start();
|
|
223
|
+
}, [clearable, text, editable, clear, reduce]);
|
|
214
224
|
|
|
215
225
|
useEffect(() => {
|
|
216
|
-
Animated.
|
|
217
|
-
|
|
226
|
+
if (reduce) Animated.timing(check, { toValue: ok ? 1 : 0, duration: 150, useNativeDriver: true }).start();
|
|
227
|
+
else Animated.spring(check, { toValue: ok ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
|
|
228
|
+
}, [ok, check, reduce]);
|
|
218
229
|
|
|
219
230
|
useEffect(() => {
|
|
220
|
-
|
|
231
|
+
// Reduce Motion: no shake; the red border and message still flag the error.
|
|
232
|
+
if (!error || reduce) return;
|
|
221
233
|
shake.setValue(0);
|
|
222
234
|
Animated.sequence(
|
|
223
235
|
[10, -8, 6, -4, 0].map((x) => Animated.timing(shake, { toValue: x, duration: 55, useNativeDriver: true }))
|
|
224
236
|
).start();
|
|
225
|
-
}, [error, shake]);
|
|
237
|
+
}, [error, shake, reduce]);
|
|
226
238
|
|
|
227
239
|
const setText = (next: string) => {
|
|
228
240
|
if (value === undefined) setInner(next);
|
|
@@ -310,23 +322,24 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
|
|
|
310
322
|
borderRadius: corner + HALO,
|
|
311
323
|
backgroundColor: active,
|
|
312
324
|
opacity: glow.interpolate({ inputRange: [0, 1], outputRange: [0, 0.18], extrapolate: 'clamp' }),
|
|
313
|
-
transform: [{ scale: glow.interpolate({ inputRange: [0, 1], outputRange: [0.97, 1] }) }],
|
|
325
|
+
transform: [{ scale: glow.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 0.97, 1] }) }],
|
|
314
326
|
},
|
|
315
327
|
]}
|
|
316
328
|
/>
|
|
317
329
|
)}
|
|
318
330
|
<Pressable onPress={() => inputRef.current?.focus()} disabled={!editable} accessible={false}>
|
|
319
|
-
<Animated.View style={[styles.field, boxStyle, {
|
|
331
|
+
<Animated.View style={[styles.field, boxStyle, { minHeight: HEIGHT }, userField, { borderColor }]}>
|
|
320
332
|
{left ? <View style={[styles.left, { left: underline ? 2 : S.padX - 2 }, underline && { top: contentTop }]}>{left}</View> : null}
|
|
321
333
|
<View style={[styles.row, { paddingLeft: leftPad, paddingRight: rightPad, paddingTop: contentTop }]}>
|
|
322
334
|
{prefix ? (
|
|
323
|
-
<Animated.Text style={[styles.affix, styles.prefix, ff, { fontSize: S.font }, { color: colors.mutedForeground }, prefixStyle, { opacity: affixOpacity }]}>
|
|
335
|
+
<Animated.Text maxFontSizeMultiplier={FONT_CAP} style={[styles.affix, styles.prefix, ff, { fontSize: S.font }, { color: colors.mutedForeground }, prefixStyle, { opacity: affixOpacity }]}>
|
|
324
336
|
{prefix}
|
|
325
337
|
</Animated.Text>
|
|
326
338
|
) : null}
|
|
327
339
|
<TextInput
|
|
328
340
|
ref={inputRef}
|
|
329
341
|
accessibilityLabel={label}
|
|
342
|
+
maxFontSizeMultiplier={FONT_CAP}
|
|
330
343
|
{...props}
|
|
331
344
|
maxLength={maxLength}
|
|
332
345
|
editable={editable}
|
|
@@ -347,7 +360,7 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
|
|
|
347
360
|
style={[styles.input, ff, { color: colors.foreground, fontSize: S.font }, inputStyle, NO_WEB_OUTLINE]}
|
|
348
361
|
/>
|
|
349
362
|
{suffix ? (
|
|
350
|
-
<Animated.Text style={[styles.affix, styles.suffix, ff, { fontSize: S.font }, { color: colors.mutedForeground }, suffixStyle, { opacity: affixOpacity }]}>
|
|
363
|
+
<Animated.Text maxFontSizeMultiplier={FONT_CAP} style={[styles.affix, styles.suffix, ff, { fontSize: S.font }, { color: colors.mutedForeground }, suffixStyle, { opacity: affixOpacity }]}>
|
|
351
364
|
{suffix}
|
|
352
365
|
</Animated.Text>
|
|
353
366
|
) : null}
|
|
@@ -358,6 +371,7 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
|
|
|
358
371
|
style={[styles.labelWrap, { left: leftPad, right: right ? adorn : S.padX - 4, top: contentTop, height: HEIGHT - contentTop }, labelStyle]}
|
|
359
372
|
>
|
|
360
373
|
<Animated.Text
|
|
374
|
+
maxFontSizeMultiplier={FONT_CAP}
|
|
361
375
|
numberOfLines={1}
|
|
362
376
|
style={[
|
|
363
377
|
styles.label,
|
|
@@ -380,7 +394,7 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
|
|
|
380
394
|
) : null}
|
|
381
395
|
{clearable ? (
|
|
382
396
|
<Animated.View
|
|
383
|
-
style={[styles.slot, { right: edge, top: contentTop, opacity: clear, transform: [{ scale: clear }] }]}
|
|
397
|
+
style={[styles.slot, { right: edge, top: contentTop, opacity: clear, transform: [{ scale: reduce ? 1 : clear }] }]}
|
|
384
398
|
pointerEvents={text && editable ? 'box-none' : 'none'}
|
|
385
399
|
>
|
|
386
400
|
<Pressable
|
|
@@ -408,8 +422,8 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
|
|
|
408
422
|
top: contentTop,
|
|
409
423
|
opacity: check.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),
|
|
410
424
|
transform: [
|
|
411
|
-
{ scale: check.interpolate({ inputRange: [0, 1], outputRange: [0.3, 1] }) },
|
|
412
|
-
{ rotate: check.interpolate({ inputRange: [0, 1], outputRange: ['-30deg', '0deg'] }) },
|
|
425
|
+
{ scale: check.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 1 : 0.3, 1] }) },
|
|
426
|
+
{ rotate: check.interpolate({ inputRange: [0, 1], outputRange: [reduce ? '0deg' : '-30deg', '0deg'] }) },
|
|
413
427
|
],
|
|
414
428
|
},
|
|
415
429
|
]}
|
|
@@ -423,10 +437,14 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
|
|
|
423
437
|
{outlined ? null : (
|
|
424
438
|
<>
|
|
425
439
|
<View pointerEvents="none" style={[styles.baseLine, { height: underline ? 1.5 : 1, backgroundColor: rest }]} />
|
|
426
|
-
{/* Grows out from the centre on focus. */}
|
|
440
|
+
{/* Grows out from the centre on focus (fades in with Reduce Motion). */}
|
|
427
441
|
<Animated.View
|
|
428
442
|
pointerEvents="none"
|
|
429
|
-
style={[
|
|
443
|
+
style={[
|
|
444
|
+
styles.bar,
|
|
445
|
+
{ backgroundColor: active },
|
|
446
|
+
reduce ? { opacity: glowClamped } : { transform: [{ scaleX: glowClamped }] },
|
|
447
|
+
]}
|
|
430
448
|
/>
|
|
431
449
|
</>
|
|
432
450
|
)}
|
|
@@ -436,10 +454,11 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
|
|
|
436
454
|
{message || showCounter ? (
|
|
437
455
|
<View style={[styles.footer, underline && styles.footerFlush]}>
|
|
438
456
|
<View style={styles.messageSlot}>
|
|
439
|
-
{message ? <FieldMessage key={tone + message} text={message} tone={tone} color={toneColor} style={messageStyle} /> : null}
|
|
457
|
+
{message ? <FieldMessage key={tone + message} text={message} tone={tone} color={toneColor} style={messageStyle} reduce={reduce} /> : null}
|
|
440
458
|
</View>
|
|
441
459
|
{showCounter ? (
|
|
442
460
|
<Text
|
|
461
|
+
maxFontSizeMultiplier={FONT_CAP}
|
|
443
462
|
style={[
|
|
444
463
|
styles.count,
|
|
445
464
|
ff,
|
|
@@ -459,13 +478,27 @@ export const FloatingInput = forwardRef<ComponentRef<typeof TextInput>, Floating
|
|
|
459
478
|
|
|
460
479
|
type MessageTone = 'error' | 'success' | 'helper';
|
|
461
480
|
|
|
462
|
-
/** Helper / error / success line. Error and success slide up and fade in. */
|
|
463
|
-
function FieldMessage({
|
|
481
|
+
/** Helper / error / success line. Error and success slide up and fade in (only fade with Reduce Motion). */
|
|
482
|
+
function FieldMessage({
|
|
483
|
+
text,
|
|
484
|
+
tone,
|
|
485
|
+
color,
|
|
486
|
+
style,
|
|
487
|
+
reduce,
|
|
488
|
+
}: {
|
|
489
|
+
text: string;
|
|
490
|
+
tone: MessageTone;
|
|
491
|
+
color: string;
|
|
492
|
+
style?: StyleProp<TextStyle>;
|
|
493
|
+
reduce: boolean;
|
|
494
|
+
}) {
|
|
464
495
|
const { font } = useKit();
|
|
465
496
|
const enter = useRef(new Animated.Value(tone === 'helper' ? 1 : 0)).current; // native
|
|
466
497
|
useEffect(() => {
|
|
467
|
-
if (tone
|
|
468
|
-
|
|
498
|
+
if (tone === 'helper') return;
|
|
499
|
+
if (reduce) Animated.timing(enter, { toValue: 1, duration: 150, useNativeDriver: true }).start();
|
|
500
|
+
else Animated.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
|
|
501
|
+
}, [tone, enter, reduce]);
|
|
469
502
|
|
|
470
503
|
return (
|
|
471
504
|
<Animated.View
|
|
@@ -473,7 +506,7 @@ function FieldMessage({ text, tone, color, style }: { text: string; tone: Messag
|
|
|
473
506
|
styles.message,
|
|
474
507
|
{
|
|
475
508
|
opacity: enter.interpolate({ inputRange: [0, 1], outputRange: [0, 1], extrapolate: 'clamp' }),
|
|
476
|
-
transform: [{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [4, 0] }) }],
|
|
509
|
+
transform: [{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [reduce ? 0 : 4, 0] }) }],
|
|
477
510
|
},
|
|
478
511
|
]}
|
|
479
512
|
>
|
|
@@ -510,11 +543,27 @@ function flattenView(style: StyleProp<ViewStyle>): ViewStyle {
|
|
|
510
543
|
return style as ViewStyle;
|
|
511
544
|
}
|
|
512
545
|
|
|
546
|
+
function useReduceMotion() {
|
|
547
|
+
const [reduce, setReduce] = useState(false);
|
|
548
|
+
useEffect(() => {
|
|
549
|
+
let alive = true;
|
|
550
|
+
AccessibilityInfo.isReduceMotionEnabled()
|
|
551
|
+
.then((r) => alive && setReduce(r))
|
|
552
|
+
.catch(() => {});
|
|
553
|
+
const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
|
|
554
|
+
return () => {
|
|
555
|
+
alive = false;
|
|
556
|
+
sub.remove();
|
|
557
|
+
};
|
|
558
|
+
}, []);
|
|
559
|
+
return reduce;
|
|
560
|
+
}
|
|
561
|
+
|
|
513
562
|
const styles = StyleSheet.create({
|
|
514
563
|
disabled: { opacity: 0.5 },
|
|
515
564
|
halo: { position: 'absolute', top: -HALO, left: -HALO, right: -HALO, bottom: -HALO },
|
|
516
565
|
field: { justifyContent: 'center' },
|
|
517
|
-
row: { flexDirection: 'row', alignItems: 'center',
|
|
566
|
+
row: { flexDirection: 'row', alignItems: 'center', flexGrow: 1 },
|
|
518
567
|
input: { flex: 1, alignSelf: 'stretch', paddingVertical: 0, paddingHorizontal: 0 },
|
|
519
568
|
affix: { fontWeight: '400' },
|
|
520
569
|
prefix: { marginRight: 4 },
|
|
@@ -1,97 +1,6 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { DynamicColorIOS, Platform } from 'react-native';
|
|
3
|
-
import { Tabs } from 'expo-router/js-tabs';
|
|
4
|
-
// SDK 54–57 path. On SDK 58+ change this to 'expo-router/native-tabs'.
|
|
5
|
-
import { NativeTabs } from 'expo-router/unstable-native-tabs';
|
|
6
|
-
|
|
7
|
-
import { GlassTabBar } from '@/components/GlassTabBar';
|
|
8
|
-
import { USE_NATIVE_TABS } from '@/lib/tab-bar';
|
|
9
|
-
import {
|
|
10
|
-
asFamily,
|
|
11
|
-
resolveIconLibrary,
|
|
12
|
-
resolveIconNames,
|
|
13
|
-
TAB_BAR,
|
|
14
|
-
TABS,
|
|
15
|
-
type TabItem,
|
|
16
|
-
} from '@/lib/tabs';
|
|
17
|
-
|
|
18
|
-
type IconProps = React.ComponentProps<typeof NativeTabs.Trigger.Icon>;
|
|
19
|
-
|
|
20
|
-
function dynamicColor(pair: { light: string; dark: string }) {
|
|
21
|
-
return Platform.OS === 'ios' ? DynamicColorIOS(pair) : pair.light;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** Vector-font icon rasterised for the native bar (Android, or iOS when no SF Symbol is set). */
|
|
25
|
-
function vectorSrc(tab: TabItem) {
|
|
26
|
-
const library = resolveIconLibrary(tab);
|
|
27
|
-
const { name, selected } = resolveIconNames(tab);
|
|
28
|
-
if (!library || !name) return undefined;
|
|
29
|
-
|
|
30
|
-
const family = asFamily(library);
|
|
31
|
-
const icon = <NativeTabs.Trigger.VectorIcon family={family} name={name} />;
|
|
32
|
-
if (!selected) return icon;
|
|
33
|
-
return { default: icon, selected: <NativeTabs.Trigger.VectorIcon family={family} name={selected} /> };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Returns the Icon element itself (not a wrapper component): NativeTabs reads its
|
|
38
|
-
* children by type, so <NativeTabs.Trigger.Icon> must be a direct child of the Trigger.
|
|
39
|
-
*/
|
|
40
|
-
function nativeIcon(tab: TabItem, tint: IconProps['selectedColor']) {
|
|
41
|
-
const src = tab.src ?? vectorSrc(tab);
|
|
42
|
-
if (!tab.sf && !src) return null;
|
|
43
|
-
// sf wins on iOS, src is used on Android.
|
|
44
|
-
const props = { sf: tab.sf, src, selectedColor: tint } as IconProps;
|
|
45
|
-
return <NativeTabs.Trigger.Icon {...props} />;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function NativeTabLayout() {
|
|
49
|
-
const tint = dynamicColor(TAB_BAR.colors.tint);
|
|
50
|
-
const inactive = dynamicColor(TAB_BAR.colors.inactive);
|
|
51
|
-
const { fontSize, fontWeight, selectedFontWeight } = TAB_BAR.labelStyle;
|
|
52
|
-
|
|
53
|
-
return (
|
|
54
|
-
<NativeTabs
|
|
55
|
-
tintColor={tint}
|
|
56
|
-
iconColor={inactive}
|
|
57
|
-
labelVisibilityMode={TAB_BAR.labelVisibilityMode}
|
|
58
|
-
minimizeBehavior={TAB_BAR.minimizeBehavior}
|
|
59
|
-
labelStyle={{ color: inactive, fontSize, fontWeight }}
|
|
60
|
-
{...(TAB_BAR.backgroundColor != null ? { backgroundColor: TAB_BAR.backgroundColor } : null)}
|
|
61
|
-
>
|
|
62
|
-
{TABS.map((tab) => (
|
|
63
|
-
<NativeTabs.Trigger key={tab.name} name={tab.name} hidden={tab.hidden}>
|
|
64
|
-
{nativeIcon(tab, tint)}
|
|
65
|
-
<NativeTabs.Trigger.Label
|
|
66
|
-
hidden={tab.hiddenLabel}
|
|
67
|
-
selectedStyle={{ color: tint, fontSize, fontWeight: selectedFontWeight }}
|
|
68
|
-
>
|
|
69
|
-
{tab.title}
|
|
70
|
-
</NativeTabs.Trigger.Label>
|
|
71
|
-
{tab.badge ? <NativeTabs.Trigger.Badge>{tab.badge}</NativeTabs.Trigger.Badge> : null}
|
|
72
|
-
</NativeTabs.Trigger>
|
|
73
|
-
))}
|
|
74
|
-
</NativeTabs>
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function GlassTabLayout() {
|
|
79
|
-
return (
|
|
80
|
-
<Tabs
|
|
81
|
-
tabBar={(props) => <GlassTabBar {...props} />}
|
|
82
|
-
screenOptions={{ headerShown: false, animation: 'fade' }}
|
|
83
|
-
>
|
|
84
|
-
{TABS.map((tab) => (
|
|
85
|
-
<Tabs.Screen
|
|
86
|
-
key={tab.name}
|
|
87
|
-
name={tab.name}
|
|
88
|
-
options={{ title: tab.title, href: tab.hidden ? null : undefined }}
|
|
89
|
-
/>
|
|
90
|
-
))}
|
|
91
|
-
</Tabs>
|
|
92
|
-
);
|
|
93
|
-
}
|
|
1
|
+
import { LiquidGlassTabs } from '@/components/LiquidGlassTabs';
|
|
94
2
|
|
|
3
|
+
// Tabs, icons and colours live in lib/tabs.ts. Auth redirects etc. go here, before the return.
|
|
95
4
|
export default function TabLayout() {
|
|
96
|
-
return
|
|
5
|
+
return <LiquidGlassTabs />;
|
|
97
6
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { TabScreenFromConfig } from '@/components/TabScreen';
|
|
2
2
|
|
|
3
3
|
export default function AlertsScreen() {
|
|
4
|
-
|
|
4
|
+
// The button is pinned with useTabBarInset(), so it clears the floating bar on every OS.
|
|
5
|
+
return <TabScreenFromConfig tabName="alerts" action="Mark all as read" />;
|
|
5
6
|
}
|
|
@@ -3,8 +3,8 @@ import React, { useEffect } from 'react';
|
|
|
3
3
|
import { StyleSheet, useColorScheme } from 'react-native';
|
|
4
4
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
|
5
5
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
|
6
|
-
//
|
|
7
|
-
// ThemeProvider
|
|
6
|
+
// Navigation themes: from 'expo-router' on SDK 56+ (it ships its own copy, so the
|
|
7
|
+
// @react-navigation/native ThemeProvider would do nothing there), '@react-navigation/native' on SDK 54–55.
|
|
8
8
|
import { DarkTheme, DefaultTheme, Slot, ThemeProvider } from 'expo-router';
|
|
9
9
|
import * as SplashScreen from 'expo-splash-screen';
|
|
10
10
|
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from 'react-native';
|
|
15
15
|
import { BlurView } from 'expo-blur';
|
|
16
16
|
import * as Haptics from 'expo-haptics';
|
|
17
|
+
// '@react-navigation/bottom-tabs' on SDK 54–55 (the CLI writes the right one).
|
|
17
18
|
import type { BottomTabBarProps } from 'expo-router/js-tabs';
|
|
18
19
|
|
|
19
20
|
import { GLASS_BAR_HEIGHT, setTabBarCollapsed, tabBarCollapse } from '@/lib/tab-bar';
|
|
@@ -157,7 +158,7 @@ function TabButton({
|
|
|
157
158
|
}) {
|
|
158
159
|
const press = useRef(new Animated.Value(1)).current;
|
|
159
160
|
const to = (v: number) =>
|
|
160
|
-
Animated.spring(press, { toValue: v, useNativeDriver: true, speed: 40, bounciness:
|
|
161
|
+
Animated.spring(press, { toValue: v, useNativeDriver: true, speed: 40, bounciness: 0 }).start();
|
|
161
162
|
|
|
162
163
|
return (
|
|
163
164
|
<Pressable
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { DynamicColorIOS, Platform } from 'react-native';
|
|
3
|
+
// Written for your Expo SDK by `npx @expokit/cli add liquidglass-tab` (SDK 54–55: Tabs from
|
|
4
|
+
// 'expo-router'; SDK 58+: NativeTabs from 'expo-router/native-tabs'). `update` keeps it in sync.
|
|
5
|
+
import { Tabs } from 'expo-router/js-tabs';
|
|
6
|
+
import { NativeTabs } from 'expo-router/unstable-native-tabs';
|
|
7
|
+
|
|
8
|
+
import { GlassTabBar } from '@/components/GlassTabBar';
|
|
9
|
+
import { USE_NATIVE_TABS } from '@/lib/tab-bar';
|
|
10
|
+
import {
|
|
11
|
+
asFamily,
|
|
12
|
+
resolveIconLibrary,
|
|
13
|
+
resolveIconNames,
|
|
14
|
+
TAB_BAR,
|
|
15
|
+
TABS,
|
|
16
|
+
type TabItem,
|
|
17
|
+
} from '@/lib/tabs';
|
|
18
|
+
|
|
19
|
+
type IconProps = React.ComponentProps<typeof NativeTabs.Trigger.Icon>;
|
|
20
|
+
|
|
21
|
+
function dynamicColor(pair: { light: string; dark: string }) {
|
|
22
|
+
return Platform.OS === 'ios' ? DynamicColorIOS(pair) : pair.light;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Vector-font icon rasterised for the native bar (Android, or iOS when no SF Symbol is set). */
|
|
26
|
+
function vectorSrc(tab: TabItem) {
|
|
27
|
+
const library = resolveIconLibrary(tab);
|
|
28
|
+
const { name, selected } = resolveIconNames(tab);
|
|
29
|
+
if (!library || !name) return undefined;
|
|
30
|
+
|
|
31
|
+
const family = asFamily(library);
|
|
32
|
+
const icon = <NativeTabs.Trigger.VectorIcon family={family} name={name} />;
|
|
33
|
+
if (!selected) return icon;
|
|
34
|
+
return { default: icon, selected: <NativeTabs.Trigger.VectorIcon family={family} name={selected} /> };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Returns the Icon element itself (not a wrapper component): NativeTabs reads its
|
|
39
|
+
* children by type, so the Icon must be a direct child of the Trigger.
|
|
40
|
+
*/
|
|
41
|
+
function nativeIcon(tab: TabItem, tint: IconProps['selectedColor']) {
|
|
42
|
+
// SF Symbol on iOS when set; otherwise the image / vector icon (always on Android).
|
|
43
|
+
if (Platform.OS === 'ios' && tab.sf) {
|
|
44
|
+
return <NativeTabs.Trigger.Icon {...({ sf: tab.sf, selectedColor: tint } as IconProps)} />;
|
|
45
|
+
}
|
|
46
|
+
const src = tab.src ?? vectorSrc(tab);
|
|
47
|
+
if (!src) return null;
|
|
48
|
+
return <NativeTabs.Trigger.Icon {...({ src, selectedColor: tint } as IconProps)} />;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function NativeTabLayout() {
|
|
52
|
+
const tint = dynamicColor(TAB_BAR.colors.tint);
|
|
53
|
+
const inactive = dynamicColor(TAB_BAR.colors.inactive);
|
|
54
|
+
const { fontSize, fontWeight, selectedFontWeight } = TAB_BAR.labelStyle;
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<NativeTabs
|
|
58
|
+
tintColor={tint}
|
|
59
|
+
iconColor={inactive}
|
|
60
|
+
labelVisibilityMode={TAB_BAR.labelVisibilityMode}
|
|
61
|
+
minimizeBehavior={TAB_BAR.minimizeBehavior}
|
|
62
|
+
labelStyle={{ color: inactive, fontSize, fontWeight }}
|
|
63
|
+
{...(TAB_BAR.backgroundColor != null ? { backgroundColor: TAB_BAR.backgroundColor } : null)}
|
|
64
|
+
>
|
|
65
|
+
{TABS.map((tab) => (
|
|
66
|
+
<NativeTabs.Trigger key={tab.name} name={tab.name} hidden={tab.hidden}>
|
|
67
|
+
{nativeIcon(tab, tint)}
|
|
68
|
+
<NativeTabs.Trigger.Label
|
|
69
|
+
hidden={tab.hiddenLabel}
|
|
70
|
+
selectedStyle={{ color: tint, fontSize, fontWeight: selectedFontWeight }}
|
|
71
|
+
>
|
|
72
|
+
{tab.title}
|
|
73
|
+
</NativeTabs.Trigger.Label>
|
|
74
|
+
{tab.badge ? <NativeTabs.Trigger.Badge>{tab.badge}</NativeTabs.Trigger.Badge> : null}
|
|
75
|
+
</NativeTabs.Trigger>
|
|
76
|
+
))}
|
|
77
|
+
</NativeTabs>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function GlassTabLayout() {
|
|
82
|
+
return (
|
|
83
|
+
<Tabs
|
|
84
|
+
tabBar={(props) => <GlassTabBar {...props} />}
|
|
85
|
+
screenOptions={{ headerShown: false, animation: 'fade' }}
|
|
86
|
+
>
|
|
87
|
+
{TABS.map((tab) => (
|
|
88
|
+
<Tabs.Screen
|
|
89
|
+
key={tab.name}
|
|
90
|
+
name={tab.name}
|
|
91
|
+
options={{ title: tab.title, href: tab.hidden ? null : undefined }}
|
|
92
|
+
/>
|
|
93
|
+
))}
|
|
94
|
+
</Tabs>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The whole tab bar, driven by lib/tabs.ts: system NativeTabs with real Liquid Glass on
|
|
100
|
+
* iOS 26+, the floating glass fallback everywhere else. Render it from the layout that owns
|
|
101
|
+
* your tab routes — app/(tabs)/_layout.tsx — after any redirects:
|
|
102
|
+
*
|
|
103
|
+
* export default function TabLayout() {
|
|
104
|
+
* if (!signedIn) return <Redirect href="/sign-in" />;
|
|
105
|
+
* return <LiquidGlassTabs />;
|
|
106
|
+
* }
|
|
107
|
+
*/
|
|
108
|
+
export function LiquidGlassTabs() {
|
|
109
|
+
return USE_NATIVE_TABS ? <NativeTabLayout /> : <GlassTabLayout />;
|
|
110
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import {
|
|
3
|
+
Pressable,
|
|
3
4
|
ScrollView,
|
|
4
5
|
StyleSheet,
|
|
5
6
|
Text,
|
|
@@ -15,13 +16,17 @@ interface TabScreenProps {
|
|
|
15
16
|
title: string;
|
|
16
17
|
description: string;
|
|
17
18
|
accent: string;
|
|
19
|
+
/** Label of a button pinned to the bottom of the screen, above the tab bar. */
|
|
20
|
+
action?: string;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
|
-
export function TabScreen({ title, description, accent }: TabScreenProps) {
|
|
23
|
+
export function TabScreen({ title, description, accent, action }: TabScreenProps) {
|
|
21
24
|
const isDark = useColorScheme() === 'dark';
|
|
22
25
|
const insets = useSafeAreaInsets();
|
|
23
26
|
const onScroll = useTabBarScroll();
|
|
24
|
-
const bottomInset = useTabBarInset();
|
|
27
|
+
const bottomInset = useTabBarInset(24);
|
|
28
|
+
// Bottom-anchored UI sits on top of the bar's footprint, not on the screen edge.
|
|
29
|
+
const actionBottom = useTabBarInset(12);
|
|
25
30
|
const bg = isDark ? '#0f0f1a' : '#f2f2f7';
|
|
26
31
|
const text = isDark ? '#fff' : '#1c1c1e';
|
|
27
32
|
const sub = isDark ? '#9898a8' : '#6c6c70';
|
|
@@ -32,11 +37,11 @@ export function TabScreen({ title, description, accent }: TabScreenProps) {
|
|
|
32
37
|
style={{ flex: 1, backgroundColor: bg }}
|
|
33
38
|
contentContainerStyle={[
|
|
34
39
|
styles.scroll,
|
|
35
|
-
{ paddingTop:
|
|
40
|
+
{ paddingTop: insets.top + 12, paddingBottom: bottomInset },
|
|
36
41
|
]}
|
|
37
42
|
showsVerticalScrollIndicator={false}
|
|
38
|
-
//
|
|
39
|
-
contentInsetAdjustmentBehavior=
|
|
43
|
+
// Pad by hand on every bar: useTabBarInset() already counts the floating bar and home indicator.
|
|
44
|
+
contentInsetAdjustmentBehavior="never"
|
|
40
45
|
scrollEventThrottle={16}
|
|
41
46
|
onScroll={onScroll}
|
|
42
47
|
>
|
|
@@ -64,12 +69,21 @@ export function TabScreen({ title, description, accent }: TabScreenProps) {
|
|
|
64
69
|
</Text>
|
|
65
70
|
</View>
|
|
66
71
|
))}
|
|
72
|
+
{action ? <View style={{ height: 64 }} /> : null}
|
|
67
73
|
</ScrollView>
|
|
74
|
+
{action ? (
|
|
75
|
+
<Pressable
|
|
76
|
+
accessibilityRole="button"
|
|
77
|
+
style={({ pressed }) => [styles.action, { bottom: actionBottom, backgroundColor: accent, opacity: pressed ? 0.85 : 1 }]}
|
|
78
|
+
>
|
|
79
|
+
<Text style={styles.actionText}>{action}</Text>
|
|
80
|
+
</Pressable>
|
|
81
|
+
) : null}
|
|
68
82
|
</View>
|
|
69
83
|
);
|
|
70
84
|
}
|
|
71
85
|
|
|
72
|
-
export function TabScreenFromConfig({ tabName }: { tabName: string }) {
|
|
86
|
+
export function TabScreenFromConfig({ tabName, action }: { tabName: string; action?: string }) {
|
|
73
87
|
const tab = getTab(tabName);
|
|
74
88
|
if (!tab) {
|
|
75
89
|
return null;
|
|
@@ -83,6 +97,7 @@ export function TabScreenFromConfig({ tabName }: { tabName: string }) {
|
|
|
83
97
|
'Scroll to test native tab bar minimize behavior on iOS 26+.'
|
|
84
98
|
}
|
|
85
99
|
accent={tab.accent ?? '#6366f1'}
|
|
100
|
+
action={action}
|
|
86
101
|
/>
|
|
87
102
|
);
|
|
88
103
|
}
|
|
@@ -119,4 +134,14 @@ const styles = StyleSheet.create({
|
|
|
119
134
|
},
|
|
120
135
|
cardTitle: { fontSize: 17, fontWeight: '400' },
|
|
121
136
|
cardSub: { fontSize: 14, marginTop: 4, lineHeight: 20 },
|
|
137
|
+
action: {
|
|
138
|
+
position: 'absolute',
|
|
139
|
+
left: 20,
|
|
140
|
+
right: 20,
|
|
141
|
+
height: 50,
|
|
142
|
+
borderRadius: 25,
|
|
143
|
+
alignItems: 'center',
|
|
144
|
+
justifyContent: 'center',
|
|
145
|
+
},
|
|
146
|
+
actionText: { color: '#fff', fontSize: 16, fontWeight: '500' },
|
|
122
147
|
});
|
|
@@ -53,9 +53,17 @@ export function useTabBarScroll() {
|
|
|
53
53
|
}, []);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
/**
|
|
57
|
-
|
|
56
|
+
/**
|
|
57
|
+
* How far the tab bar covers the bottom of a screen, plus `gap`. Both bars float over the
|
|
58
|
+
* content — iOS 26's Liquid Glass bar and the glass fallback — so use it for anything that
|
|
59
|
+
* reaches the bottom edge: a ScrollView's paddingBottom (with
|
|
60
|
+
* contentInsetAdjustmentBehavior="never"), a bottom-anchored button's `bottom`, or the
|
|
61
|
+
* paddingBottom of a full-screen layout. Android's system bar (TAB_BAR.legacy = 'native')
|
|
62
|
+
* sits below the content, so there it is just `gap`.
|
|
63
|
+
*/
|
|
64
|
+
export function useTabBarInset(gap = 8) {
|
|
58
65
|
const insets = useSafeAreaInsets();
|
|
59
|
-
if (USE_NATIVE_TABS) return
|
|
60
|
-
|
|
66
|
+
if (USE_NATIVE_TABS && Platform.OS === 'android') return gap;
|
|
67
|
+
// The system bar has no height API; it sits in the same spot as the fallback pill.
|
|
68
|
+
return GLASS_BAR_HEIGHT + Math.max(insets.bottom, 12) + gap;
|
|
61
69
|
}
|
|
@@ -19,7 +19,8 @@ import type { ColorValue, ImageSourcePropType } from 'react-native';
|
|
|
19
19
|
* Preset sets (no import needed): Ionicons, Feather, FontAwesome, …
|
|
20
20
|
* Custom font: iconFont() / icoMoon()
|
|
21
21
|
* Custom module: library + icon on TabItem
|
|
22
|
-
* PNG: src on TabItem
|
|
22
|
+
* PNG: src on TabItem — SVG kits (Hugeicons, Lucide, Tabler) → PNGs with
|
|
23
|
+
* npx @expokit/cli@latest icons --from hugeicons --names Home01Icon --out assets/tab-icons
|
|
23
24
|
*/
|
|
24
25
|
|
|
25
26
|
export type IconSetName =
|
|
@@ -85,8 +86,10 @@ export function asFamily(mod: unknown): VectorIconFamily {
|
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
throw new Error(
|
|
88
|
-
'[expo-kit] This icon package has no getImageSource
|
|
89
|
-
'
|
|
89
|
+
'[expo-kit] This icon package has no getImageSource — SVG kits (Hugeicons, Lucide, Tabler) can not be drawn by the native tab bar.\n' +
|
|
90
|
+
'Render them to PNGs and use `src` on the tab:\n' +
|
|
91
|
+
' npx @expokit/cli@latest icons --from hugeicons --names Home01Icon,Search01Icon --out assets/tab-icons\n' +
|
|
92
|
+
'Or use an icon.set preset, iconFont() or icoMoon().'
|
|
90
93
|
);
|
|
91
94
|
}
|
|
92
95
|
|
|
@@ -14,12 +14,14 @@ export type { IconSetName, TabIconPreset } from '@/lib/tab-icons';
|
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Edit this file only — tabs, labels, icons, colors, and screen copy.
|
|
17
|
-
* Each tab needs a route file: src/app/(tabs)/{name}.tsx
|
|
17
|
+
* Each tab needs a route file: src/app/(tabs)/{name}.tsx — `npx @expokit/cli@latest update`
|
|
18
|
+
* creates a missing one.
|
|
18
19
|
*
|
|
19
20
|
* Icons (pick one per tab):
|
|
20
21
|
* icon: { set: 'Ionicons', name: 'home-outline', selected: 'home' }
|
|
21
22
|
* library + iconName (any package with getImageSource)
|
|
22
|
-
* src: require('../assets/icon.png')
|
|
23
|
+
* src: require('../assets/icon.png') — or { default, selected }. SVG kits (Hugeicons, Lucide,
|
|
24
|
+
* Tabler): npx @expokit/cli@latest icons --from hugeicons --names Home01Icon --out assets/tab-icons
|
|
23
25
|
*/
|
|
24
26
|
|
|
25
27
|
export interface TabItem {
|