Refactor all 18 React Native components from static DTColors imports to dynamic useDTTheme() context, enabling proper visual differentiation when switching between DT and Classic brands. Key changes: - Replace static DTColors with theme context in all components - Add shape tokens (bevel/radius) to DTExtendedTheme interface - Conditionally render SVG bevels (DT) vs borderRadius (Classic) - Configure fonts per brand (Tektur for DT, system sans-serif for Classic) - Update buildThemeFromBrand to parse shape and typography tokens - Fix Metro config for monorepo singleton resolution Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
284 lines
7.3 KiB
TypeScript
284 lines
7.3 KiB
TypeScript
/**
|
|
* DT Drawer Component
|
|
*
|
|
* Hybrid component: RNP Portal for layering + custom Animated panel
|
|
* for the sliding behavior. RNP has no sliding panel component.
|
|
*
|
|
* Uses the dual-SVG-path technique (outer border fill + inner dark fill)
|
|
* consistent with DTCard/DTMediaFrame for proper visual clipping at
|
|
* beveled corners.
|
|
*
|
|
* Web CSS reference (Aside.tsx):
|
|
* - Fixed sidebar sliding from right
|
|
* - Beveled left edges (top-left + bottom-left for right drawer)
|
|
* - Colored header bar
|
|
* - Overlay backdrop
|
|
* - transition: transform 200ms ease-in-out
|
|
*/
|
|
|
|
import {useRef, useEffect} from 'react';
|
|
import {
|
|
StyleSheet,
|
|
View,
|
|
ViewStyle,
|
|
StyleProp,
|
|
Pressable,
|
|
Animated,
|
|
BackHandler,
|
|
ScrollView,
|
|
useWindowDimensions,
|
|
} from 'react-native';
|
|
import {Portal, Text} from 'react-native-paper';
|
|
import Svg, {Path} from 'react-native-svg';
|
|
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
|
import {useDTTheme} from '../theme/DTThemeProvider';
|
|
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
|
import {buildDrawerBevelPath} from '../utils/bevelPaths';
|
|
|
|
interface DTDrawerProps {
|
|
/**
|
|
* Whether the drawer is visible
|
|
*/
|
|
visible: boolean;
|
|
/**
|
|
* Called when the drawer is dismissed
|
|
*/
|
|
onDismiss: () => void;
|
|
/**
|
|
* Heading text in the drawer header bar
|
|
*/
|
|
heading: string;
|
|
/**
|
|
* Visual variant for the header bar
|
|
* @default 'emphasis'
|
|
*/
|
|
headingVariant?: DTVariant;
|
|
/**
|
|
* Which side the drawer slides from
|
|
* @default 'right'
|
|
*/
|
|
position?: 'right' | 'left';
|
|
/**
|
|
* Drawer panel width in pixels
|
|
* @default 400
|
|
*/
|
|
width?: number;
|
|
/**
|
|
* Bevel size in pixels
|
|
* @default 32
|
|
*/
|
|
bevelSize?: number;
|
|
/**
|
|
* Border width in pixels
|
|
* @default 2
|
|
*/
|
|
borderWidth?: number;
|
|
/**
|
|
* Drawer content
|
|
*/
|
|
children: React.ReactNode;
|
|
/**
|
|
* Additional styles
|
|
*/
|
|
style?: StyleProp<ViewStyle>;
|
|
}
|
|
|
|
/**
|
|
* DT-styled Drawer/Aside with beveled edges and slide animation
|
|
*
|
|
* @example
|
|
* <DTDrawer visible={cartOpen} onDismiss={() => setCartOpen(false)} heading="Cart">
|
|
* <CartItems />
|
|
* </DTDrawer>
|
|
*
|
|
* @example
|
|
* <DTDrawer visible={menuOpen} onDismiss={close} heading="Menu" position="left">
|
|
* <MenuItems />
|
|
* </DTDrawer>
|
|
*/
|
|
export function DTDrawer({
|
|
visible,
|
|
onDismiss,
|
|
heading,
|
|
headingVariant = 'emphasis',
|
|
position = 'right',
|
|
width: drawerWidth = 400,
|
|
bevelSize = 32,
|
|
borderWidth = 2,
|
|
children,
|
|
style,
|
|
}: DTDrawerProps) {
|
|
const theme = useDTTheme();
|
|
const useBevels = theme.custom.bevelMd > 0;
|
|
const slideAnim = useRef(new Animated.Value(0)).current;
|
|
const overlayAnim = useRef(new Animated.Value(0)).current;
|
|
const headerColor = getVariantColor(theme, headingVariant);
|
|
const {width: screenWidth, height: screenHeight} = useWindowDimensions();
|
|
const insets = useSafeAreaInsets();
|
|
const effectiveWidth = Math.min(drawerWidth, screenWidth);
|
|
|
|
useEffect(() => {
|
|
if (visible) {
|
|
Animated.parallel([
|
|
Animated.timing(slideAnim, {
|
|
toValue: 1,
|
|
duration: 200,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(overlayAnim, {
|
|
toValue: 1,
|
|
duration: 200,
|
|
useNativeDriver: true,
|
|
}),
|
|
]).start();
|
|
} else {
|
|
Animated.parallel([
|
|
Animated.timing(slideAnim, {
|
|
toValue: 0,
|
|
duration: 200,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(overlayAnim, {
|
|
toValue: 0,
|
|
duration: 200,
|
|
useNativeDriver: true,
|
|
}),
|
|
]).start();
|
|
}
|
|
}, [visible, slideAnim, overlayAnim]);
|
|
|
|
// Android back button handling
|
|
useEffect(() => {
|
|
if (!visible) return;
|
|
const handler = BackHandler.addEventListener('hardwareBackPress', () => {
|
|
onDismiss();
|
|
return true;
|
|
});
|
|
return () => handler.remove();
|
|
}, [visible, onDismiss]);
|
|
|
|
const translateX = slideAnim.interpolate({
|
|
inputRange: [0, 1],
|
|
outputRange: [
|
|
position === 'right' ? effectiveWidth : -effectiveWidth,
|
|
0,
|
|
],
|
|
});
|
|
|
|
// Don't render when fully hidden
|
|
const pointerEvents = visible ? 'auto' : 'none';
|
|
|
|
return (
|
|
<Portal>
|
|
<View style={StyleSheet.absoluteFill} pointerEvents={pointerEvents}>
|
|
{/* Overlay backdrop */}
|
|
<Animated.View style={[StyleSheet.absoluteFill, {opacity: overlayAnim}]}>
|
|
<Pressable
|
|
style={[StyleSheet.absoluteFill, {backgroundColor: theme.colors.backdrop}]}
|
|
onPress={onDismiss}
|
|
/>
|
|
</Animated.View>
|
|
|
|
{/* Drawer panel */}
|
|
<Animated.View
|
|
style={[
|
|
styles.panel,
|
|
position === 'right' ? styles.panelRight : styles.panelLeft,
|
|
{width: effectiveWidth, transform: [{translateX}]},
|
|
!useBevels && {
|
|
borderWidth,
|
|
borderColor: headerColor,
|
|
borderRadius: theme.custom.radius,
|
|
backgroundColor: theme.colors.background,
|
|
overflow: 'hidden',
|
|
},
|
|
style,
|
|
]}>
|
|
{/* Dual-SVG-path technique: outer border + inner dark fill — beveled mode only */}
|
|
{useBevels && (
|
|
<View style={StyleSheet.absoluteFill}>
|
|
<Svg width={effectiveWidth} height={screenHeight}>
|
|
<Path
|
|
d={buildDrawerBevelPath(
|
|
effectiveWidth,
|
|
screenHeight,
|
|
bevelSize,
|
|
position,
|
|
)}
|
|
fill={headerColor}
|
|
/>
|
|
<Path
|
|
d={buildDrawerBevelPath(
|
|
effectiveWidth - borderWidth * 2,
|
|
screenHeight - borderWidth * 2,
|
|
bevelSize - borderWidth,
|
|
position,
|
|
)}
|
|
fill={theme.colors.background}
|
|
transform={`translate(${borderWidth}, ${borderWidth})`}
|
|
/>
|
|
</Svg>
|
|
</View>
|
|
)}
|
|
|
|
{/* Header bar */}
|
|
<View style={[styles.header, {backgroundColor: headerColor, paddingTop: insets.top + 16}]}>
|
|
<Text style={[styles.headerText, {color: theme.colors.onPrimary}]}>{heading}</Text>
|
|
<Pressable
|
|
onPress={onDismiss}
|
|
style={({pressed}) => ({opacity: pressed ? 0.7 : 1})}>
|
|
<Text style={[styles.closeButton, {color: theme.colors.onPrimary}]}>✕</Text>
|
|
</Pressable>
|
|
</View>
|
|
|
|
{/* Content */}
|
|
<ScrollView
|
|
style={styles.content}
|
|
contentContainerStyle={[styles.contentContainer, {paddingBottom: insets.bottom + 16}]}>
|
|
{children}
|
|
</ScrollView>
|
|
</Animated.View>
|
|
</View>
|
|
</Portal>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
panel: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
bottom: 0,
|
|
},
|
|
panelRight: {
|
|
right: 0,
|
|
},
|
|
panelLeft: {
|
|
left: 0,
|
|
},
|
|
header: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 16,
|
|
zIndex: 1,
|
|
},
|
|
headerText: {
|
|
fontWeight: '700',
|
|
fontSize: 18,
|
|
letterSpacing: 0.5,
|
|
},
|
|
closeButton: {
|
|
fontSize: 20,
|
|
fontWeight: '700',
|
|
paddingHorizontal: 8,
|
|
},
|
|
content: {
|
|
flex: 1,
|
|
zIndex: 1,
|
|
},
|
|
contentContainer: {
|
|
padding: 16,
|
|
},
|
|
});
|