Files
dt-design-system/packages/react-native/src/components/DTDrawer.tsx
michael 47e8085581 Make RN components fully theme-driven for multi-brand support
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>
2026-03-06 10:18:39 -08:00

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,
},
});