@expokit/cli 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +46 -2
- package/bin/expo-kit.js +1266 -0
- package/bin/lib.mjs +343 -0
- package/bin/mcp.mjs +345 -0
- package/package.json +49 -4
- package/registry/_docs/accordion.json +115 -0
- package/registry/_docs/action-sheet.json +138 -0
- package/registry/_docs/alert.json +155 -0
- package/registry/_docs/avatar.json +227 -0
- package/registry/_docs/badge.json +148 -0
- package/registry/_docs/bottom-nav.json +143 -0
- package/registry/_docs/bottom-sheet.json +186 -0
- package/registry/_docs/bullet.json +27 -0
- package/registry/_docs/button-3d.json +138 -0
- package/registry/_docs/button.json +131 -0
- package/registry/_docs/calendar.json +198 -0
- package/registry/_docs/card-stack.json +124 -0
- package/registry/_docs/card.json +131 -0
- package/registry/_docs/carousel.json +122 -0
- package/registry/_docs/chat.json +184 -0
- package/registry/_docs/checkbox.json +109 -0
- package/registry/_docs/checkout.json +105 -0
- package/registry/_docs/chip-group.json +132 -0
- package/registry/_docs/color-swatch.json +82 -0
- package/registry/_docs/confetti.json +102 -0
- package/registry/_docs/coupon.json +192 -0
- package/registry/_docs/cube-pager.json +100 -0
- package/registry/_docs/dashboard.json +123 -0
- package/registry/_docs/date-picker.json +269 -0
- package/registry/_docs/dialog.json +186 -0
- package/registry/_docs/drawer.json +199 -0
- package/registry/_docs/fab.json +144 -0
- package/registry/_docs/flip-card.json +127 -0
- package/registry/_docs/floating-input.json +211 -0
- package/registry/_docs/gift-card.json +124 -0
- package/registry/_docs/glass-button.json +142 -0
- package/registry/_docs/glass-nav-bar.json +102 -0
- package/registry/_docs/glass-search-bar.json +114 -0
- package/registry/_docs/glass-segmented.json +94 -0
- package/registry/_docs/glass-sheet.json +136 -0
- package/registry/_docs/glass-tab-bar.json +100 -0
- package/registry/_docs/glass.json +94 -0
- package/registry/_docs/inbox.json +75 -0
- package/registry/_docs/isometric-bar.json +75 -0
- package/registry/_docs/kit.json +14 -0
- package/registry/_docs/kpi-card.json +14 -0
- package/registry/_docs/like-button.json +92 -0
- package/registry/_docs/liquidglass-tab.json +30 -0
- package/registry/_docs/list-item.json +216 -0
- package/registry/_docs/loyalty-card.json +124 -0
- package/registry/_docs/morph-button.json +128 -0
- package/registry/_docs/number-ticker.json +112 -0
- package/registry/_docs/offers.json +81 -0
- package/registry/_docs/onboarding.json +69 -0
- package/registry/_docs/otp-input.json +158 -0
- package/registry/_docs/pagination-dots.json +108 -0
- package/registry/_docs/parallax-layers.json +104 -0
- package/registry/_docs/parallax-scroll.json +210 -0
- package/registry/_docs/password-input.json +112 -0
- package/registry/_docs/payment-card.json +169 -0
- package/registry/_docs/pixel-bar.json +69 -0
- package/registry/_docs/polar-area.json +81 -0
- package/registry/_docs/profile.json +87 -0
- package/registry/_docs/progress-steps.json +116 -0
- package/registry/_docs/progress.json +152 -0
- package/registry/_docs/radio-group.json +112 -0
- package/registry/_docs/rating.json +114 -0
- package/registry/_docs/ring-carousel.json +122 -0
- package/registry/_docs/scratch-card.json +110 -0
- package/registry/_docs/search-bar.json +160 -0
- package/registry/_docs/search-results.json +123 -0
- package/registry/_docs/segmented-control.json +123 -0
- package/registry/_docs/select.json +277 -0
- package/registry/_docs/settings.json +75 -0
- package/registry/_docs/shimmer-button.json +98 -0
- package/registry/_docs/sign-in.json +201 -0
- package/registry/_docs/skeleton.json +162 -0
- package/registry/_docs/slide-to-confirm.json +108 -0
- package/registry/_docs/slider.json +177 -0
- package/registry/_docs/spin-wheel.json +138 -0
- package/registry/_docs/spinner.json +121 -0
- package/registry/_docs/stateful-button.json +184 -0
- package/registry/_docs/stepper.json +184 -0
- package/registry/_docs/swipe-deck.json +118 -0
- package/registry/_docs/swipeable-row.json +95 -0
- package/registry/_docs/switch.json +124 -0
- package/registry/_docs/tabs.json +129 -0
- package/registry/_docs/tag-input.json +148 -0
- package/registry/_docs/text-animation.json +141 -0
- package/registry/_docs/textarea.json +191 -0
- package/registry/_docs/ticket-card.json +194 -0
- package/registry/_docs/ticket-shape.json +14 -0
- package/registry/_docs/tilt-card.json +157 -0
- package/registry/_docs/time-picker.json +269 -0
- package/registry/_docs/timeline-chart.json +33 -0
- package/registry/_docs/timeline.json +172 -0
- package/registry/_docs/toast.json +185 -0
- package/registry/_docs/treemap.json +33 -0
- package/registry/_docs/waterfall.json +39 -0
- package/registry/_docs/wheel-list.json +122 -0
- package/registry/accordion/files/src/components/ui/accordion.tsx +233 -0
- package/registry/accordion/files/src/lib/kit.tsx +219 -0
- package/registry/accordion.json +17 -0
- package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +580 -0
- package/registry/action-sheet/files/src/lib/kit.tsx +219 -0
- package/registry/action-sheet.json +18 -0
- package/registry/alert/files/src/components/ui/alert.tsx +654 -0
- package/registry/alert/files/src/components/ui/button.tsx +274 -0
- package/registry/alert/files/src/lib/kit.tsx +219 -0
- package/registry/alert.json +20 -0
- package/registry/avatar/files/src/components/ui/avatar.tsx +790 -0
- package/registry/avatar/files/src/lib/kit.tsx +219 -0
- package/registry/avatar.json +18 -0
- package/registry/badge/files/src/components/ui/badge.tsx +626 -0
- package/registry/badge/files/src/lib/kit.tsx +219 -0
- package/registry/badge.json +17 -0
- package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +1050 -0
- package/registry/bottom-nav/files/src/lib/kit.tsx +219 -0
- package/registry/bottom-nav.json +18 -0
- package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +585 -0
- package/registry/bottom-sheet/files/src/lib/kit.tsx +219 -0
- package/registry/bottom-sheet.json +17 -0
- package/registry/bullet.json +14 -0
- package/registry/button/files/src/components/ui/button.tsx +274 -0
- package/registry/button/files/src/lib/kit.tsx +219 -0
- package/registry/button-3d/files/src/components/ui/button-3d.tsx +212 -0
- package/registry/button-3d/files/src/lib/kit.tsx +219 -0
- package/registry/button-3d.json +18 -0
- package/registry/button.json +18 -0
- package/registry/calendar/files/src/components/ui/calendar.tsx +822 -0
- package/registry/calendar/files/src/lib/kit.tsx +219 -0
- package/registry/calendar.json +17 -0
- package/registry/card/files/src/components/ui/card.tsx +300 -0
- package/registry/card/files/src/lib/kit.tsx +219 -0
- package/registry/card-stack.json +15 -0
- package/registry/card.json +17 -0
- package/registry/carousel.json +15 -0
- package/registry/chat.json +18 -0
- package/registry/checkbox/files/src/components/ui/checkbox.tsx +150 -0
- package/registry/checkbox/files/src/lib/kit.tsx +219 -0
- package/registry/checkbox.json +17 -0
- package/registry/checkout.json +26 -0
- package/registry/chip-group/files/src/components/ui/chip-group.tsx +269 -0
- package/registry/chip-group/files/src/lib/kit.tsx +219 -0
- package/registry/chip-group.json +17 -0
- package/registry/color-swatch.json +17 -0
- package/registry/confetti/files/src/components/ui/confetti.tsx +222 -0
- package/registry/confetti/files/src/lib/kit.tsx +219 -0
- package/registry/confetti.json +15 -0
- package/registry/coupon/files/src/components/ui/coupon.tsx +698 -0
- package/registry/coupon/files/src/components/ui/flip-card.tsx +205 -0
- package/registry/coupon/files/src/components/ui/tilt-card.tsx +635 -0
- package/registry/coupon/files/src/lib/kit.tsx +219 -0
- package/registry/coupon/files/src/lib/ticket-shape.tsx +543 -0
- package/registry/coupon.json +23 -0
- package/registry/cube-pager.json +15 -0
- package/registry/dashboard.json +32 -0
- package/registry/date-picker/files/src/components/ui/calendar.tsx +822 -0
- package/registry/date-picker/files/src/components/ui/date-picker.tsx +796 -0
- package/registry/date-picker/files/src/lib/kit.tsx +219 -0
- package/registry/date-picker.json +19 -0
- package/registry/dialog/files/src/components/ui/dialog.tsx +759 -0
- package/registry/dialog/files/src/lib/kit.tsx +219 -0
- package/registry/dialog.json +18 -0
- package/registry/drawer/files/src/components/ui/drawer.tsx +1135 -0
- package/registry/drawer/files/src/lib/kit.tsx +219 -0
- package/registry/drawer.json +18 -0
- package/registry/fab.json +18 -0
- package/registry/flip-card/files/src/components/ui/flip-card.tsx +205 -0
- package/registry/flip-card/files/src/lib/kit.tsx +219 -0
- package/registry/flip-card.json +15 -0
- package/registry/floating-input/files/src/components/ui/floating-input.tsx +528 -0
- package/registry/floating-input/files/src/lib/kit.tsx +219 -0
- package/registry/floating-input.json +17 -0
- package/registry/gift-card.json +21 -0
- package/registry/glass-button.json +22 -0
- package/registry/glass-nav-bar.json +21 -0
- package/registry/glass-search-bar.json +21 -0
- package/registry/glass-segmented.json +22 -0
- package/registry/glass-sheet.json +22 -0
- package/registry/glass-tab-bar.json +22 -0
- package/registry/glass.json +19 -0
- package/registry/inbox.json +30 -0
- package/registry/isometric-bar.json +14 -0
- package/registry/kit/files/src/lib/kit.tsx +219 -0
- package/registry/kit.json +11 -0
- package/registry/kpi-card/files/src/components/charts/kpi-card.tsx +45 -0
- package/registry/kpi-card.json +14 -0
- package/registry/like-button.json +18 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/_layout.tsx +97 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/alerts.tsx +5 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/explore.tsx +5 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/index.tsx +5 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/profile.tsx +5 -0
- package/registry/liquidglass-tab/files/src/app/_layout.tsx +33 -0
- package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +259 -0
- package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +122 -0
- package/registry/liquidglass-tab/files/src/lib/tab-bar.ts +61 -0
- package/registry/liquidglass-tab/files/src/lib/tab-icons.ts +130 -0
- package/registry/liquidglass-tab/files/src/lib/tabs.ts +108 -0
- package/registry/liquidglass-tab.json +23 -0
- package/registry/list-item/files/src/components/ui/list-item.tsx +508 -0
- package/registry/list-item/files/src/lib/kit.tsx +219 -0
- package/registry/list-item.json +17 -0
- package/registry/loyalty-card.json +23 -0
- package/registry/morph-button.json +18 -0
- package/registry/number-ticker.json +15 -0
- package/registry/offers.json +24 -0
- package/registry/onboarding.json +17 -0
- package/registry/otp-input/files/src/components/ui/otp-input.tsx +380 -0
- package/registry/otp-input/files/src/lib/kit.tsx +219 -0
- package/registry/otp-input.json +17 -0
- package/registry/pagination-dots/files/src/components/ui/pagination-dots.tsx +262 -0
- package/registry/pagination-dots/files/src/lib/kit.tsx +219 -0
- package/registry/pagination-dots.json +15 -0
- package/registry/parallax-layers.json +15 -0
- package/registry/parallax-scroll.json +17 -0
- package/registry/password-input/files/src/components/ui/floating-input.tsx +528 -0
- package/registry/password-input/files/src/components/ui/password-input.tsx +271 -0
- package/registry/password-input/files/src/lib/kit.tsx +219 -0
- package/registry/password-input.json +19 -0
- package/registry/payment-card/files/src/components/ui/flip-card.tsx +205 -0
- package/registry/payment-card/files/src/components/ui/payment-card.tsx +385 -0
- package/registry/payment-card/files/src/components/ui/tilt-card.tsx +635 -0
- package/registry/payment-card/files/src/lib/kit.tsx +219 -0
- package/registry/payment-card.json +21 -0
- package/registry/pixel-bar.json +14 -0
- package/registry/polar-area.json +14 -0
- package/registry/profile.json +17 -0
- package/registry/progress/files/src/components/ui/progress.tsx +883 -0
- package/registry/progress/files/src/lib/kit.tsx +219 -0
- package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +559 -0
- package/registry/progress-steps/files/src/lib/kit.tsx +219 -0
- package/registry/progress-steps.json +18 -0
- package/registry/progress.json +17 -0
- package/registry/radio-group/files/src/components/ui/radio-group.tsx +327 -0
- package/registry/radio-group/files/src/lib/kit.tsx +219 -0
- package/registry/radio-group.json +18 -0
- package/registry/rating.json +18 -0
- package/registry/ring-carousel.json +15 -0
- package/registry/scratch-card.json +18 -0
- package/registry/search-bar/files/src/components/ui/search-bar.tsx +530 -0
- package/registry/search-bar/files/src/lib/kit.tsx +219 -0
- package/registry/search-bar.json +17 -0
- package/registry/search-results.json +30 -0
- package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +341 -0
- package/registry/segmented-control/files/src/lib/kit.tsx +219 -0
- package/registry/segmented-control.json +18 -0
- package/registry/select/files/src/components/ui/select.tsx +1398 -0
- package/registry/select/files/src/lib/kit.tsx +219 -0
- package/registry/select.json +17 -0
- package/registry/settings.json +26 -0
- package/registry/shimmer-button.json +17 -0
- package/registry/sign-in.json +26 -0
- package/registry/skeleton/files/src/components/ui/skeleton.tsx +799 -0
- package/registry/skeleton/files/src/lib/kit.tsx +219 -0
- package/registry/skeleton.json +17 -0
- package/registry/slide-to-confirm.json +18 -0
- package/registry/slider/files/src/components/ui/slider.tsx +716 -0
- package/registry/slider/files/src/lib/kit.tsx +219 -0
- package/registry/slider.json +17 -0
- package/registry/spin-wheel.json +18 -0
- package/registry/spinner/files/src/components/ui/spinner.tsx +748 -0
- package/registry/spinner/files/src/lib/kit.tsx +219 -0
- package/registry/spinner.json +17 -0
- 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/stepper/files/src/components/ui/stepper.tsx +646 -0
- package/registry/stepper/files/src/lib/kit.tsx +219 -0
- package/registry/stepper.json +17 -0
- package/registry/swipe-deck.json +15 -0
- package/registry/swipeable-row.json +15 -0
- package/registry/switch/files/src/components/ui/switch.tsx +344 -0
- package/registry/switch/files/src/lib/kit.tsx +219 -0
- package/registry/switch.json +18 -0
- package/registry/tabs/files/src/components/ui/tabs.tsx +539 -0
- package/registry/tabs/files/src/lib/kit.tsx +219 -0
- package/registry/tabs.json +17 -0
- package/registry/tag-input/files/src/components/ui/tag-input.tsx +720 -0
- package/registry/tag-input/files/src/lib/kit.tsx +219 -0
- package/registry/tag-input.json +17 -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
- package/registry/textarea/files/src/components/ui/textarea.tsx +553 -0
- package/registry/textarea/files/src/lib/kit.tsx +219 -0
- package/registry/textarea.json +17 -0
- package/registry/ticket-card.json +24 -0
- package/registry/ticket-shape/files/src/lib/kit.tsx +219 -0
- package/registry/ticket-shape/files/src/lib/ticket-shape.tsx +543 -0
- package/registry/ticket-shape.json +17 -0
- package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +635 -0
- package/registry/tilt-card/files/src/lib/kit.tsx +219 -0
- package/registry/tilt-card.json +17 -0
- package/registry/time-picker/files/src/components/ui/time-picker.tsx +1159 -0
- package/registry/time-picker/files/src/lib/kit.tsx +219 -0
- package/registry/time-picker.json +17 -0
- package/registry/timeline/files/src/components/ui/avatar.tsx +790 -0
- package/registry/timeline/files/src/components/ui/timeline.tsx +1059 -0
- package/registry/timeline/files/src/lib/kit.tsx +219 -0
- package/registry/timeline-chart.json +14 -0
- package/registry/timeline.json +20 -0
- package/registry/toast/files/src/components/ui/button.tsx +274 -0
- package/registry/toast/files/src/components/ui/toast.tsx +1284 -0
- package/registry/toast/files/src/lib/kit.tsx +219 -0
- package/registry/toast.json +20 -0
- package/registry/treemap.json +14 -0
- package/registry/waterfall.json +14 -0
- package/registry/wheel-list.json +17 -0
- package/schema/components.schema.json +13 -0
|
@@ -0,0 +1,1135 @@
|
|
|
1
|
+
import * as Haptics from 'expo-haptics';
|
|
2
|
+
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
AccessibilityInfo,
|
|
5
|
+
Animated,
|
|
6
|
+
BackHandler,
|
|
7
|
+
Easing,
|
|
8
|
+
Image,
|
|
9
|
+
Modal,
|
|
10
|
+
PanResponder,
|
|
11
|
+
Platform,
|
|
12
|
+
Pressable,
|
|
13
|
+
ScrollView,
|
|
14
|
+
StatusBar,
|
|
15
|
+
StyleSheet,
|
|
16
|
+
Text,
|
|
17
|
+
View,
|
|
18
|
+
useWindowDimensions,
|
|
19
|
+
type LayoutChangeEvent,
|
|
20
|
+
type PanResponderGestureState,
|
|
21
|
+
type StyleProp,
|
|
22
|
+
type ViewStyle,
|
|
23
|
+
} from 'react-native';
|
|
24
|
+
import Svg, { Path } from 'react-native-svg';
|
|
25
|
+
|
|
26
|
+
import { alpha, motion, useKit } from '@/lib/kit';
|
|
27
|
+
|
|
28
|
+
export type DrawerSide = 'left' | 'right';
|
|
29
|
+
/**
|
|
30
|
+
* `overlay` — the panel slides over the screen, which dims (and gently scales with `scaleContent`).
|
|
31
|
+
* `push` — the screen becomes a rounded card that shrinks to 0.92 and slides aside, revealing the
|
|
32
|
+
* panel underneath (the modern mail-app look). Needs `children`; without them it renders as overlay.
|
|
33
|
+
*/
|
|
34
|
+
export type DrawerVariant = 'overlay' | 'push';
|
|
35
|
+
|
|
36
|
+
const INLINE_TOP = Platform.select({ ios: 14, android: 10, default: 8 }) ?? 8;
|
|
37
|
+
const INLINE_BOTTOM = Platform.select({ ios: 18, android: 12, default: 10 }) ?? 10;
|
|
38
|
+
const MODAL_TOP = Platform.select({ ios: 52, android: (StatusBar.currentHeight ?? 24) + 8, default: 16 }) ?? 16;
|
|
39
|
+
const MODAL_BOTTOM = Platform.select({ ios: 28, android: 16, default: 12 }) ?? 12;
|
|
40
|
+
|
|
41
|
+
/** Panel surface hidden past the screen edge, so overshoot and rubber-banding never show a gap. */
|
|
42
|
+
const EXTRA = 48;
|
|
43
|
+
/** Extra travel when closed so the panel's shadow is off-screen too. */
|
|
44
|
+
const SHADOW_PAD = 28;
|
|
45
|
+
const PUSH_SCALE = 0.92;
|
|
46
|
+
const PUSH_GAP = 14;
|
|
47
|
+
/** Kit snappy with a touch more damping: the panel lands with a hint of overshoot, not a wobble. */
|
|
48
|
+
const OPEN_SPRING = { ...motion.snappy, damping: 22 };
|
|
49
|
+
/** Closing is clamped, so it ends on the first crossing — exits feel faster than entrances. */
|
|
50
|
+
const CLOSE_SPRING = { ...motion.snappy, overshootClamping: true };
|
|
51
|
+
|
|
52
|
+
interface DrawerContextValue {
|
|
53
|
+
open: boolean;
|
|
54
|
+
setOpen: (open: boolean) => void;
|
|
55
|
+
toggle: () => void;
|
|
56
|
+
close: () => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const DrawerContext = createContext<DrawerContextValue | null>(null);
|
|
60
|
+
/** The colour behind the panel content, so status-dot rings blend in. */
|
|
61
|
+
const SurfaceContext = createContext<string | null>(null);
|
|
62
|
+
|
|
63
|
+
/** Open / close the nearest Drawer from anywhere inside it (header buttons, DrawerItems…). */
|
|
64
|
+
export function useDrawer(): DrawerContextValue {
|
|
65
|
+
const ctx = useContext(DrawerContext);
|
|
66
|
+
return ctx ?? { open: false, setOpen: () => {}, toggle: () => {}, close: () => {} };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface DrawerProps {
|
|
70
|
+
open?: boolean;
|
|
71
|
+
defaultOpen?: boolean;
|
|
72
|
+
onOpenChange?: (open: boolean) => void;
|
|
73
|
+
/** The panel's content (usually DrawerItems). Scrolls between `header` and `footer`. */
|
|
74
|
+
content: React.ReactNode;
|
|
75
|
+
/** Pinned above the scrolling content (DrawerProfile or DrawerHeader). */
|
|
76
|
+
header?: React.ReactNode;
|
|
77
|
+
/** Pinned below the scrolling content (DrawerFooter, a DrawerItem or buttons). */
|
|
78
|
+
footer?: React.ReactNode;
|
|
79
|
+
/**
|
|
80
|
+
* The screen the drawer belongs to. When given, the drawer lives inside this container (no
|
|
81
|
+
* Modal): edge swipe and the 'push' variant work. Without children it opens in a Modal.
|
|
82
|
+
*/
|
|
83
|
+
children?: React.ReactNode;
|
|
84
|
+
side?: DrawerSide;
|
|
85
|
+
/** Points or a percentage of the container width (default '80%', max 360). */
|
|
86
|
+
width?: number | `${number}%`;
|
|
87
|
+
/** 'overlay' slides over the screen · 'push' shrinks the screen into a card and slides it aside. */
|
|
88
|
+
variant?: DrawerVariant;
|
|
89
|
+
/** Swipe in from the screen edge to open (needs `children`, default true). */
|
|
90
|
+
edgeSwipe?: boolean;
|
|
91
|
+
/** Width of the edge-swipe zone (default 24). Taps in it still reach your screen. */
|
|
92
|
+
edgeWidth?: number;
|
|
93
|
+
/** Close on scrim tap and drag (default true). */
|
|
94
|
+
dismissible?: boolean;
|
|
95
|
+
/** Overlay variant: scale the screen behind to 0.96 with rounded corners while open (inline only, default true). */
|
|
96
|
+
scaleContent?: boolean;
|
|
97
|
+
/** Light haptic when the drawer opens; DrawerItems tick on press (default true). */
|
|
98
|
+
haptics?: boolean;
|
|
99
|
+
/** Space above the header (default: a small inline gap, or the status bar height in Modal mode). */
|
|
100
|
+
topInset?: number;
|
|
101
|
+
/** Space below the footer, e.g. useSafeAreaInsets().bottom (default: platform estimate). */
|
|
102
|
+
bottomInset?: number;
|
|
103
|
+
accessibilityLabel?: string;
|
|
104
|
+
/** Root container style (inline mode). */
|
|
105
|
+
style?: StyleProp<ViewStyle>;
|
|
106
|
+
/** Panel style. */
|
|
107
|
+
panelStyle?: StyleProp<ViewStyle>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A side navigation drawer that follows your finger. It springs in with the scrim fading
|
|
112
|
+
* behind it, swipes open from the screen edge, drags closed from anywhere on the panel, settles
|
|
113
|
+
* with your release velocity and rubber-bands past fully open. The 'push' variant shrinks the
|
|
114
|
+
* screen into a rounded card and slides it aside, like modern mail apps.
|
|
115
|
+
*/
|
|
116
|
+
export function Drawer({
|
|
117
|
+
open,
|
|
118
|
+
defaultOpen = false,
|
|
119
|
+
onOpenChange,
|
|
120
|
+
content,
|
|
121
|
+
header,
|
|
122
|
+
footer,
|
|
123
|
+
children,
|
|
124
|
+
side = 'left',
|
|
125
|
+
width = '80%',
|
|
126
|
+
variant = 'overlay',
|
|
127
|
+
edgeSwipe = true,
|
|
128
|
+
edgeWidth = 24,
|
|
129
|
+
dismissible = true,
|
|
130
|
+
scaleContent = true,
|
|
131
|
+
haptics = true,
|
|
132
|
+
topInset,
|
|
133
|
+
bottomInset,
|
|
134
|
+
accessibilityLabel = 'Navigation drawer',
|
|
135
|
+
style,
|
|
136
|
+
panelStyle,
|
|
137
|
+
}: DrawerProps) {
|
|
138
|
+
const { colors, dark, radius } = useKit();
|
|
139
|
+
const reduceMotion = useReduceMotion();
|
|
140
|
+
const win = useWindowDimensions();
|
|
141
|
+
const inline = children !== undefined;
|
|
142
|
+
const push = inline && variant === 'push';
|
|
143
|
+
const [box, setBox] = useState(inline ? 0 : win.width);
|
|
144
|
+
const containerW = inline ? box : win.width;
|
|
145
|
+
const W = Math.max(
|
|
146
|
+
0,
|
|
147
|
+
Math.min(typeof width === 'number' ? width : (parseFloat(width) / 100) * containerW, 360, containerW - 40),
|
|
148
|
+
);
|
|
149
|
+
const dir = side === 'left' ? -1 : 1; // closed offset direction
|
|
150
|
+
const top = topInset ?? (inline ? INLINE_TOP : MODAL_TOP);
|
|
151
|
+
const bottom = bottomInset ?? (inline ? INLINE_BOTTOM : MODAL_BOTTOM);
|
|
152
|
+
// The screen moves (push, or the overlay scale) → its corners round in on the JS driver.
|
|
153
|
+
const movesScreen = inline && (push || (scaleContent && !reduceMotion));
|
|
154
|
+
|
|
155
|
+
const [inner, setInner] = useState(defaultOpen);
|
|
156
|
+
const isOpen = open ?? inner;
|
|
157
|
+
const [mounted, setMounted] = useState(isOpen); // Modal mode only
|
|
158
|
+
// Progress 0 (closed) → 1 (open), native-driven; every transform and opacity reads from it.
|
|
159
|
+
const p = useRef(new Animated.Value(isOpen ? 1 : 0)).current;
|
|
160
|
+
// Same progress on the JS driver, for the screen's corner radius and edge colour (separate view).
|
|
161
|
+
const r = useRef(new Animated.Value(isOpen ? 1 : 0)).current;
|
|
162
|
+
|
|
163
|
+
const live = useRef({ open: isOpen, w: W, box, reduce: reduceMotion, haptics, movesScreen, edgeSwipe, edgeWidth, dismissible });
|
|
164
|
+
live.current = { open: isOpen, w: W, box, reduce: reduceMotion, haptics, movesScreen, edgeSwipe, edgeWidth, dismissible };
|
|
165
|
+
|
|
166
|
+
const setOpen = useCallback(
|
|
167
|
+
(next: boolean) => {
|
|
168
|
+
if (open === undefined) setInner(next);
|
|
169
|
+
onOpenChange?.(next);
|
|
170
|
+
},
|
|
171
|
+
[open, onOpenChange],
|
|
172
|
+
);
|
|
173
|
+
const setOpenRef = useRef(setOpen);
|
|
174
|
+
setOpenRef.current = setOpen;
|
|
175
|
+
|
|
176
|
+
const target = useRef<0 | 1>(isOpen ? 1 : 0);
|
|
177
|
+
const settle = useCallback(
|
|
178
|
+
(to: 0 | 1, velocity = 0) => {
|
|
179
|
+
const was = target.current;
|
|
180
|
+
target.current = to;
|
|
181
|
+
if (to === 1 && !inline) setMounted(true);
|
|
182
|
+
if (to === 1 && was === 0 && live.current.haptics) {
|
|
183
|
+
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
|
|
184
|
+
}
|
|
185
|
+
const done = ({ finished }: { finished: boolean }) => {
|
|
186
|
+
if (finished && to === 0 && !inline) setMounted(false);
|
|
187
|
+
};
|
|
188
|
+
if (live.current.reduce) {
|
|
189
|
+
const cfg = { toValue: to, duration: 200, easing: Easing.out(Easing.cubic) };
|
|
190
|
+
Animated.timing(p, { ...cfg, useNativeDriver: true }).start(done);
|
|
191
|
+
if (live.current.movesScreen) Animated.timing(r, { ...cfg, useNativeDriver: false }).start();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const cfg = { toValue: to, velocity, ...(to === 1 ? OPEN_SPRING : CLOSE_SPRING) };
|
|
195
|
+
Animated.spring(p, { ...cfg, useNativeDriver: true }).start(done);
|
|
196
|
+
if (live.current.movesScreen) Animated.spring(r, { ...cfg, useNativeDriver: false }).start();
|
|
197
|
+
},
|
|
198
|
+
[p, r, inline],
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
if (isOpen && !inline) setMounted(true);
|
|
203
|
+
// Skip when a drag release already started a velocity spring towards this state.
|
|
204
|
+
if (target.current !== (isOpen ? 1 : 0)) settle(isOpen ? 1 : 0);
|
|
205
|
+
}, [isOpen, settle, inline]);
|
|
206
|
+
|
|
207
|
+
// Android back (inline mode; the Modal handles it otherwise) and Escape on web.
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
if (!isOpen) return;
|
|
210
|
+
const sub = inline && Platform.OS !== 'web'
|
|
211
|
+
? BackHandler.addEventListener('hardwareBackPress', () => {
|
|
212
|
+
setOpenRef.current(false);
|
|
213
|
+
return true;
|
|
214
|
+
})
|
|
215
|
+
: null;
|
|
216
|
+
let off: (() => void) | undefined;
|
|
217
|
+
if (Platform.OS === 'web' && typeof document !== 'undefined') {
|
|
218
|
+
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpenRef.current(false);
|
|
219
|
+
document.addEventListener('keydown', onKey);
|
|
220
|
+
off = () => document.removeEventListener('keydown', onKey);
|
|
221
|
+
}
|
|
222
|
+
return () => {
|
|
223
|
+
sub?.remove();
|
|
224
|
+
off?.();
|
|
225
|
+
};
|
|
226
|
+
}, [isOpen, inline]);
|
|
227
|
+
|
|
228
|
+
// Root position on the page, for the edge-swipe zone (gesture coordinates are page-based).
|
|
229
|
+
const rootRef = useRef<View>(null);
|
|
230
|
+
const rootX = useRef(0);
|
|
231
|
+
const measure = useCallback(() => {
|
|
232
|
+
rootRef.current?.measure((_x, _y, _w, _h, pageX) => {
|
|
233
|
+
if (typeof pageX === 'number') rootX.current = pageX;
|
|
234
|
+
});
|
|
235
|
+
}, []);
|
|
236
|
+
|
|
237
|
+
// Two responders sharing one drag: `edge` captures a horizontal swipe that starts in the edge
|
|
238
|
+
// zone (so taps there still reach the screen), `drag` captures horizontal drags on the open
|
|
239
|
+
// panel / scrim (so vertical scrolling and taps inside the panel keep working).
|
|
240
|
+
const start = useRef(0);
|
|
241
|
+
const pans = useMemo(() => {
|
|
242
|
+
const toward = (dx: number) => (side === 'left' ? dx : -dx); // + means "more open"
|
|
243
|
+
const horizontal = (g: PanResponderGestureState) => Math.abs(g.dx) > 10 && Math.abs(g.dx) > Math.abs(g.dy) * 1.5;
|
|
244
|
+
const base = {
|
|
245
|
+
onPanResponderTerminationRequest: () => false,
|
|
246
|
+
onPanResponderGrant: () => {
|
|
247
|
+
p.stopAnimation((v) => (start.current = v));
|
|
248
|
+
r.stopAnimation();
|
|
249
|
+
if (!live.current.open && !inline) setMounted(true);
|
|
250
|
+
},
|
|
251
|
+
onPanResponderMove: (_: unknown, g: PanResponderGestureState) => {
|
|
252
|
+
const w = live.current.w || 1;
|
|
253
|
+
let next = start.current + toward(g.dx) / w;
|
|
254
|
+
if (next > 1) next = 1 + rubber((next - 1) * w, EXTRA) / w;
|
|
255
|
+
if (next < 0) next = 0;
|
|
256
|
+
p.setValue(next);
|
|
257
|
+
if (live.current.movesScreen) r.setValue(Math.min(1, next));
|
|
258
|
+
},
|
|
259
|
+
onPanResponderRelease: (_: unknown, g: PanResponderGestureState) => {
|
|
260
|
+
const w = live.current.w || 1;
|
|
261
|
+
const v = toward(g.vx); // px/ms, + means opening
|
|
262
|
+
const projected = start.current + toward(g.dx) / w + (v * 200) / w;
|
|
263
|
+
let to: 0 | 1 = projected > 0.5 ? 1 : 0;
|
|
264
|
+
if (Math.abs(v) > 0.35) to = v > 0 ? 1 : 0; // a flick decides on its own
|
|
265
|
+
settle(to, (v * 1000) / w);
|
|
266
|
+
if ((to === 1) !== live.current.open) setOpenRef.current(to === 1);
|
|
267
|
+
},
|
|
268
|
+
onPanResponderTerminate: () => settle(live.current.open ? 1 : 0),
|
|
269
|
+
};
|
|
270
|
+
const edge = PanResponder.create({
|
|
271
|
+
...base,
|
|
272
|
+
onMoveShouldSetPanResponderCapture: (_, g) => {
|
|
273
|
+
const l = live.current;
|
|
274
|
+
if (!l.edgeSwipe || l.open || !horizontal(g) || toward(g.dx) <= 0) return false;
|
|
275
|
+
const x = g.x0 - rootX.current;
|
|
276
|
+
return side === 'left' ? x <= l.edgeWidth : x >= l.box - l.edgeWidth;
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
const drag = PanResponder.create({
|
|
280
|
+
...base,
|
|
281
|
+
onMoveShouldSetPanResponderCapture: (_, g) => live.current.dismissible && live.current.open && horizontal(g),
|
|
282
|
+
});
|
|
283
|
+
return { edge, drag };
|
|
284
|
+
}, [p, r, side, settle, inline]);
|
|
285
|
+
|
|
286
|
+
const ctx = useMemo(
|
|
287
|
+
() => ({ open: isOpen, setOpen, toggle: () => setOpen(!isOpen), close: () => setOpen(false) }),
|
|
288
|
+
[isOpen, setOpen],
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
/* Interpolations (all native) ---------------------------------------------------------------- */
|
|
292
|
+
const panelX = push
|
|
293
|
+
? p.interpolate({ inputRange: [0, 1], outputRange: [dir * W * 0.28, 0], extrapolate: 'clamp' })
|
|
294
|
+
: p.interpolate({ inputRange: [0, 1, 2], outputRange: [dir * (W + EXTRA + SHADOW_PAD), 0, -dir * W], extrapolate: 'clamp' });
|
|
295
|
+
const panelOpacity = push ? p.interpolate({ inputRange: [0, 0.7, 1], outputRange: [0, 0.9, 1], extrapolate: 'clamp' }) : 1;
|
|
296
|
+
const scrimOpacity = p.interpolate({ inputRange: [0, 1], outputRange: [0, push ? 0.5 : 1], extrapolate: 'clamp' });
|
|
297
|
+
const S = reduceMotion ? 1 : PUSH_SCALE;
|
|
298
|
+
const shift = W + PUSH_GAP - ((1 - S) * containerW) / 2;
|
|
299
|
+
const screenTransform = push
|
|
300
|
+
? [
|
|
301
|
+
{ translateX: p.interpolate({ inputRange: [0, 1, 2], outputRange: [0, -dir * shift, -dir * (shift + W)], extrapolate: 'clamp' }) },
|
|
302
|
+
{ scale: p.interpolate({ inputRange: [0, 1], outputRange: [1, S], extrapolate: 'clamp' }) },
|
|
303
|
+
]
|
|
304
|
+
: movesScreen
|
|
305
|
+
? [{ scale: p.interpolate({ inputRange: [0, 1], outputRange: [1, 0.96], extrapolate: 'clamp' }) }]
|
|
306
|
+
: undefined;
|
|
307
|
+
// JS driver, on the inner screen view only.
|
|
308
|
+
const screenRadius = r.interpolate({ inputRange: [0, 1], outputRange: [0, radius.scale(push ? 28 : 20)], extrapolate: 'clamp' });
|
|
309
|
+
const screenEdge = r.interpolate({
|
|
310
|
+
inputRange: [0, 1],
|
|
311
|
+
outputRange: [alpha(colors.foreground, 0), alpha(colors.foreground, dark ? 0.12 : 0.08)],
|
|
312
|
+
extrapolate: 'clamp',
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
/* Panel ---------------------------------------------------------------------------------------- */
|
|
316
|
+
const surface = dark ? colors.popover : colors.card;
|
|
317
|
+
const rootBg = push ? (dark ? colors.card : colors.muted) : dark ? colors.background : colors.foreground;
|
|
318
|
+
const hairline = alpha(colors.foreground, dark ? 0.1 : 0.06);
|
|
319
|
+
const corner = push ? 0 : radius.scale(24);
|
|
320
|
+
const round =
|
|
321
|
+
side === 'left'
|
|
322
|
+
? { borderTopRightRadius: corner, borderBottomRightRadius: corner }
|
|
323
|
+
: { borderTopLeftRadius: corner, borderBottomLeftRadius: corner };
|
|
324
|
+
|
|
325
|
+
const panel = (
|
|
326
|
+
<Animated.View
|
|
327
|
+
{...(dismissible ? pans.drag.panHandlers : null)}
|
|
328
|
+
accessibilityViewIsModal={isOpen}
|
|
329
|
+
accessibilityLabel={accessibilityLabel}
|
|
330
|
+
importantForAccessibility={isOpen ? 'yes' : 'no-hide-descendants'}
|
|
331
|
+
pointerEvents={push && !isOpen ? 'none' : 'auto'}
|
|
332
|
+
style={[
|
|
333
|
+
styles.panel,
|
|
334
|
+
side === 'left' ? { left: -EXTRA } : { right: -EXTRA },
|
|
335
|
+
{ width: W + EXTRA, opacity: panelOpacity, transform: [{ translateX: panelX }] },
|
|
336
|
+
!push && {
|
|
337
|
+
shadowColor: '#000',
|
|
338
|
+
shadowOpacity: dark ? 0.45 : 0.12,
|
|
339
|
+
shadowRadius: 28,
|
|
340
|
+
shadowOffset: { width: -dir * 8, height: 0 },
|
|
341
|
+
elevation: 24,
|
|
342
|
+
},
|
|
343
|
+
panelStyle,
|
|
344
|
+
]}
|
|
345
|
+
>
|
|
346
|
+
<View
|
|
347
|
+
style={[
|
|
348
|
+
styles.flex,
|
|
349
|
+
round,
|
|
350
|
+
!push && {
|
|
351
|
+
backgroundColor: surface,
|
|
352
|
+
shadowColor: '#000',
|
|
353
|
+
shadowOpacity: dark ? 0.3 : 0.06,
|
|
354
|
+
shadowRadius: 4,
|
|
355
|
+
shadowOffset: { width: -dir * 1, height: 0 },
|
|
356
|
+
},
|
|
357
|
+
]}
|
|
358
|
+
>
|
|
359
|
+
<View
|
|
360
|
+
style={[
|
|
361
|
+
styles.flex,
|
|
362
|
+
styles.clip,
|
|
363
|
+
round,
|
|
364
|
+
!push && { borderColor: hairline },
|
|
365
|
+
!push && (side === 'left' ? styles.borderRight : styles.borderLeft),
|
|
366
|
+
]}
|
|
367
|
+
>
|
|
368
|
+
<SurfaceContext.Provider value={push ? rootBg : surface}>
|
|
369
|
+
<View style={[styles.flex, side === 'left' ? { paddingLeft: EXTRA } : { paddingRight: EXTRA }]}>
|
|
370
|
+
<View
|
|
371
|
+
style={[
|
|
372
|
+
styles.chromeTop,
|
|
373
|
+
{ paddingTop: top },
|
|
374
|
+
header ? [styles.chromeTopDivided, { borderBottomColor: hairline }] : null,
|
|
375
|
+
]}
|
|
376
|
+
>
|
|
377
|
+
{header ?? <DrawerHandle side={side} />}
|
|
378
|
+
</View>
|
|
379
|
+
<ScrollView
|
|
380
|
+
style={styles.flex}
|
|
381
|
+
contentContainerStyle={styles.content}
|
|
382
|
+
showsVerticalScrollIndicator={false}
|
|
383
|
+
keyboardShouldPersistTaps="handled"
|
|
384
|
+
>
|
|
385
|
+
{content}
|
|
386
|
+
</ScrollView>
|
|
387
|
+
{footer ? (
|
|
388
|
+
<View style={[styles.chromeBottom, { paddingBottom: bottom, borderTopColor: hairline }]}>{footer}</View>
|
|
389
|
+
) : (
|
|
390
|
+
<View style={{ height: bottom }} />
|
|
391
|
+
)}
|
|
392
|
+
</View>
|
|
393
|
+
</SurfaceContext.Provider>
|
|
394
|
+
</View>
|
|
395
|
+
</View>
|
|
396
|
+
</Animated.View>
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
const scrim = (
|
|
400
|
+
<Animated.View
|
|
401
|
+
{...(dismissible ? pans.drag.panHandlers : null)}
|
|
402
|
+
pointerEvents={isOpen ? 'auto' : 'none'}
|
|
403
|
+
style={[StyleSheet.absoluteFill, { backgroundColor: colors.overlay, opacity: scrimOpacity }]}
|
|
404
|
+
>
|
|
405
|
+
<Pressable
|
|
406
|
+
style={StyleSheet.absoluteFill}
|
|
407
|
+
onPress={() => dismissible && setOpen(false)}
|
|
408
|
+
accessibilityRole="button"
|
|
409
|
+
accessibilityLabel="Close drawer"
|
|
410
|
+
/>
|
|
411
|
+
</Animated.View>
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
if (!inline) {
|
|
415
|
+
return (
|
|
416
|
+
<DrawerContext.Provider value={ctx}>
|
|
417
|
+
<Modal visible={mounted} transparent animationType="none" statusBarTranslucent onRequestClose={() => setOpen(false)}>
|
|
418
|
+
{scrim}
|
|
419
|
+
{panel}
|
|
420
|
+
</Modal>
|
|
421
|
+
</DrawerContext.Provider>
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return (
|
|
426
|
+
<DrawerContext.Provider value={ctx}>
|
|
427
|
+
<View
|
|
428
|
+
ref={rootRef}
|
|
429
|
+
{...pans.edge.panHandlers}
|
|
430
|
+
onTouchStart={measure}
|
|
431
|
+
onLayout={(e: LayoutChangeEvent) => {
|
|
432
|
+
setBox(e.nativeEvent.layout.width);
|
|
433
|
+
measure();
|
|
434
|
+
}}
|
|
435
|
+
style={[styles.root, movesScreen && { backgroundColor: rootBg }, style]}
|
|
436
|
+
>
|
|
437
|
+
{push && box ? panel : null}
|
|
438
|
+
<Animated.View
|
|
439
|
+
importantForAccessibility={isOpen ? 'no-hide-descendants' : 'auto'}
|
|
440
|
+
style={[
|
|
441
|
+
StyleSheet.absoluteFill,
|
|
442
|
+
screenTransform && { transform: screenTransform },
|
|
443
|
+
push &&
|
|
444
|
+
Platform.OS === 'ios' && {
|
|
445
|
+
shadowColor: '#000',
|
|
446
|
+
shadowOpacity: dark ? 0.5 : 0.16,
|
|
447
|
+
shadowRadius: 24,
|
|
448
|
+
shadowOffset: { width: dir * 6, height: 8 },
|
|
449
|
+
},
|
|
450
|
+
]}
|
|
451
|
+
>
|
|
452
|
+
<Animated.View
|
|
453
|
+
style={[
|
|
454
|
+
styles.flex,
|
|
455
|
+
movesScreen && {
|
|
456
|
+
backgroundColor: colors.background,
|
|
457
|
+
borderRadius: screenRadius,
|
|
458
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
459
|
+
borderColor: screenEdge,
|
|
460
|
+
overflow: 'hidden',
|
|
461
|
+
elevation: push ? 16 : 0,
|
|
462
|
+
},
|
|
463
|
+
]}
|
|
464
|
+
>
|
|
465
|
+
{children}
|
|
466
|
+
{push ? scrim : null}
|
|
467
|
+
</Animated.View>
|
|
468
|
+
</Animated.View>
|
|
469
|
+
{push ? null : scrim}
|
|
470
|
+
{!push && box ? panel : null}
|
|
471
|
+
</View>
|
|
472
|
+
</DrawerContext.Provider>
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Top spacing used when you do not pass `header`. */
|
|
477
|
+
export function DrawerHandle(_: { side?: DrawerSide }) {
|
|
478
|
+
return <View style={styles.handleSpacer} />;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export interface DrawerHeaderProps {
|
|
482
|
+
title?: string;
|
|
483
|
+
subtitle?: string;
|
|
484
|
+
/** Optional leading node (avatar, logo). */
|
|
485
|
+
leading?: React.ReactNode;
|
|
486
|
+
/** Shows a close control on the trailing edge (default false). */
|
|
487
|
+
showClose?: boolean;
|
|
488
|
+
onClose?: () => void;
|
|
489
|
+
children?: React.ReactNode;
|
|
490
|
+
style?: StyleProp<ViewStyle>;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Title block with optional leading node and close button — drop into `header`. */
|
|
494
|
+
export function DrawerHeader({ title, subtitle, leading, showClose = false, onClose, children, style }: DrawerHeaderProps) {
|
|
495
|
+
const { colors, radius, dark } = useKit();
|
|
496
|
+
const drawerCtx = useContext(DrawerContext);
|
|
497
|
+
const close = onClose ?? (() => drawerCtx?.close());
|
|
498
|
+
const canClose = showClose || onClose !== undefined;
|
|
499
|
+
return (
|
|
500
|
+
<View style={[styles.header, style]}>
|
|
501
|
+
{leading ? <View>{leading}</View> : null}
|
|
502
|
+
<View style={styles.headerText}>
|
|
503
|
+
{title ? (
|
|
504
|
+
<Text accessibilityRole="header" style={[styles.headerTitle, { color: colors.foreground }]} numberOfLines={1}>
|
|
505
|
+
{title}
|
|
506
|
+
</Text>
|
|
507
|
+
) : null}
|
|
508
|
+
{subtitle ? (
|
|
509
|
+
<Text style={[styles.headerSub, { color: colors.mutedForeground }]} numberOfLines={1}>
|
|
510
|
+
{subtitle}
|
|
511
|
+
</Text>
|
|
512
|
+
) : null}
|
|
513
|
+
{children}
|
|
514
|
+
</View>
|
|
515
|
+
{canClose ? (
|
|
516
|
+
<Pressable
|
|
517
|
+
onPress={close}
|
|
518
|
+
accessibilityRole="button"
|
|
519
|
+
accessibilityLabel="Close menu"
|
|
520
|
+
hitSlop={8}
|
|
521
|
+
style={({ pressed }) => [
|
|
522
|
+
styles.closeBtn,
|
|
523
|
+
{
|
|
524
|
+
borderRadius: radius.capsule(34),
|
|
525
|
+
backgroundColor: alpha(colors.foreground, pressed ? (dark ? 0.16 : 0.1) : dark ? 0.08 : 0.05),
|
|
526
|
+
},
|
|
527
|
+
]}
|
|
528
|
+
>
|
|
529
|
+
<DrawerIcon name="x" size={16} color={colors.mutedForeground} />
|
|
530
|
+
</Pressable>
|
|
531
|
+
) : null}
|
|
532
|
+
</View>
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/* -------------------------------------------------------------------------------------------------
|
|
537
|
+
* DrawerProfile
|
|
538
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
539
|
+
|
|
540
|
+
export type DrawerPresence = 'online' | 'away' | 'busy' | 'offline';
|
|
541
|
+
|
|
542
|
+
export interface DrawerProfileProps {
|
|
543
|
+
name: string;
|
|
544
|
+
/** Email or any second line (plan, role, workspace URL). */
|
|
545
|
+
email?: string;
|
|
546
|
+
/** Avatar image URL. Without it, initials sit on a soft solid tint derived from the name. */
|
|
547
|
+
src?: string;
|
|
548
|
+
/** Presence dot on the avatar. */
|
|
549
|
+
status?: DrawerPresence;
|
|
550
|
+
/** 'row' — avatar beside the text · 'stacked' — large avatar above (Gmail-style). */
|
|
551
|
+
layout?: 'row' | 'stacked';
|
|
552
|
+
/** Makes the block a button (e.g. an account or workspace switcher). */
|
|
553
|
+
onPress?: () => void;
|
|
554
|
+
/** Trailing node. Defaults to an up/down chevron when `onPress` is set; `null` hides it. */
|
|
555
|
+
accessory?: React.ReactNode;
|
|
556
|
+
accessibilityHint?: string;
|
|
557
|
+
style?: StyleProp<ViewStyle>;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Account block for the drawer header: initials (or image) avatar with presence, name and email. */
|
|
561
|
+
export function DrawerProfile({
|
|
562
|
+
name,
|
|
563
|
+
email,
|
|
564
|
+
src,
|
|
565
|
+
status,
|
|
566
|
+
layout = 'row',
|
|
567
|
+
onPress,
|
|
568
|
+
accessory,
|
|
569
|
+
accessibilityHint,
|
|
570
|
+
style,
|
|
571
|
+
}: DrawerProfileProps) {
|
|
572
|
+
const { colors, radius, dark } = useKit();
|
|
573
|
+
const press = useRef(new Animated.Value(0)).current;
|
|
574
|
+
const stacked = layout === 'stacked';
|
|
575
|
+
const trailing =
|
|
576
|
+
accessory !== undefined ? (
|
|
577
|
+
accessory
|
|
578
|
+
) : onPress ? (
|
|
579
|
+
<View
|
|
580
|
+
style={[
|
|
581
|
+
styles.profileChevron,
|
|
582
|
+
{ borderRadius: radius.capsule(30), backgroundColor: alpha(colors.foreground, dark ? 0.08 : 0.05) },
|
|
583
|
+
]}
|
|
584
|
+
>
|
|
585
|
+
<DrawerIcon name="chevrons" size={16} color={colors.mutedForeground} />
|
|
586
|
+
</View>
|
|
587
|
+
) : null;
|
|
588
|
+
const text = (
|
|
589
|
+
<View style={styles.profileText}>
|
|
590
|
+
<Text numberOfLines={1} style={[styles.profileName, { color: colors.foreground }, stacked && styles.profileNameLg]}>
|
|
591
|
+
{name}
|
|
592
|
+
</Text>
|
|
593
|
+
{email ? (
|
|
594
|
+
<Text numberOfLines={1} style={[styles.profileEmail, { color: colors.mutedForeground }]}>
|
|
595
|
+
{email}
|
|
596
|
+
</Text>
|
|
597
|
+
) : null}
|
|
598
|
+
</View>
|
|
599
|
+
);
|
|
600
|
+
const spring = (to: number) => Animated.spring(press, { toValue: to, useNativeDriver: true, ...motion.bouncy }).start();
|
|
601
|
+
return (
|
|
602
|
+
<Animated.View style={[{ transform: [{ scale: press.interpolate({ inputRange: [0, 1], outputRange: [1, 0.98] }) }] }, style]}>
|
|
603
|
+
<Pressable
|
|
604
|
+
disabled={!onPress}
|
|
605
|
+
onPress={onPress}
|
|
606
|
+
onPressIn={() => spring(1)}
|
|
607
|
+
onPressOut={() => spring(0)}
|
|
608
|
+
accessibilityRole={onPress ? 'button' : undefined}
|
|
609
|
+
accessibilityLabel={email ? `${name}, ${email}` : name}
|
|
610
|
+
accessibilityHint={accessibilityHint ?? (onPress ? 'Switch account' : undefined)}
|
|
611
|
+
style={({ pressed }) => [
|
|
612
|
+
stacked ? styles.profileStacked : styles.profileRow,
|
|
613
|
+
{
|
|
614
|
+
borderRadius: radius.lg,
|
|
615
|
+
backgroundColor: pressed && onPress ? alpha(colors.foreground, dark ? 0.08 : 0.05) : 'transparent',
|
|
616
|
+
},
|
|
617
|
+
]}
|
|
618
|
+
>
|
|
619
|
+
{stacked ? (
|
|
620
|
+
<>
|
|
621
|
+
<View style={styles.profileTop}>
|
|
622
|
+
<ProfileAvatar name={name} src={src} size={56} status={status} />
|
|
623
|
+
{trailing}
|
|
624
|
+
</View>
|
|
625
|
+
{text}
|
|
626
|
+
</>
|
|
627
|
+
) : (
|
|
628
|
+
<>
|
|
629
|
+
<ProfileAvatar name={name} src={src} size={44} status={status} />
|
|
630
|
+
{text}
|
|
631
|
+
{trailing}
|
|
632
|
+
</>
|
|
633
|
+
)}
|
|
634
|
+
</Pressable>
|
|
635
|
+
</Animated.View>
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function ProfileAvatar({ name, src, size, status }: { name: string; src?: string; size: number; status?: DrawerPresence }) {
|
|
640
|
+
const { colors, radius, dark } = useKit();
|
|
641
|
+
const ring = useContext(SurfaceContext) ?? colors.card;
|
|
642
|
+
const hue = nameColor(name);
|
|
643
|
+
const ink = dark ? mix(hue, colors.foreground, 0.35) : mix(hue, colors.foreground, 0.3);
|
|
644
|
+
const initials = name
|
|
645
|
+
.split(/\s+/)
|
|
646
|
+
.filter(Boolean)
|
|
647
|
+
.slice(0, 2)
|
|
648
|
+
.map((w) => w[0]?.toUpperCase() ?? '')
|
|
649
|
+
.join('');
|
|
650
|
+
const dot = Math.round(size * 0.27);
|
|
651
|
+
const presence: Record<DrawerPresence, string> = {
|
|
652
|
+
online: colors.success,
|
|
653
|
+
away: colors.warning,
|
|
654
|
+
busy: colors.danger,
|
|
655
|
+
offline: colors.mutedForeground,
|
|
656
|
+
};
|
|
657
|
+
return (
|
|
658
|
+
<View style={{ width: size, height: size }} accessible={false}>
|
|
659
|
+
<View
|
|
660
|
+
style={[
|
|
661
|
+
styles.clip,
|
|
662
|
+
styles.center,
|
|
663
|
+
{
|
|
664
|
+
width: size,
|
|
665
|
+
height: size,
|
|
666
|
+
borderRadius: radius.capsule(size),
|
|
667
|
+
backgroundColor: alpha(hue, dark ? 0.24 : 0.16),
|
|
668
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
669
|
+
borderColor: alpha(hue, dark ? 0.36 : 0.24),
|
|
670
|
+
},
|
|
671
|
+
]}
|
|
672
|
+
>
|
|
673
|
+
<Text style={{ color: ink, fontSize: size * 0.36, fontWeight: '400', letterSpacing: -0.2 }}>{initials}</Text>
|
|
674
|
+
{src ? <Image source={{ uri: src }} alt="" style={StyleSheet.absoluteFill} /> : null}
|
|
675
|
+
</View>
|
|
676
|
+
{status ? (
|
|
677
|
+
<View
|
|
678
|
+
style={[
|
|
679
|
+
styles.presence,
|
|
680
|
+
{
|
|
681
|
+
width: dot + 4,
|
|
682
|
+
height: dot + 4,
|
|
683
|
+
borderRadius: radius.capsule(dot + 4),
|
|
684
|
+
backgroundColor: presence[status],
|
|
685
|
+
borderColor: ring,
|
|
686
|
+
},
|
|
687
|
+
]}
|
|
688
|
+
/>
|
|
689
|
+
) : null}
|
|
690
|
+
</View>
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/* -------------------------------------------------------------------------------------------------
|
|
695
|
+
* Icons
|
|
696
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
697
|
+
|
|
698
|
+
const ICONS = {
|
|
699
|
+
home: ['M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z', 'M9 22V12h6v10'],
|
|
700
|
+
inbox: [
|
|
701
|
+
'M22 12h-6l-2 3h-4l-2-3H2',
|
|
702
|
+
'M5.45 5.11L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11z',
|
|
703
|
+
],
|
|
704
|
+
send: ['M22 2L11 13', 'M22 2l-7 20-4-9-9-4 20-7z'],
|
|
705
|
+
file: ['M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z', 'M14 2v6h6', 'M8 13h8M8 17h5'],
|
|
706
|
+
archive: ['M21 8v13H3V8', 'M1 3h22v5H1z', 'M10 12h4'],
|
|
707
|
+
trash: ['M3 6h18', 'M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6', 'M10 11v6M14 11v6', 'M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2'],
|
|
708
|
+
alert: ['M12 22a10 10 0 100-20 10 10 0 000 20z', 'M12 8v4M12 16h.01'],
|
|
709
|
+
calendar: ['M5 4h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z', 'M16 2v4M8 2v4M3 10h18'],
|
|
710
|
+
folder: ['M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z'],
|
|
711
|
+
users: [
|
|
712
|
+
'M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2',
|
|
713
|
+
'M9 11a4 4 0 100-8 4 4 0 000 8z',
|
|
714
|
+
'M23 21v-2a4 4 0 00-3-3.87',
|
|
715
|
+
'M16 3.13a4 4 0 010 7.75',
|
|
716
|
+
],
|
|
717
|
+
star: ['M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z'],
|
|
718
|
+
chart: ['M18 20V10M12 20V4M6 20v-6'],
|
|
719
|
+
layers: ['M12 2L2 7l10 5 10-5-10-5z', 'M2 17l10 5 10-5', 'M2 12l10 5 10-5'],
|
|
720
|
+
target: ['M12 22a10 10 0 100-20 10 10 0 000 20z', 'M12 16a4 4 0 100-8 4 4 0 000 8z'],
|
|
721
|
+
search: ['M11 19a8 8 0 100-16 8 8 0 000 16z', 'M21 21l-4.35-4.35'],
|
|
722
|
+
music: ['M9 18V5l12-2v13', 'M6 21a3 3 0 100-6 3 3 0 000 6z', 'M18 19a3 3 0 100-6 3 3 0 000 6z'],
|
|
723
|
+
cloud: ['M18 10h-1.26A8 8 0 109 20h9a5 5 0 000-10z'],
|
|
724
|
+
settings: ['M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6'],
|
|
725
|
+
help: ['M12 22a10 10 0 100-20 10 10 0 000 20z', 'M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3', 'M12 17h.01'],
|
|
726
|
+
logout: ['M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4', 'M16 17l5-5-5-5', 'M21 12H9'],
|
|
727
|
+
bell: ['M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9', 'M13.73 21a2 2 0 01-3.46 0'],
|
|
728
|
+
bookmark: ['M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z'],
|
|
729
|
+
tag: ['M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z', 'M7 7h.01'],
|
|
730
|
+
plus: ['M12 5v14M5 12h14'],
|
|
731
|
+
menu: ['M3 6h18M3 12h18M3 18h18'],
|
|
732
|
+
filter: ['M3 6h18M6 12h12M10 18h4'],
|
|
733
|
+
chevrons: ['M7 9l5-5 5 5', 'M7 15l5 5 5-5'],
|
|
734
|
+
chevronRight: ['M9 18l6-6-6-6'],
|
|
735
|
+
x: ['M18 6L6 18M6 6l12 12'],
|
|
736
|
+
check: ['M20 6L9 17l-5-5'],
|
|
737
|
+
} as const;
|
|
738
|
+
|
|
739
|
+
export type DrawerIconName = keyof typeof ICONS;
|
|
740
|
+
|
|
741
|
+
export interface DrawerIconProps {
|
|
742
|
+
name: DrawerIconName;
|
|
743
|
+
size?: number;
|
|
744
|
+
color?: string;
|
|
745
|
+
strokeWidth?: number;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** The drawer's line icons (1.8 stroke), exported for menu buttons and headers. */
|
|
749
|
+
export function DrawerIcon({ name, size = 22, color, strokeWidth = 1.8 }: DrawerIconProps) {
|
|
750
|
+
const { colors } = useKit();
|
|
751
|
+
return (
|
|
752
|
+
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
|
753
|
+
{ICONS[name].map((d) => (
|
|
754
|
+
<Path
|
|
755
|
+
key={d}
|
|
756
|
+
d={d}
|
|
757
|
+
stroke={color ?? colors.foreground}
|
|
758
|
+
strokeWidth={strokeWidth}
|
|
759
|
+
strokeLinecap="round"
|
|
760
|
+
strokeLinejoin="round"
|
|
761
|
+
/>
|
|
762
|
+
))}
|
|
763
|
+
</Svg>
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/* -------------------------------------------------------------------------------------------------
|
|
768
|
+
* DrawerItem
|
|
769
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
770
|
+
|
|
771
|
+
export interface DrawerItemProps {
|
|
772
|
+
label: string;
|
|
773
|
+
/** A built-in icon name or your own node (e.g. a label colour dot). */
|
|
774
|
+
icon?: DrawerIconName | React.ReactNode;
|
|
775
|
+
/** A count or short text on the right. 0 hides it. */
|
|
776
|
+
badge?: number | string;
|
|
777
|
+
/** 'neutral' plain muted count (accent ink when active) · 'accent' soft tinted pill · 'danger' solid pill for alerts. */
|
|
778
|
+
badgeTone?: 'neutral' | 'accent' | 'danger';
|
|
779
|
+
/** Highlights the row as the current destination with a tinted pill. */
|
|
780
|
+
active?: boolean;
|
|
781
|
+
/** 'danger' tints the icon and label (e.g. Log out). */
|
|
782
|
+
tone?: 'default' | 'danger';
|
|
783
|
+
onPress?: () => void;
|
|
784
|
+
/** Close the surrounding Drawer after pressing (default true). */
|
|
785
|
+
closeOnPress?: boolean;
|
|
786
|
+
/** Active colour (default: theme accent). */
|
|
787
|
+
accent?: string;
|
|
788
|
+
/** Selection haptic on press (default true). */
|
|
789
|
+
haptics?: boolean;
|
|
790
|
+
/** Anything after the badge, e.g. a chevron or a Switch. */
|
|
791
|
+
trailing?: React.ReactNode;
|
|
792
|
+
disabled?: boolean;
|
|
793
|
+
style?: StyleProp<ViewStyle>;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const ITEM_H = 46;
|
|
797
|
+
|
|
798
|
+
/** A drawer row: icon, label and count badge, with a spring-in accent pill when active. */
|
|
799
|
+
export function DrawerItem({
|
|
800
|
+
label,
|
|
801
|
+
icon,
|
|
802
|
+
badge,
|
|
803
|
+
badgeTone = 'neutral',
|
|
804
|
+
active = false,
|
|
805
|
+
tone = 'default',
|
|
806
|
+
onPress,
|
|
807
|
+
closeOnPress = true,
|
|
808
|
+
accent,
|
|
809
|
+
haptics = true,
|
|
810
|
+
trailing,
|
|
811
|
+
disabled,
|
|
812
|
+
style,
|
|
813
|
+
}: DrawerItemProps) {
|
|
814
|
+
const { colors, radius, dark } = useKit();
|
|
815
|
+
const drawer = useContext(DrawerContext);
|
|
816
|
+
const reduceMotion = useReduceMotion();
|
|
817
|
+
const danger = tone === 'danger';
|
|
818
|
+
const tint = danger ? colors.danger : (accent ?? colors.accent);
|
|
819
|
+
const ink = dark ? tint : mix(tint, colors.foreground, 0.22);
|
|
820
|
+
|
|
821
|
+
// Native-driven: pill presence, icon pop, badge pop, press scale — each on its own value.
|
|
822
|
+
const on = useRef(new Animated.Value(active ? 1 : 0)).current;
|
|
823
|
+
const pop = useRef(new Animated.Value(1)).current;
|
|
824
|
+
const badgePop = useRef(new Animated.Value(1)).current;
|
|
825
|
+
const press = useRef(new Animated.Value(0)).current;
|
|
826
|
+
const first = useRef(true);
|
|
827
|
+
const firstBadge = useRef(true);
|
|
828
|
+
|
|
829
|
+
useEffect(() => {
|
|
830
|
+
Animated.spring(on, { toValue: active ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
|
|
831
|
+
if (first.current) {
|
|
832
|
+
first.current = false;
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
if (active && !reduceMotion) {
|
|
836
|
+
pop.setValue(0.78);
|
|
837
|
+
Animated.spring(pop, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();
|
|
838
|
+
}
|
|
839
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
840
|
+
}, [active]);
|
|
841
|
+
|
|
842
|
+
useEffect(() => {
|
|
843
|
+
if (firstBadge.current) {
|
|
844
|
+
firstBadge.current = false;
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
if (reduceMotion) return;
|
|
848
|
+
badgePop.setValue(0.6);
|
|
849
|
+
Animated.spring(badgePop, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();
|
|
850
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
851
|
+
}, [badge]);
|
|
852
|
+
|
|
853
|
+
const showBadge = badge !== undefined && badge !== 0 && badge !== '';
|
|
854
|
+
// Neutral counts are plain text (iOS / Material 3 style); 'accent' gets a soft tinted pill and
|
|
855
|
+
// 'danger' a solid one. On the active row the count takes the row's ink colour.
|
|
856
|
+
const badgeColors =
|
|
857
|
+
badgeTone === 'danger'
|
|
858
|
+
? { bg: colors.danger, fg: colors.dangerForeground }
|
|
859
|
+
: badgeTone === 'accent'
|
|
860
|
+
? { bg: alpha(tint, dark ? 0.24 : 0.14), fg: ink }
|
|
861
|
+
: { bg: 'transparent', fg: active ? ink : colors.mutedForeground };
|
|
862
|
+
const badgePill = badgeTone !== 'neutral';
|
|
863
|
+
const iconColor = active ? tint : danger ? colors.danger : colors.mutedForeground;
|
|
864
|
+
const labelColor = active ? ink : danger ? colors.danger : colors.foreground;
|
|
865
|
+
const pill = radius.capsule(ITEM_H);
|
|
866
|
+
const spring = (to: number) => Animated.spring(press, { toValue: to, useNativeDriver: true, ...motion.bouncy }).start();
|
|
867
|
+
|
|
868
|
+
return (
|
|
869
|
+
<Animated.View style={{ transform: [{ scale: press.interpolate({ inputRange: [0, 1], outputRange: [1, 0.98] }) }] }}>
|
|
870
|
+
<Pressable
|
|
871
|
+
onPress={() => {
|
|
872
|
+
if (haptics) Haptics.selectionAsync().catch(() => {});
|
|
873
|
+
onPress?.();
|
|
874
|
+
if (closeOnPress) drawer?.close();
|
|
875
|
+
}}
|
|
876
|
+
disabled={disabled}
|
|
877
|
+
accessibilityRole="menuitem"
|
|
878
|
+
accessibilityLabel={showBadge ? `${label}, ${badge}` : label}
|
|
879
|
+
accessibilityState={{ selected: active, disabled: !!disabled }}
|
|
880
|
+
onPressIn={() => spring(1)}
|
|
881
|
+
onPressOut={() => spring(0)}
|
|
882
|
+
style={({ pressed }) => [
|
|
883
|
+
styles.item,
|
|
884
|
+
{
|
|
885
|
+
borderRadius: pill,
|
|
886
|
+
opacity: disabled ? 0.45 : 1,
|
|
887
|
+
backgroundColor: pressed && !active ? alpha(colors.foreground, dark ? 0.07 : 0.045) : 'transparent',
|
|
888
|
+
},
|
|
889
|
+
style,
|
|
890
|
+
]}
|
|
891
|
+
>
|
|
892
|
+
<Animated.View
|
|
893
|
+
pointerEvents="none"
|
|
894
|
+
style={[
|
|
895
|
+
StyleSheet.absoluteFill,
|
|
896
|
+
{
|
|
897
|
+
borderRadius: pill,
|
|
898
|
+
backgroundColor: alpha(tint, dark ? 0.18 : 0.12),
|
|
899
|
+
opacity: on,
|
|
900
|
+
transform: [{ scaleX: on.interpolate({ inputRange: [0, 1], outputRange: [0.92, 1] }) }],
|
|
901
|
+
},
|
|
902
|
+
]}
|
|
903
|
+
/>
|
|
904
|
+
{icon ? (
|
|
905
|
+
<Animated.View style={[styles.itemIcon, { transform: [{ scale: pop }] }]}>
|
|
906
|
+
{typeof icon === 'string' ? (
|
|
907
|
+
<DrawerIcon name={icon as DrawerIconName} size={21} color={iconColor} strokeWidth={active ? 2.1 : 1.8} />
|
|
908
|
+
) : (
|
|
909
|
+
icon
|
|
910
|
+
)}
|
|
911
|
+
</Animated.View>
|
|
912
|
+
) : null}
|
|
913
|
+
<Text numberOfLines={1} style={[styles.itemLabel, { color: labelColor }]}>
|
|
914
|
+
{label}
|
|
915
|
+
</Text>
|
|
916
|
+
{showBadge ? (
|
|
917
|
+
<Animated.View
|
|
918
|
+
style={[
|
|
919
|
+
styles.badge,
|
|
920
|
+
!badgePill && styles.badgePlain,
|
|
921
|
+
{ borderRadius: radius.capsule(22), backgroundColor: badgeColors.bg, transform: [{ scale: badgePop }] },
|
|
922
|
+
]}
|
|
923
|
+
>
|
|
924
|
+
<Text style={[styles.badgeText, { color: badgeColors.fg }]}>
|
|
925
|
+
{typeof badge === 'number' && badge > 99 ? '99+' : badge}
|
|
926
|
+
</Text>
|
|
927
|
+
</Animated.View>
|
|
928
|
+
) : null}
|
|
929
|
+
{trailing}
|
|
930
|
+
</Pressable>
|
|
931
|
+
</Animated.View>
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
export interface DrawerSectionProps {
|
|
936
|
+
title: string;
|
|
937
|
+
/** Node on the right of the heading, e.g. a small "+" button. */
|
|
938
|
+
trailing?: React.ReactNode;
|
|
939
|
+
/** Hairline above the heading, separating it from the group before (default true). */
|
|
940
|
+
divider?: boolean;
|
|
941
|
+
style?: StyleProp<ViewStyle>;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/** A quiet heading between groups of DrawerItems, with a hairline divider above it. */
|
|
945
|
+
export function DrawerSection({ title, trailing, divider = true, style }: DrawerSectionProps) {
|
|
946
|
+
const { colors, dark } = useKit();
|
|
947
|
+
return (
|
|
948
|
+
<View style={style}>
|
|
949
|
+
{divider ? <View style={[styles.divider, { backgroundColor: alpha(colors.foreground, dark ? 0.1 : 0.07) }]} /> : null}
|
|
950
|
+
<View style={[styles.section, !divider && styles.sectionFlush]}>
|
|
951
|
+
<Text accessibilityRole="header" style={[styles.sectionText, { color: colors.mutedForeground }]}>
|
|
952
|
+
{title}
|
|
953
|
+
</Text>
|
|
954
|
+
{trailing}
|
|
955
|
+
</View>
|
|
956
|
+
</View>
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
export interface DrawerFooterProps {
|
|
961
|
+
label: string;
|
|
962
|
+
/** Muted second line, e.g. "12.4 GB of 15 GB used". */
|
|
963
|
+
detail?: string;
|
|
964
|
+
icon?: DrawerIconName | React.ReactNode;
|
|
965
|
+
/** 0–1: draws a thin meter under the label (turns warning above 90%). */
|
|
966
|
+
progress?: number;
|
|
967
|
+
onPress?: () => void;
|
|
968
|
+
/** Node on the right, e.g. an icon button. */
|
|
969
|
+
trailing?: React.ReactNode;
|
|
970
|
+
style?: StyleProp<ViewStyle>;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** A compact footer row — storage meter, app version, sync status — for the `footer` slot. */
|
|
974
|
+
export function DrawerFooter({ label, detail, icon, progress, onPress, trailing, style }: DrawerFooterProps) {
|
|
975
|
+
const { colors, radius, dark } = useKit();
|
|
976
|
+
const pct = progress === undefined ? undefined : Math.max(0, Math.min(1, progress));
|
|
977
|
+
return (
|
|
978
|
+
<Pressable
|
|
979
|
+
disabled={!onPress}
|
|
980
|
+
onPress={onPress}
|
|
981
|
+
accessibilityRole={onPress ? 'button' : undefined}
|
|
982
|
+
accessibilityLabel={detail ? `${label}, ${detail}` : label}
|
|
983
|
+
accessibilityValue={pct !== undefined ? { min: 0, max: 100, now: Math.round(pct * 100) } : undefined}
|
|
984
|
+
style={({ pressed }) => [
|
|
985
|
+
styles.footerRow,
|
|
986
|
+
{
|
|
987
|
+
borderRadius: radius.lg,
|
|
988
|
+
backgroundColor: pressed && onPress ? alpha(colors.foreground, dark ? 0.07 : 0.045) : 'transparent',
|
|
989
|
+
},
|
|
990
|
+
style,
|
|
991
|
+
]}
|
|
992
|
+
>
|
|
993
|
+
{icon ? (
|
|
994
|
+
<View style={styles.itemIcon}>
|
|
995
|
+
{typeof icon === 'string' ? <DrawerIcon name={icon as DrawerIconName} size={20} color={colors.mutedForeground} /> : icon}
|
|
996
|
+
</View>
|
|
997
|
+
) : null}
|
|
998
|
+
<View style={styles.footerText}>
|
|
999
|
+
<Text numberOfLines={1} style={[styles.footerLabel, { color: colors.foreground }]}>
|
|
1000
|
+
{label}
|
|
1001
|
+
</Text>
|
|
1002
|
+
{pct !== undefined ? (
|
|
1003
|
+
<View style={[styles.meter, { borderRadius: radius.capsule(4), backgroundColor: alpha(colors.foreground, dark ? 0.1 : 0.07) }]}>
|
|
1004
|
+
<View
|
|
1005
|
+
style={{
|
|
1006
|
+
width: `${pct * 100}%`,
|
|
1007
|
+
height: '100%',
|
|
1008
|
+
borderRadius: radius.capsule(4),
|
|
1009
|
+
backgroundColor: pct > 0.9 ? colors.warning : colors.accent,
|
|
1010
|
+
}}
|
|
1011
|
+
/>
|
|
1012
|
+
</View>
|
|
1013
|
+
) : null}
|
|
1014
|
+
{detail ? (
|
|
1015
|
+
<Text numberOfLines={1} style={[styles.footerDetail, { color: colors.mutedForeground }]}>
|
|
1016
|
+
{detail}
|
|
1017
|
+
</Text>
|
|
1018
|
+
) : null}
|
|
1019
|
+
</View>
|
|
1020
|
+
{trailing}
|
|
1021
|
+
</Pressable>
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/* -------------------------------------------------------------------------------------------------
|
|
1026
|
+
* Helpers
|
|
1027
|
+
* -----------------------------------------------------------------------------------------------*/
|
|
1028
|
+
|
|
1029
|
+
/** Diminishing resistance past the limits (approaches `dimension`). */
|
|
1030
|
+
function rubber(distance: number, dimension: number) {
|
|
1031
|
+
return (distance * dimension * 0.55) / (dimension + 0.55 * distance);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function useReduceMotion() {
|
|
1035
|
+
const [reduce, setReduce] = useState(false);
|
|
1036
|
+
useEffect(() => {
|
|
1037
|
+
let alive = true;
|
|
1038
|
+
AccessibilityInfo.isReduceMotionEnabled()
|
|
1039
|
+
.then((v) => alive && setReduce(v))
|
|
1040
|
+
.catch(() => {});
|
|
1041
|
+
const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
|
|
1042
|
+
return () => {
|
|
1043
|
+
alive = false;
|
|
1044
|
+
sub.remove();
|
|
1045
|
+
};
|
|
1046
|
+
}, []);
|
|
1047
|
+
return reduce;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function parseHex(hex: string): [number, number, number] | null {
|
|
1051
|
+
const full = hex.length === 4 ? '#' + [...hex.slice(1)].map((c) => c + c).join('') : hex;
|
|
1052
|
+
if (!/^#[0-9a-fA-F]{6}/.test(full)) return null;
|
|
1053
|
+
const n = parseInt(full.slice(1, 7), 16);
|
|
1054
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function toHex(r: number, g: number, b: number) {
|
|
1058
|
+
return '#' + [r, g, b].map((v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0')).join('');
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/** Blend `a` toward `b` by `t` (hex colours; returns `a` if either can't be parsed). */
|
|
1062
|
+
function mix(a: string, b: string, t: number) {
|
|
1063
|
+
const x = parseHex(a);
|
|
1064
|
+
const y = parseHex(b);
|
|
1065
|
+
if (!x || !y) return a;
|
|
1066
|
+
return toHex(x[0] + (y[0] - x[0]) * t, x[1] + (y[1] - x[1]) * t, x[2] + (y[2] - x[2]) * t);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function hsl(h: number, s: number, l: number) {
|
|
1070
|
+
const k = (n: number) => (n + h / 30) % 12;
|
|
1071
|
+
const a = s * Math.min(l, 1 - l);
|
|
1072
|
+
const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
|
1073
|
+
return toHex(f(0) * 255, f(8) * 255, f(4) * 255);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/** One solid, calm colour per name, so the same person always gets the same avatar tint. */
|
|
1077
|
+
function nameColor(name: string) {
|
|
1078
|
+
let h = 0;
|
|
1079
|
+
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % 360;
|
|
1080
|
+
return hsl(h, 0.55, 0.52);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
const styles = StyleSheet.create({
|
|
1084
|
+
// 'clip' on web: an overflow:hidden box can still be scrolled by focus, which would shift the panel.
|
|
1085
|
+
root: Platform.OS === 'web' ? ({ flex: 1, overflow: 'clip' } as object) : { flex: 1, overflow: 'hidden' },
|
|
1086
|
+
flex: { flex: 1 },
|
|
1087
|
+
clip: { overflow: 'hidden' },
|
|
1088
|
+
center: { alignItems: 'center', justifyContent: 'center' },
|
|
1089
|
+
panel: { position: 'absolute', top: 0, bottom: 0 },
|
|
1090
|
+
borderRight: { borderRightWidth: StyleSheet.hairlineWidth },
|
|
1091
|
+
borderLeft: { borderLeftWidth: StyleSheet.hairlineWidth },
|
|
1092
|
+
chromeTop: { paddingHorizontal: 10, paddingBottom: 6 },
|
|
1093
|
+
chromeTopDivided: { borderBottomWidth: StyleSheet.hairlineWidth, paddingBottom: 10, marginBottom: 4 },
|
|
1094
|
+
chromeBottom: { paddingHorizontal: 10, paddingTop: 8, borderTopWidth: StyleSheet.hairlineWidth },
|
|
1095
|
+
handleSpacer: { height: 8 },
|
|
1096
|
+
content: { paddingHorizontal: 10, paddingTop: 4, paddingBottom: 12, gap: 2 },
|
|
1097
|
+
header: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 8, paddingVertical: 8 },
|
|
1098
|
+
headerText: { flex: 1, gap: 2, minWidth: 0 },
|
|
1099
|
+
headerTitle: { fontSize: 20, lineHeight: 26, fontWeight: '400', letterSpacing: -0.4 },
|
|
1100
|
+
headerSub: { fontSize: 13, lineHeight: 18, fontWeight: '400' },
|
|
1101
|
+
closeBtn: { width: 34, height: 34, alignItems: 'center', justifyContent: 'center' },
|
|
1102
|
+
profileRow: { flexDirection: 'row', alignItems: 'center', gap: 12, padding: 8 },
|
|
1103
|
+
profileStacked: { gap: 12, padding: 8 },
|
|
1104
|
+
profileTop: { flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between' },
|
|
1105
|
+
profileText: { flex: 1, minWidth: 0, gap: 1 },
|
|
1106
|
+
profileName: { fontSize: 16, lineHeight: 21, fontWeight: '400', letterSpacing: -0.2 },
|
|
1107
|
+
profileNameLg: { fontSize: 18, lineHeight: 23, letterSpacing: -0.3 },
|
|
1108
|
+
profileEmail: { fontSize: 13, lineHeight: 18, fontWeight: '400' },
|
|
1109
|
+
profileChevron: { width: 30, height: 30, alignItems: 'center', justifyContent: 'center' },
|
|
1110
|
+
presence: { position: 'absolute', right: -2, bottom: -2, borderWidth: 2 },
|
|
1111
|
+
item: { minHeight: ITEM_H, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, gap: 14 },
|
|
1112
|
+
itemIcon: { width: 24, alignItems: 'center', justifyContent: 'center' },
|
|
1113
|
+
itemLabel: { flex: 1, fontSize: 15.5, fontWeight: '400', letterSpacing: -0.15 },
|
|
1114
|
+
badge: { minWidth: 22, height: 22, paddingHorizontal: 7, alignItems: 'center', justifyContent: 'center' },
|
|
1115
|
+
badgePlain: { paddingHorizontal: 2, minWidth: 0 },
|
|
1116
|
+
badgeText: { fontSize: 13, fontWeight: '400', fontVariant: ['tabular-nums'] },
|
|
1117
|
+
section: {
|
|
1118
|
+
flexDirection: 'row',
|
|
1119
|
+
alignItems: 'center',
|
|
1120
|
+
justifyContent: 'space-between',
|
|
1121
|
+
minHeight: 36,
|
|
1122
|
+
paddingLeft: 14,
|
|
1123
|
+
paddingRight: 10,
|
|
1124
|
+
paddingTop: 6,
|
|
1125
|
+
paddingBottom: 2,
|
|
1126
|
+
},
|
|
1127
|
+
sectionFlush: { paddingTop: 10 },
|
|
1128
|
+
sectionText: { fontSize: 13, lineHeight: 18, fontWeight: '400', letterSpacing: 0.1 },
|
|
1129
|
+
divider: { height: StyleSheet.hairlineWidth, marginHorizontal: 14, marginTop: 10, marginBottom: 4 },
|
|
1130
|
+
footerRow: { minHeight: 48, flexDirection: 'row', alignItems: 'center', gap: 14, paddingHorizontal: 12, paddingVertical: 8 },
|
|
1131
|
+
footerText: { flex: 1, minWidth: 0, gap: 5 },
|
|
1132
|
+
footerLabel: { fontSize: 14, fontWeight: '400' },
|
|
1133
|
+
footerDetail: { fontSize: 12, fontWeight: '400' },
|
|
1134
|
+
meter: { height: 4, overflow: 'hidden' },
|
|
1135
|
+
});
|