react-native-magic-tab-bar 1.0.1 → 2.0.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.
- package/README.md +245 -46
- package/lib/module/MagicTabBar.js +50 -7
- package/lib/module/MagicTabBar.js.map +1 -1
- package/lib/module/MagicTabItem.js +256 -23
- package/lib/module/MagicTabItem.js.map +1 -1
- package/lib/module/MagicTabs.js +107 -15
- package/lib/module/MagicTabs.js.map +1 -1
- package/lib/module/defaultTabs.js +5 -4
- package/lib/module/defaultTabs.js.map +1 -1
- package/lib/module/index.js +6 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/theme.js +10 -1
- package/lib/module/theme.js.map +1 -1
- package/lib/typescript/MagicTabBar.d.ts +18 -1
- package/lib/typescript/MagicTabBar.d.ts.map +1 -1
- package/lib/typescript/MagicTabItem.d.ts +30 -4
- package/lib/typescript/MagicTabItem.d.ts.map +1 -1
- package/lib/typescript/MagicTabs.d.ts +43 -6
- package/lib/typescript/MagicTabs.d.ts.map +1 -1
- package/lib/typescript/defaultTabs.d.ts +5 -4
- package/lib/typescript/defaultTabs.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +1 -2
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/theme.d.ts.map +1 -1
- package/lib/typescript/types.d.ts +60 -0
- package/lib/typescript/types.d.ts.map +1 -1
- package/package.json +14 -4
- package/src/MagicTabBar.tsx +81 -7
- package/src/MagicTabItem.tsx +338 -19
- package/src/MagicTabs.tsx +185 -13
- package/src/defaultTabs.tsx +5 -4
- package/src/index.tsx +10 -1
- package/src/theme.ts +5 -0
- package/src/types.ts +64 -0
package/src/MagicTabItem.tsx
CHANGED
|
@@ -1,32 +1,92 @@
|
|
|
1
|
-
import { forwardRef, useEffect, type ReactNode } from 'react';
|
|
1
|
+
import { forwardRef, memo, useCallback, useEffect, useMemo, type ReactNode } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
Pressable,
|
|
4
4
|
StyleSheet,
|
|
5
|
+
Text,
|
|
6
|
+
View,
|
|
5
7
|
type GestureResponderEvent,
|
|
6
8
|
type View as RNView,
|
|
7
9
|
} from 'react-native';
|
|
8
10
|
import Animated, {
|
|
9
11
|
FadeIn,
|
|
12
|
+
FadeOut,
|
|
10
13
|
LinearTransition,
|
|
11
14
|
useAnimatedStyle,
|
|
12
15
|
useSharedValue,
|
|
13
16
|
withSpring,
|
|
14
17
|
} from 'react-native-reanimated';
|
|
15
|
-
import type {
|
|
18
|
+
import type {
|
|
19
|
+
MagicLabelMode,
|
|
20
|
+
MagicLabelPosition,
|
|
21
|
+
MagicTabBarTheme,
|
|
22
|
+
MagicTabIconProps,
|
|
23
|
+
MagicTabPressHandler,
|
|
24
|
+
} from './types';
|
|
25
|
+
|
|
26
|
+
declare const require: (moduleName: string) => unknown;
|
|
27
|
+
|
|
28
|
+
/** Minimal shape we use from the optional `expo-haptics` module. */
|
|
29
|
+
type HapticsModule = { selectionAsync?: () => void };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `expo-haptics` is an optional peer dependency. We load it through a guarded
|
|
33
|
+
* `require` so the library still installs and runs without it — when it's
|
|
34
|
+
* absent, the `haptics` prop simply has no effect.
|
|
35
|
+
*/
|
|
36
|
+
const expoHaptics = (() => {
|
|
37
|
+
try {
|
|
38
|
+
return require('expo-haptics') as HapticsModule;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
})();
|
|
16
43
|
|
|
17
44
|
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
|
|
18
45
|
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
46
|
+
/** Fixed small icon size used by the compact "light" tab bar. */
|
|
47
|
+
const LIGHT_ICON_SIZE = 20;
|
|
48
|
+
|
|
49
|
+
/** Whether a `badge` value should render anything at all. */
|
|
50
|
+
function hasBadge(badge: MagicTabItemProps['badge']): boolean {
|
|
51
|
+
return badge === true || (typeof badge === 'number' && badge > 0) || (typeof badge === 'string' && badge.length > 0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Formats a numeric/string badge; numbers above 99 collapse to `99+`. */
|
|
55
|
+
function formatBadge(badge: number | string): string {
|
|
56
|
+
return typeof badge === 'number' && badge > 99 ? '99+' : String(badge);
|
|
57
|
+
}
|
|
24
58
|
|
|
25
59
|
export interface MagicTabItemProps {
|
|
26
60
|
/** Renders the icon. Provided automatically by `MagicTabs`. */
|
|
27
61
|
icon: (props: MagicTabIconProps) => ReactNode;
|
|
28
|
-
/** Optional label
|
|
62
|
+
/** Optional label text. */
|
|
29
63
|
label?: string;
|
|
64
|
+
/** When the label is shown: `'active'` (default), `'always'` or `'never'`. */
|
|
65
|
+
labelMode?: MagicLabelMode;
|
|
66
|
+
/** Whether the label sits to the right of the icon (default) or below it. */
|
|
67
|
+
labelPosition?: MagicLabelPosition;
|
|
68
|
+
/**
|
|
69
|
+
* Badge shown over the icon. `true` renders a dot; a number/string renders
|
|
70
|
+
* a count bubble (numbers above 99 show as `99+`). Falsy renders nothing.
|
|
71
|
+
*/
|
|
72
|
+
badge?: number | string | boolean;
|
|
73
|
+
/** Dim the tab and block presses. */
|
|
74
|
+
disabled?: boolean;
|
|
75
|
+
/** Render as a raised, circular action ("FAB") button. */
|
|
76
|
+
variant?: 'action';
|
|
77
|
+
/**
|
|
78
|
+
* Compact "light" mode: small icon-only tab, no label. Provided by
|
|
79
|
+
* `MagicTabs`. Uses its own dedicated styles.
|
|
80
|
+
*/
|
|
81
|
+
isLight?: boolean;
|
|
82
|
+
/** Fire a selection haptic on press. Requires `expo-haptics`. */
|
|
83
|
+
haptics?: boolean;
|
|
84
|
+
/** Route name, used for the press callbacks. Provided by `MagicTabs`. */
|
|
85
|
+
name?: string;
|
|
86
|
+
/** Called when the tab is pressed. */
|
|
87
|
+
onTabPress?: MagicTabPressHandler;
|
|
88
|
+
/** Called when the tab is long-pressed. */
|
|
89
|
+
onTabLongPress?: MagicTabPressHandler;
|
|
30
90
|
/** Resolved theme. Provided automatically by `MagicTabs`. */
|
|
31
91
|
theme: MagicTabBarTheme;
|
|
32
92
|
|
|
@@ -48,32 +108,225 @@ export interface MagicTabItemProps {
|
|
|
48
108
|
* Each tab sizes to its content — just the icon when inactive, icon + label
|
|
49
109
|
* when active — so the active label is never clipped, on any screen width.
|
|
50
110
|
*/
|
|
51
|
-
export const MagicTabItem = forwardRef<RNView, MagicTabItemProps>(
|
|
52
|
-
function MagicTabItem(
|
|
111
|
+
export const MagicTabItem = memo(forwardRef<RNView, MagicTabItemProps>(
|
|
112
|
+
function MagicTabItem(
|
|
113
|
+
{
|
|
114
|
+
icon,
|
|
115
|
+
label,
|
|
116
|
+
labelMode = 'active',
|
|
117
|
+
labelPosition = 'right',
|
|
118
|
+
badge,
|
|
119
|
+
disabled = false,
|
|
120
|
+
variant,
|
|
121
|
+
isLight = false,
|
|
122
|
+
haptics = false,
|
|
123
|
+
name,
|
|
124
|
+
onTabPress,
|
|
125
|
+
onTabLongPress,
|
|
126
|
+
theme,
|
|
127
|
+
isFocused,
|
|
128
|
+
onPress,
|
|
129
|
+
onLongPress,
|
|
130
|
+
},
|
|
131
|
+
ref,
|
|
132
|
+
) {
|
|
53
133
|
const focused = Boolean(isFocused);
|
|
134
|
+
const { spring } = theme;
|
|
54
135
|
const progress = useSharedValue(focused ? 1 : 0);
|
|
55
136
|
|
|
56
137
|
useEffect(() => {
|
|
57
|
-
progress.value = withSpring(focused ? 1 : 0,
|
|
58
|
-
}, [focused, progress]);
|
|
138
|
+
progress.value = withSpring(focused ? 1 : 0, spring);
|
|
139
|
+
}, [focused, progress, spring]);
|
|
140
|
+
|
|
141
|
+
// Rebuilt only when the theme's spring changes, so consumers can tune the
|
|
142
|
+
// layout animation's feel via `theme.spring`.
|
|
143
|
+
const transition = useMemo(
|
|
144
|
+
() =>
|
|
145
|
+
LinearTransition.springify()
|
|
146
|
+
.mass(spring.mass)
|
|
147
|
+
.damping(spring.damping)
|
|
148
|
+
.stiffness(spring.stiffness),
|
|
149
|
+
[spring.mass, spring.damping, spring.stiffness],
|
|
150
|
+
);
|
|
59
151
|
|
|
60
152
|
const pillStyle = useAnimatedStyle(() => ({
|
|
61
153
|
opacity: progress.value,
|
|
62
154
|
transform: [{ scale: 0.8 + progress.value * 0.2 }],
|
|
63
155
|
}));
|
|
64
156
|
|
|
157
|
+
const handlePress = useCallback(
|
|
158
|
+
(event: GestureResponderEvent) => {
|
|
159
|
+
if (haptics) expoHaptics?.selectionAsync?.();
|
|
160
|
+
onTabPress?.(name ?? '', focused);
|
|
161
|
+
onPress?.(event);
|
|
162
|
+
},
|
|
163
|
+
[haptics, onTabPress, name, focused, onPress],
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const handleLongPress = useCallback(
|
|
167
|
+
(event: GestureResponderEvent) => {
|
|
168
|
+
onTabLongPress?.(name ?? '', focused);
|
|
169
|
+
onLongPress?.(event);
|
|
170
|
+
},
|
|
171
|
+
[onTabLongPress, name, focused, onLongPress],
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const badgeVisible = hasBadge(badge);
|
|
175
|
+
const badgeIsDot = badge === true;
|
|
176
|
+
|
|
177
|
+
const iconWithBadge = (iconColor: string, size: number) => (
|
|
178
|
+
<View>
|
|
179
|
+
{icon({ focused, color: iconColor, size })}
|
|
180
|
+
{badgeVisible ? (
|
|
181
|
+
<View
|
|
182
|
+
pointerEvents="none"
|
|
183
|
+
style={[
|
|
184
|
+
styles.badge,
|
|
185
|
+
badgeIsDot && styles.badgeDot,
|
|
186
|
+
{ backgroundColor: theme.badgeColor },
|
|
187
|
+
]}
|
|
188
|
+
>
|
|
189
|
+
{badgeIsDot ? null : (
|
|
190
|
+
<Text
|
|
191
|
+
numberOfLines={1}
|
|
192
|
+
style={[styles.badgeText, { color: theme.badgeTextColor }]}
|
|
193
|
+
>
|
|
194
|
+
{formatBadge(badge as number | string)}
|
|
195
|
+
</Text>
|
|
196
|
+
)}
|
|
197
|
+
</View>
|
|
198
|
+
) : null}
|
|
199
|
+
</View>
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
// ---- Raised circular action ("FAB") tab ----
|
|
203
|
+
if (variant === 'action') {
|
|
204
|
+
const size = theme.height - 6;
|
|
205
|
+
return (
|
|
206
|
+
<AnimatedPressable
|
|
207
|
+
ref={ref}
|
|
208
|
+
onPress={handlePress}
|
|
209
|
+
onLongPress={handleLongPress}
|
|
210
|
+
disabled={disabled}
|
|
211
|
+
accessibilityRole="button"
|
|
212
|
+
accessibilityState={{ selected: focused, disabled }}
|
|
213
|
+
style={[
|
|
214
|
+
styles.action,
|
|
215
|
+
{
|
|
216
|
+
width: size,
|
|
217
|
+
height: size,
|
|
218
|
+
borderRadius: size / 2,
|
|
219
|
+
backgroundColor: theme.actionColor,
|
|
220
|
+
},
|
|
221
|
+
disabled && styles.disabled,
|
|
222
|
+
]}
|
|
223
|
+
>
|
|
224
|
+
{iconWithBadge(theme.actionIconColor, theme.iconSize + 2)}
|
|
225
|
+
</AnimatedPressable>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---- Normal tab ----
|
|
65
230
|
const color = focused ? theme.activeColor : theme.inactiveColor;
|
|
66
|
-
const showLabel = focused && !!label;
|
|
67
231
|
|
|
232
|
+
// ---- Compact "light" tab: small icon only, no label ----
|
|
233
|
+
if (isLight) {
|
|
234
|
+
return (
|
|
235
|
+
<AnimatedPressable
|
|
236
|
+
ref={ref}
|
|
237
|
+
onPress={handlePress}
|
|
238
|
+
onLongPress={handleLongPress}
|
|
239
|
+
disabled={disabled}
|
|
240
|
+
accessibilityRole="tab"
|
|
241
|
+
accessibilityState={{ selected: focused, disabled }}
|
|
242
|
+
layout={transition}
|
|
243
|
+
style={[styles.itemLight, disabled && styles.disabled]}
|
|
244
|
+
>
|
|
245
|
+
<Animated.View
|
|
246
|
+
pointerEvents="none"
|
|
247
|
+
style={[
|
|
248
|
+
StyleSheet.absoluteFill,
|
|
249
|
+
{ backgroundColor: theme.activePillColor, borderRadius: theme.radius },
|
|
250
|
+
pillStyle,
|
|
251
|
+
]}
|
|
252
|
+
/>
|
|
253
|
+
{iconWithBadge(color, LIGHT_ICON_SIZE)}
|
|
254
|
+
</AnimatedPressable>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const showLabel =
|
|
259
|
+
!!label && labelMode !== 'never' && (labelMode === 'always' || focused);
|
|
260
|
+
|
|
261
|
+
// ---- Bottom labels: Material Design 3 layout ----
|
|
262
|
+
// The active indicator ("pill") wraps only the icon as a capsule; the label
|
|
263
|
+
// sits below it. Following M3's `LABEL_VISIBILITY_SELECTED` behaviour, each
|
|
264
|
+
// item is centered as a group: unlabeled tabs are just a centered icon, and
|
|
265
|
+
// the active tab grows to icon + label (nudging its icon up a touch). The
|
|
266
|
+
// layout transition animates that shift smoothly. Indicator sizing follows
|
|
267
|
+
// M3's 64x32-over-24dp ratio, scaled to the theme's icon size.
|
|
268
|
+
if (labelPosition === 'bottom') {
|
|
269
|
+
const indicatorHeight = theme.iconSize + 10;
|
|
270
|
+
const indicatorWidth = theme.iconSize + 32;
|
|
271
|
+
return (
|
|
272
|
+
<AnimatedPressable
|
|
273
|
+
ref={ref}
|
|
274
|
+
onPress={handlePress}
|
|
275
|
+
onLongPress={handleLongPress}
|
|
276
|
+
disabled={disabled}
|
|
277
|
+
accessibilityRole="tab"
|
|
278
|
+
accessibilityState={{ selected: focused, disabled }}
|
|
279
|
+
layout={transition}
|
|
280
|
+
style={[styles.itemBottom, disabled && styles.disabled]}
|
|
281
|
+
>
|
|
282
|
+
<View
|
|
283
|
+
style={[
|
|
284
|
+
styles.indicatorWrap,
|
|
285
|
+
{ width: indicatorWidth, height: indicatorHeight },
|
|
286
|
+
]}
|
|
287
|
+
>
|
|
288
|
+
<Animated.View
|
|
289
|
+
pointerEvents="none"
|
|
290
|
+
style={[
|
|
291
|
+
StyleSheet.absoluteFill,
|
|
292
|
+
{
|
|
293
|
+
backgroundColor: theme.activePillColor,
|
|
294
|
+
borderRadius: indicatorHeight / 2,
|
|
295
|
+
},
|
|
296
|
+
pillStyle,
|
|
297
|
+
]}
|
|
298
|
+
/>
|
|
299
|
+
{iconWithBadge(color, theme.iconSize)}
|
|
300
|
+
</View>
|
|
301
|
+
{showLabel ? (
|
|
302
|
+
<Animated.Text
|
|
303
|
+
entering={FadeIn.duration(150)}
|
|
304
|
+
exiting={FadeOut.duration(120)}
|
|
305
|
+
numberOfLines={1}
|
|
306
|
+
style={[styles.labelBottom, { color, fontSize: theme.fontSize - 1 }]}
|
|
307
|
+
>
|
|
308
|
+
{label}
|
|
309
|
+
</Animated.Text>
|
|
310
|
+
) : null}
|
|
311
|
+
</AnimatedPressable>
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---- Right labels: floating pill wrapping icon + label ----
|
|
68
316
|
return (
|
|
69
317
|
<AnimatedPressable
|
|
70
318
|
ref={ref}
|
|
71
|
-
onPress={
|
|
72
|
-
onLongPress={
|
|
319
|
+
onPress={handlePress}
|
|
320
|
+
onLongPress={handleLongPress}
|
|
321
|
+
disabled={disabled}
|
|
73
322
|
accessibilityRole="tab"
|
|
74
|
-
accessibilityState={{ selected: focused }}
|
|
323
|
+
accessibilityState={{ selected: focused, disabled }}
|
|
75
324
|
layout={transition}
|
|
76
|
-
style={[
|
|
325
|
+
style={[
|
|
326
|
+
styles.pressable,
|
|
327
|
+
focused && styles.pressableActive,
|
|
328
|
+
disabled && styles.disabled,
|
|
329
|
+
]}
|
|
77
330
|
>
|
|
78
331
|
<Animated.View
|
|
79
332
|
pointerEvents="none"
|
|
@@ -83,10 +336,11 @@ export const MagicTabItem = forwardRef<RNView, MagicTabItemProps>(
|
|
|
83
336
|
pillStyle,
|
|
84
337
|
]}
|
|
85
338
|
/>
|
|
86
|
-
{
|
|
339
|
+
{iconWithBadge(color, theme.iconSize)}
|
|
87
340
|
{showLabel ? (
|
|
88
341
|
<Animated.Text
|
|
89
342
|
entering={FadeIn.duration(150)}
|
|
343
|
+
exiting={FadeOut.duration(120)}
|
|
90
344
|
numberOfLines={1}
|
|
91
345
|
style={[styles.label, { color, fontSize: theme.fontSize }]}
|
|
92
346
|
>
|
|
@@ -96,7 +350,7 @@ export const MagicTabItem = forwardRef<RNView, MagicTabItemProps>(
|
|
|
96
350
|
</AnimatedPressable>
|
|
97
351
|
);
|
|
98
352
|
}
|
|
99
|
-
);
|
|
353
|
+
));
|
|
100
354
|
|
|
101
355
|
const styles = StyleSheet.create({
|
|
102
356
|
pressable: {
|
|
@@ -120,4 +374,69 @@ const styles = StyleSheet.create({
|
|
|
120
374
|
fontWeight: '600',
|
|
121
375
|
flexShrink: 1,
|
|
122
376
|
},
|
|
377
|
+
// Material Design 3 bottom layout: icon (with capsule indicator) stacked
|
|
378
|
+
// over an always-below label.
|
|
379
|
+
itemBottom: {
|
|
380
|
+
alignSelf: 'center',
|
|
381
|
+
flexDirection: 'column',
|
|
382
|
+
alignItems: 'center',
|
|
383
|
+
justifyContent: 'center',
|
|
384
|
+
gap: 2,
|
|
385
|
+
paddingVertical: 3,
|
|
386
|
+
paddingHorizontal: 4,
|
|
387
|
+
},
|
|
388
|
+
indicatorWrap: {
|
|
389
|
+
alignItems: 'center',
|
|
390
|
+
justifyContent: 'center',
|
|
391
|
+
},
|
|
392
|
+
// Compact "light" mode: small icon-only tap target with its own tight padding.
|
|
393
|
+
itemLight: {
|
|
394
|
+
alignSelf: 'center',
|
|
395
|
+
alignItems: 'center',
|
|
396
|
+
justifyContent: 'center',
|
|
397
|
+
paddingVertical: 6,
|
|
398
|
+
paddingHorizontal: 14,
|
|
399
|
+
},
|
|
400
|
+
labelBottom: {
|
|
401
|
+
fontWeight: '600',
|
|
402
|
+
textAlign: 'center',
|
|
403
|
+
},
|
|
404
|
+
action: {
|
|
405
|
+
alignSelf: 'center',
|
|
406
|
+
alignItems: 'center',
|
|
407
|
+
justifyContent: 'center',
|
|
408
|
+
marginTop: -22,
|
|
409
|
+
shadowColor: '#000',
|
|
410
|
+
shadowOpacity: 0.3,
|
|
411
|
+
shadowRadius: 8,
|
|
412
|
+
shadowOffset: { width: 0, height: 4 },
|
|
413
|
+
elevation: 8,
|
|
414
|
+
},
|
|
415
|
+
disabled: {
|
|
416
|
+
opacity: 0.4,
|
|
417
|
+
},
|
|
418
|
+
badge: {
|
|
419
|
+
position: 'absolute',
|
|
420
|
+
top: -5,
|
|
421
|
+
left: '65%',
|
|
422
|
+
minWidth: 16,
|
|
423
|
+
height: 16,
|
|
424
|
+
paddingHorizontal: 4,
|
|
425
|
+
borderRadius: 8,
|
|
426
|
+
alignItems: 'center',
|
|
427
|
+
justifyContent: 'center',
|
|
428
|
+
},
|
|
429
|
+
badgeDot: {
|
|
430
|
+
top: -2,
|
|
431
|
+
minWidth: 8,
|
|
432
|
+
width: 8,
|
|
433
|
+
height: 8,
|
|
434
|
+
paddingHorizontal: 0,
|
|
435
|
+
borderRadius: 4,
|
|
436
|
+
},
|
|
437
|
+
badgeText: {
|
|
438
|
+
fontSize: 10,
|
|
439
|
+
fontWeight: '700',
|
|
440
|
+
textAlign: 'center',
|
|
441
|
+
},
|
|
123
442
|
});
|
package/src/MagicTabs.tsx
CHANGED
|
@@ -1,20 +1,131 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { useMemo, type ReactNode } from 'react';
|
|
2
2
|
import { Tabs, TabList, TabSlot, TabTrigger } from 'expo-router/ui';
|
|
3
|
+
import { usePathname } from 'expo-router';
|
|
3
4
|
import { MagicTabBar } from './MagicTabBar';
|
|
4
5
|
import { MagicTabItem } from './MagicTabItem';
|
|
5
|
-
import { defaultTabs } from './defaultTabs';
|
|
6
6
|
import { defaultTheme } from './theme';
|
|
7
|
-
import type {
|
|
7
|
+
import type { Href } from 'expo-router';
|
|
8
|
+
import type {
|
|
9
|
+
MagicLabelMode,
|
|
10
|
+
MagicLabelPosition,
|
|
11
|
+
MagicTabBarTheme,
|
|
12
|
+
MagicTabBarVariant,
|
|
13
|
+
MagicTabConfig,
|
|
14
|
+
MagicTabPressHandler,
|
|
15
|
+
} from './types';
|
|
16
|
+
|
|
17
|
+
declare const __DEV__: boolean;
|
|
18
|
+
|
|
19
|
+
/** Best-effort string path for an `Href` (covers string and `{ pathname }` forms). */
|
|
20
|
+
function hrefToPath(href: Href): string {
|
|
21
|
+
if (typeof href === 'string') return href;
|
|
22
|
+
const pathname = (href as { pathname?: string })?.pathname;
|
|
23
|
+
return typeof pathname === 'string' ? pathname : '';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Removes Expo Router group segments (`(group)`) from a path so hrefs match
|
|
28
|
+
* `usePathname()`. Groups are organizational and never appear in the URL, so
|
|
29
|
+
* `href: "/(home)/expenses"` must compare against a pathname of `/expenses`.
|
|
30
|
+
* Collapses any doubled or trailing slashes and falls back to `/` for root.
|
|
31
|
+
*/
|
|
32
|
+
function stripGroupSegments(path: string): string {
|
|
33
|
+
return (
|
|
34
|
+
path
|
|
35
|
+
.replace(/\/\([^/)]+\)/g, '')
|
|
36
|
+
.replace(/\/{2,}/g, '/')
|
|
37
|
+
.replace(/(.)\/$/, '$1') || '/'
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Finds the tab whose `href` best matches the current path, by longest prefix
|
|
43
|
+
* so nested routes (e.g. `/explore/details`) still resolve to their tab.
|
|
44
|
+
*/
|
|
45
|
+
function findActiveTab(
|
|
46
|
+
tabs: MagicTabConfig[],
|
|
47
|
+
pathname: string,
|
|
48
|
+
): MagicTabConfig | undefined {
|
|
49
|
+
let best: MagicTabConfig | undefined;
|
|
50
|
+
let bestLen = -1;
|
|
51
|
+
const currentPath = stripGroupSegments(pathname);
|
|
52
|
+
for (const tab of tabs) {
|
|
53
|
+
const rawPath = hrefToPath(tab.href);
|
|
54
|
+
if (!rawPath) continue;
|
|
55
|
+
const path = stripGroupSegments(rawPath);
|
|
56
|
+
const matches =
|
|
57
|
+
path === '/'
|
|
58
|
+
? currentPath === '/'
|
|
59
|
+
: currentPath === path || currentPath.startsWith(`${path}/`);
|
|
60
|
+
if (matches && path.length > bestLen) {
|
|
61
|
+
best = tab;
|
|
62
|
+
bestLen = path.length;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return best;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Normalizes the `showLabels` prop (boolean shorthand or explicit mode) and
|
|
70
|
+
* clamps it to what the current `labelPosition` can display well.
|
|
71
|
+
*
|
|
72
|
+
* `'always'` only works with `labelPosition="bottom"`: side-by-side, every tab
|
|
73
|
+
* would expand to its full label width and overflow the bar, so we downgrade
|
|
74
|
+
* it to `'active'` (and warn in dev).
|
|
75
|
+
*/
|
|
76
|
+
function resolveLabelMode(
|
|
77
|
+
showLabels: boolean | MagicLabelMode,
|
|
78
|
+
labelPosition: MagicLabelPosition,
|
|
79
|
+
): MagicLabelMode {
|
|
80
|
+
const mode: MagicLabelMode =
|
|
81
|
+
showLabels === true ? 'active' : showLabels === false ? 'never' : showLabels;
|
|
82
|
+
|
|
83
|
+
if (mode === 'always' && labelPosition !== 'bottom') {
|
|
84
|
+
if (__DEV__) {
|
|
85
|
+
console.warn(
|
|
86
|
+
'[MagicTabs] showLabels="always" requires labelPosition="bottom"; ' +
|
|
87
|
+
'falling back to "active". Set labelPosition="bottom" to keep every label visible.',
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return 'active';
|
|
91
|
+
}
|
|
92
|
+
return mode;
|
|
93
|
+
}
|
|
8
94
|
|
|
9
95
|
export interface MagicTabsProps {
|
|
10
96
|
/**
|
|
11
97
|
* The tabs to render, in order. Each entry maps a route to an icon + label.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
98
|
+
*
|
|
99
|
+
* Bring your own icons. For a ready-made demo set, import it explicitly:
|
|
100
|
+
* `import { defaultTabs } from 'react-native-magic-tab-bar/default-tabs'`.
|
|
14
101
|
*/
|
|
15
|
-
tabs
|
|
102
|
+
tabs: MagicTabConfig[];
|
|
16
103
|
/** Override any part of the default theme. */
|
|
17
104
|
theme?: Partial<MagicTabBarTheme>;
|
|
105
|
+
/**
|
|
106
|
+
* When to show tab labels:
|
|
107
|
+
* - `true` / `'active'` — only on the focused tab (default).
|
|
108
|
+
* - `'always'` — on every tab, all the time.
|
|
109
|
+
* - `false` / `'never'` — icon-only bar.
|
|
110
|
+
*
|
|
111
|
+
* A tab without a `label` never shows text. Individual tabs can override
|
|
112
|
+
* this via their `showLabel` config field.
|
|
113
|
+
*/
|
|
114
|
+
showLabels?: boolean | MagicLabelMode;
|
|
115
|
+
/** Place labels to the right of icons (default) or below them. */
|
|
116
|
+
labelPosition?: MagicLabelPosition;
|
|
117
|
+
/**
|
|
118
|
+
* Compact "light" mode. Off by default. When `true`, the bar is shorter,
|
|
119
|
+
* shows small icons only (labels hidden), and floats with extra bottom
|
|
120
|
+
* margin. Uses its own dedicated styles.
|
|
121
|
+
*/
|
|
122
|
+
isLight?: boolean;
|
|
123
|
+
/**
|
|
124
|
+
* Extra space between the bar and the bottom edge, in "light" mode only.
|
|
125
|
+
* Added on top of the safe-area inset. Ignored unless `isLight` is `true`.
|
|
126
|
+
* Defaults to 14.
|
|
127
|
+
*/
|
|
128
|
+
lightBottomMargin?: number;
|
|
18
129
|
/** Position the bar floating over content (default) or docked in-flow. */
|
|
19
130
|
variant?: MagicTabBarVariant;
|
|
20
131
|
/**
|
|
@@ -36,6 +147,18 @@ export interface MagicTabsProps {
|
|
|
36
147
|
glass?: boolean;
|
|
37
148
|
/** Render a custom background (e.g. a blur/glass view) behind the bar. */
|
|
38
149
|
renderBackground?: () => ReactNode;
|
|
150
|
+
/**
|
|
151
|
+
* Fire a selection haptic when a tab is pressed. Requires the optional
|
|
152
|
+
* `expo-haptics` package; without it this prop has no effect. Off by default.
|
|
153
|
+
*/
|
|
154
|
+
haptics?: boolean;
|
|
155
|
+
/**
|
|
156
|
+
* Called when any tab is pressed, with the tab's `name` and whether it was
|
|
157
|
+
* already focused — handy for "scroll to top" / "reset stack" on re-press.
|
|
158
|
+
*/
|
|
159
|
+
onTabPress?: MagicTabPressHandler;
|
|
160
|
+
/** Called when any tab is long-pressed. */
|
|
161
|
+
onTabLongPress?: MagicTabPressHandler;
|
|
39
162
|
}
|
|
40
163
|
|
|
41
164
|
/**
|
|
@@ -53,15 +176,34 @@ export interface MagicTabsProps {
|
|
|
53
176
|
* ```
|
|
54
177
|
*/
|
|
55
178
|
export function MagicTabs({
|
|
56
|
-
tabs
|
|
179
|
+
tabs,
|
|
57
180
|
theme: themeOverride,
|
|
181
|
+
showLabels = true,
|
|
182
|
+
labelPosition = 'right',
|
|
183
|
+
isLight = false,
|
|
184
|
+
lightBottomMargin,
|
|
58
185
|
variant,
|
|
59
186
|
isTransparent,
|
|
60
187
|
transparency,
|
|
61
188
|
glass,
|
|
62
189
|
renderBackground,
|
|
190
|
+
haptics,
|
|
191
|
+
onTabPress,
|
|
192
|
+
onTabLongPress,
|
|
63
193
|
}: MagicTabsProps) {
|
|
64
|
-
|
|
194
|
+
// Stable identity so it doesn't re-trigger memoized children every render.
|
|
195
|
+
const theme = useMemo<MagicTabBarTheme>(
|
|
196
|
+
() => ({ ...defaultTheme, ...themeOverride }),
|
|
197
|
+
[themeOverride],
|
|
198
|
+
);
|
|
199
|
+
const barLabelMode = resolveLabelMode(showLabels, labelPosition);
|
|
200
|
+
|
|
201
|
+
// The bar is "light" when either the whole bar is forced light (`isLight`)
|
|
202
|
+
// or the currently active tab opts in via its own `isLight` config. Changing
|
|
203
|
+
// routes flips this and the bar animates between the two layouts.
|
|
204
|
+
const pathname = usePathname();
|
|
205
|
+
const activeTab = findActiveTab(tabs, pathname);
|
|
206
|
+
const effectiveLight = isLight || !!activeTab?.isLight;
|
|
65
207
|
|
|
66
208
|
return (
|
|
67
209
|
<Tabs>
|
|
@@ -70,16 +212,46 @@ export function MagicTabs({
|
|
|
70
212
|
<MagicTabBar
|
|
71
213
|
theme={theme}
|
|
72
214
|
variant={variant}
|
|
215
|
+
labelPosition={labelPosition}
|
|
216
|
+
isLight={effectiveLight}
|
|
217
|
+
lightBottomMargin={lightBottomMargin}
|
|
73
218
|
isTransparent={isTransparent}
|
|
74
219
|
transparency={transparency}
|
|
75
220
|
glass={glass}
|
|
76
221
|
renderBackground={renderBackground}
|
|
77
222
|
>
|
|
78
|
-
{tabs.map((tab) =>
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
223
|
+
{tabs.map((tab) => {
|
|
224
|
+
// A tab's `showLabel` overrides the bar-level mode: `false` forces
|
|
225
|
+
// icon-only, `true` shows it (falling back to `'active'` when the
|
|
226
|
+
// bar itself is set to `'never'`).
|
|
227
|
+
const labelMode: MagicLabelMode =
|
|
228
|
+
tab.showLabel === undefined
|
|
229
|
+
? barLabelMode
|
|
230
|
+
: tab.showLabel
|
|
231
|
+
? barLabelMode === 'never'
|
|
232
|
+
? 'active'
|
|
233
|
+
: barLabelMode
|
|
234
|
+
: 'never';
|
|
235
|
+
return (
|
|
236
|
+
<TabTrigger key={tab.name} name={tab.name} href={tab.href} asChild>
|
|
237
|
+
<MagicTabItem
|
|
238
|
+
name={tab.name}
|
|
239
|
+
icon={tab.icon}
|
|
240
|
+
label={tab.label}
|
|
241
|
+
labelMode={labelMode}
|
|
242
|
+
labelPosition={labelPosition}
|
|
243
|
+
badge={tab.badge}
|
|
244
|
+
disabled={tab.disabled}
|
|
245
|
+
variant={tab.variant}
|
|
246
|
+
isLight={effectiveLight}
|
|
247
|
+
haptics={haptics}
|
|
248
|
+
onTabPress={onTabPress}
|
|
249
|
+
onTabLongPress={onTabLongPress}
|
|
250
|
+
theme={theme}
|
|
251
|
+
/>
|
|
252
|
+
</TabTrigger>
|
|
253
|
+
);
|
|
254
|
+
})}
|
|
83
255
|
</MagicTabBar>
|
|
84
256
|
</TabList>
|
|
85
257
|
</Tabs>
|
package/src/defaultTabs.tsx
CHANGED
|
@@ -15,11 +15,12 @@ const ionicon =
|
|
|
15
15
|
);
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
18
|
+
* A ready-made demo tab set — Home, Explore, Notifications, Inbox and Profile,
|
|
19
|
+
* each with a matching Ionicon. Opt in via the subpath:
|
|
20
|
+
* `import { defaultTabs } from 'react-native-magic-tab-bar/default-tabs'`.
|
|
20
21
|
*
|
|
21
|
-
*
|
|
22
|
-
* `inbox` and `profile`.
|
|
22
|
+
* Requires `@expo/vector-icons` and assumes the app has routes named `index`
|
|
23
|
+
* (`/`), `explore`, `notifications`, `inbox` and `profile`.
|
|
23
24
|
*/
|
|
24
25
|
export const defaultTabs: MagicTabConfig[] = [
|
|
25
26
|
{
|