- Add object-fit: contain + black background for video in DTMediaFrame - Style .dt-card-badge with mode-colored background, padding, and bevel-aware positioning (negative right offset for clean diagonal clip) - Restore DTStaggerContainer in react-native (was incorrectly deleted) - Update DTGallery, DTModal, DTMobileFilterOverlay, DTMediaFrame components - Refresh showcase pages for desktop and mobile Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
248 lines
6.4 KiB
TypeScript
248 lines
6.4 KiB
TypeScript
/**
|
|
* DT Button Component
|
|
*
|
|
* Interactive bevel button matching the storefront .menu-item-clipped pattern:
|
|
* - Default: outlined rectangle, no bevel, colored border with thick top
|
|
* - Pressed/hover: bottom-right bevel appears, fills with mode color, text goes dark
|
|
* - Selected: bevel persists, fills with 70% opacity mode color
|
|
*/
|
|
|
|
import {useRef, useEffect} from 'react';
|
|
import {StyleSheet, View, ViewStyle, StyleProp, Pressable, Animated} from 'react-native';
|
|
import {Text} from 'react-native-paper';
|
|
import Svg, {Path, Rect} from 'react-native-svg';
|
|
import {useDTTheme} from '../theme/DTThemeProvider';
|
|
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
|
import {buildBeveledRectPath} from '../utils/bevelPaths';
|
|
import {useComponentLayout} from '../utils/useComponentLayout';
|
|
|
|
interface DTButtonProps {
|
|
/**
|
|
* Button content/label
|
|
*/
|
|
children: React.ReactNode;
|
|
/**
|
|
* Visual variant of the button
|
|
* @default 'normal'
|
|
*/
|
|
variant?: DTVariant;
|
|
/**
|
|
* Button display mode
|
|
* - 'outlined': Border with transparent background, bevel on press (default)
|
|
* - 'contained': Filled background with static bevel
|
|
* @default 'outlined'
|
|
*/
|
|
mode?: 'outlined' | 'contained';
|
|
/**
|
|
* Whether the button is in a persistent selected state
|
|
* (bevel visible, filled with 70% opacity mode color)
|
|
*/
|
|
selected?: boolean;
|
|
/**
|
|
* Custom color (overrides variant)
|
|
*/
|
|
color?: string;
|
|
/**
|
|
* Press handler
|
|
*/
|
|
onPress?: () => void;
|
|
/**
|
|
* Whether button is disabled
|
|
*/
|
|
disabled?: boolean;
|
|
/**
|
|
* Additional styles for container
|
|
*/
|
|
style?: StyleProp<ViewStyle>;
|
|
/**
|
|
* Border width
|
|
* @default 2
|
|
*/
|
|
borderWidth?: number;
|
|
/**
|
|
* Bevel size in pixels (bottom-right corner)
|
|
* @default 16
|
|
*/
|
|
bevelSize?: number;
|
|
}
|
|
|
|
export function DTButton({
|
|
children,
|
|
variant = 'normal',
|
|
mode = 'outlined',
|
|
selected = false,
|
|
color,
|
|
onPress,
|
|
disabled = false,
|
|
style,
|
|
borderWidth = 2,
|
|
bevelSize = 16,
|
|
}: DTButtonProps) {
|
|
const theme = useDTTheme();
|
|
const {dimensions, onLayout, hasDimensions} = useComponentLayout();
|
|
const pulseAnim = useRef(new Animated.Value(1)).current;
|
|
const pressed = useRef(false);
|
|
|
|
const accentColor = getVariantColor(theme, variant, color);
|
|
const selectedColor = accentColor + 'B3'; // ~70% opacity
|
|
const isContained = mode === 'contained';
|
|
const useBevels = theme.custom.bevelMd > 0;
|
|
|
|
const {width, height} = dimensions;
|
|
|
|
// Build SVG paths: rectangle (default) and beveled (active)
|
|
const rectPath = hasDimensions
|
|
? buildBeveledRectPath(width, height, {corners: {}, strokeWidth: borderWidth})
|
|
: '';
|
|
const bevelPath = useBevels && hasDimensions
|
|
? buildBeveledRectPath(width, height, {
|
|
corners: {bottomRight: bevelSize},
|
|
strokeWidth: borderWidth,
|
|
})
|
|
: '';
|
|
|
|
// Pulse animation for pressed/selected hover
|
|
useEffect(() => {
|
|
if (selected) {
|
|
// No pulse when just selected (matches storefront: animation: none on .selected)
|
|
pulseAnim.setValue(1);
|
|
}
|
|
return () => {
|
|
pulseAnim.stopAnimation();
|
|
};
|
|
}, [selected, pulseAnim]);
|
|
|
|
const startPulse = () => {
|
|
Animated.loop(
|
|
Animated.sequence([
|
|
Animated.timing(pulseAnim, {
|
|
toValue: 0.5,
|
|
duration: 1000,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(pulseAnim, {
|
|
toValue: 1,
|
|
duration: 1000,
|
|
useNativeDriver: true,
|
|
}),
|
|
]),
|
|
).start();
|
|
};
|
|
|
|
const stopPulse = () => {
|
|
pulseAnim.stopAnimation();
|
|
pulseAnim.setValue(1);
|
|
};
|
|
|
|
const handlePressIn = () => {
|
|
pressed.current = true;
|
|
startPulse();
|
|
};
|
|
|
|
const handlePressOut = () => {
|
|
pressed.current = false;
|
|
if (!selected) {
|
|
stopPulse();
|
|
}
|
|
};
|
|
|
|
// Determine visual state
|
|
const showBevel = isContained || selected;
|
|
|
|
const getBgColor = (isPressed: boolean) => {
|
|
if (isContained) return accentColor;
|
|
if (selected) return selectedColor;
|
|
if (isPressed) return accentColor;
|
|
return 'transparent';
|
|
};
|
|
|
|
const getTextColor = (isPressed: boolean) => {
|
|
if (isContained || selected || isPressed) return theme.colors.onPrimary;
|
|
return accentColor;
|
|
};
|
|
|
|
return (
|
|
<Pressable
|
|
onPress={onPress}
|
|
disabled={disabled}
|
|
onPressIn={handlePressIn}
|
|
onPressOut={handlePressOut}
|
|
style={[{opacity: disabled ? 0.5 : 1}, style]}>
|
|
{({pressed: isPressedState}) => (
|
|
<Animated.View
|
|
style={[styles.container, {opacity: pulseAnim}]}
|
|
onLayout={onLayout}>
|
|
{/* Non-beveled mode (classic brand) */}
|
|
{!useBevels && (
|
|
<View
|
|
style={[
|
|
StyleSheet.absoluteFill,
|
|
{
|
|
borderWidth,
|
|
borderColor: accentColor,
|
|
borderTopWidth: 5,
|
|
borderRadius: theme.custom.radiusSm,
|
|
backgroundColor: getBgColor(isPressedState),
|
|
},
|
|
]}
|
|
/>
|
|
)}
|
|
{/* Beveled mode (DT brand) */}
|
|
{useBevels && hasDimensions && (
|
|
<Svg
|
|
style={StyleSheet.absoluteFill}
|
|
width={width}
|
|
height={height}
|
|
viewBox={`0 0 ${width} ${height}`}>
|
|
<Path
|
|
d={(isPressedState || showBevel) ? bevelPath : rectPath}
|
|
fill={getBgColor(isPressedState)}
|
|
stroke={accentColor}
|
|
strokeWidth={borderWidth}
|
|
/>
|
|
{/* Thick top accent — rendered inside SVG for exact alignment */}
|
|
<Rect
|
|
x={borderWidth / 2}
|
|
y={0}
|
|
width={width - borderWidth}
|
|
height={5}
|
|
fill={accentColor}
|
|
/>
|
|
</Svg>
|
|
)}
|
|
<View style={styles.content}>
|
|
{typeof children === 'string' ? (
|
|
<Text
|
|
variant="labelLarge"
|
|
style={[styles.label, {color: getTextColor(isPressedState)}]}>
|
|
{children}
|
|
</Text>
|
|
) : (
|
|
children
|
|
)}
|
|
</View>
|
|
</Animated.View>
|
|
)}
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
position: 'relative',
|
|
minHeight: 44,
|
|
},
|
|
content: {
|
|
position: 'relative',
|
|
zIndex: 1,
|
|
paddingVertical: 12,
|
|
paddingHorizontal: 24,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
label: {
|
|
fontWeight: '600',
|
|
letterSpacing: 1,
|
|
},
|
|
});
|