Add storefront component migration: cards, badges, progress bars, animations, and React wrapper package
Major feature migration from dt-shopify-storefront into the design system: - Card system: per-card color modes (normal/emphasis/warning/success/other), progress bars, full-bleed card body (card-body-flush), image bevel clip-paths, and visible structural borders at bevel corners - Badge system: dt-card-badge with position-aware offsets for cards vs media frames - Media frames: dt-bevel-media with inner surface fill, image clipping, and "MISSING MEDIA" placeholder for empty containers - Interactive bevel buttons: dt-btn with hover/selected states and pulse animation - Menu items: dt-menu-item with active states and level indentation - Animations: scale-in, fade-in, slide-up, pulse, ping, spin keyframes - Feature legend: CSS grid for product feature icons with state colors - Scrollbar styling for DT brand - New @dangerousthings/react package wrapping web CSS components - New @dangerousthings/tailwind-preset package - New @dangerousthings/hex-background package - Desktop showcase rewritten in React with Tailwind CSS - Mobile showcase updated with new screens (animations, filters, advanced cards) - Tokens: added mode color tokens, RGB variants, and selected-state tokens Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,18 +1,20 @@
|
||||
/**
|
||||
* DT Button Component
|
||||
*
|
||||
* A themed button following the Dangerous Things design language
|
||||
* with SVG-based beveled corners.
|
||||
* 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 {StyleSheet, View, ViewStyle, StyleProp, Pressable} from 'react-native';
|
||||
import {useRef, useEffect} from 'react';
|
||||
import {StyleSheet, View, ViewStyle, StyleProp, Pressable, Animated} from 'react-native';
|
||||
import {Text} from 'react-native-paper';
|
||||
import Svg, {Path} from 'react-native-svg';
|
||||
import Svg, {Path, Rect} from 'react-native-svg';
|
||||
import {useDTTheme} from '../theme/DTThemeProvider';
|
||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||
import {buildButtonBevelPath} from '../utils/bevelPaths';
|
||||
import {buildBeveledRectPath} from '../utils/bevelPaths';
|
||||
import {useComponentLayout} from '../utils/useComponentLayout';
|
||||
import {useState} from 'react';
|
||||
|
||||
interface DTButtonProps {
|
||||
/**
|
||||
@@ -26,11 +28,16 @@ interface DTButtonProps {
|
||||
variant?: DTVariant;
|
||||
/**
|
||||
* Button display mode
|
||||
* - 'outlined': Border with transparent background (default)
|
||||
* - 'contained': Filled background
|
||||
* - '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)
|
||||
*/
|
||||
@@ -53,91 +60,169 @@ interface DTButtonProps {
|
||||
*/
|
||||
borderWidth?: number;
|
||||
/**
|
||||
* Bevel size in pixels
|
||||
* @default 8
|
||||
* Bevel size in pixels (bottom-right corner)
|
||||
* @default 16
|
||||
*/
|
||||
bevelSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DT-styled Button component with SVG beveled corners
|
||||
*
|
||||
* @example
|
||||
* <DTButton variant="normal" onPress={handleScan}>
|
||||
* Scan NFC Tag
|
||||
* </DTButton>
|
||||
*
|
||||
* @example
|
||||
* <DTButton variant="emphasis" mode="contained" onPress={handleAction}>
|
||||
* View Results
|
||||
* </DTButton>
|
||||
*/
|
||||
export function DTButton({
|
||||
children,
|
||||
variant = 'normal',
|
||||
mode = 'outlined',
|
||||
selected = false,
|
||||
color,
|
||||
onPress,
|
||||
disabled = false,
|
||||
style,
|
||||
borderWidth = 2,
|
||||
bevelSize = 8,
|
||||
bevelSize = 16,
|
||||
}: DTButtonProps) {
|
||||
const theme = useDTTheme();
|
||||
const {dimensions, onLayout, hasDimensions} = useComponentLayout();
|
||||
const [pressed, setPressed] = useState(false);
|
||||
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 bgColor = isContained ? accentColor : 'transparent';
|
||||
const textColor = isContained ? theme.colors.onPrimary : accentColor;
|
||||
const useBevels = theme.custom.bevelMd > 0;
|
||||
|
||||
const {width, height} = dimensions;
|
||||
const opacity = disabled ? 0.5 : pressed ? 0.7 : 1;
|
||||
const useBevels = theme.custom.bevelMd > 0;
|
||||
|
||||
// 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={() => setPressed(true)}
|
||||
onPressOut={() => setPressed(false)}
|
||||
style={[{opacity}, style]}>
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
!useBevels && {
|
||||
borderWidth,
|
||||
borderColor: accentColor,
|
||||
borderRadius: theme.custom.radiusSm,
|
||||
backgroundColor: bgColor,
|
||||
},
|
||||
]}
|
||||
onLayout={onLayout}>
|
||||
{useBevels && hasDimensions && (
|
||||
<Svg
|
||||
style={StyleSheet.absoluteFill}
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}>
|
||||
<Path
|
||||
d={buildButtonBevelPath(width, height, bevelSize, borderWidth)}
|
||||
fill={bgColor}
|
||||
stroke={accentColor}
|
||||
strokeWidth={borderWidth}
|
||||
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),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Svg>
|
||||
)}
|
||||
<View style={styles.content}>
|
||||
{typeof children === 'string' ? (
|
||||
<Text variant="labelLarge" style={[styles.label, {color: textColor}]}>
|
||||
{children}
|
||||
</Text>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user