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:
michael
2026-03-05 13:26:03 -08:00
parent 47e8085581
commit e74c285b70
109 changed files with 7196 additions and 1026 deletions

View File

@@ -1,6 +1,6 @@
# @dangerousthings/react-native
React Native components for the Dangerous Things design system — 18 themed components built on React Native Paper with cyberpunk beveled aesthetics.
React Native components for the Dangerous Things design system — 22 themed components built on React Native Paper with cyberpunk beveled aesthetics.
## Install
@@ -74,6 +74,9 @@ Options:
| `DTMediaFrame` | Diagonal beveled frame for images |
| `DTAccordion` | Collapsible sections with accent border |
| `DTHexagon` | Decorative hexagon SVG shape |
| `DTBadgeOverlay` | Absolute-positioned badge wrapper (top-left, top-right, bottom-left, bottom-right) |
| `DTStaggerContainer` | Staggered scale-in entrance animation for child elements |
| `DTFeatureLegend` | Product feature grid with icons and rotated labels |
### Interactive
@@ -83,6 +86,14 @@ Options:
| `DTDrawer` | Side drawer with edge bevels |
| `DTGallery` | Image gallery |
| `DTMenu` | Dropdown menu with item variants |
| `DTMobileFilterOverlay` | Full-screen slide-up filter overlay with backdrop |
### Animation Hooks
| Hook | Description |
|------|-------------|
| `useScaleIn` | Scale 0→1 entrance animation |
| `usePulse` | Looping opacity pulse animation |
## Usage Examples
@@ -99,6 +110,11 @@ import { DTButton, DTCard, DTTextInput } from "@dangerousthings/react-native";
<Text>Chip detected</Text>
</DTCard>
// Card with selected state and progress bar
<DTCard title="PRODUCT" mode="emphasis" selected progress={0.6}>
<Text>Selected with 60% progress</Text>
</DTCard>
// Text input with error state
<DTTextInput
variant="normal"

View File

@@ -0,0 +1,45 @@
/**
* DT Badge Overlay
*
* Positioning wrapper for badges on cards and media frames.
* Places children absolutely at a specified corner with configurable offset.
*/
import {ReactNode} from 'react';
import {View, ViewStyle, StyleProp} from 'react-native';
type BadgePosition = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
interface DTBadgeOverlayProps {
children: ReactNode;
/**
* Corner position for the badge
* @default 'bottom-right'
*/
position?: BadgePosition;
/**
* Offset from the corner in pixels
* @default 8
*/
offset?: number;
/**
* Additional styles
*/
style?: StyleProp<ViewStyle>;
}
export function DTBadgeOverlay({
children,
position = 'bottom-right',
offset = 8,
style,
}: DTBadgeOverlayProps) {
const positionStyle: ViewStyle = {
position: 'absolute',
zIndex: 4,
...(position.includes('top') ? {top: offset} : {bottom: offset}),
...(position.includes('right') ? {right: offset} : {left: offset}),
};
return <View style={[positionStyle, style]}>{children}</View>;
}

View File

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

View File

@@ -8,6 +8,10 @@
* - Bottom-right bevel: 2em (~32px)
* - Bottom-left bevel: 1em (~16px)
* - Border width: 0.2em (~3px)
*
* The progress bar is a structural element on the left edge (0 to bevelSizeSmall).
* It is always present. At 0 progress it shows surface color (empty bar).
* As progress increases, accent color fills from the bottom up.
*/
import {ReactNode} from 'react';
@@ -39,6 +43,12 @@ interface DTCardProps {
* @default true when title is provided
*/
showHeader?: boolean;
/**
* Progress value (01) for left-edge vertical progress bar.
* Always present. At 0 the bar shows surface color (empty).
* @default 0
*/
progress?: number;
/**
* Additional styles for the card container
*/
@@ -77,6 +87,114 @@ interface DTCardProps {
onPress?: () => void;
}
/**
* Build inner card path with left edge at bevelSizeSmall (for progress bar zone).
* 5-point polygon — no bottom-left bevel, straight vertical left edge.
*/
function buildInnerCardPath(
w: number,
h: number,
bevelBR: number,
bw: number,
bevelSizeSmall: number,
): string {
const right = w - bw;
const top = bw;
const bottom = h - bw;
const br = Math.min(
bevelBR - bw,
(right - bevelSizeSmall) / 3,
(bottom - top) / 3,
);
if (right <= bevelSizeSmall || bottom <= top) return '';
return [
`M ${bevelSizeSmall} ${top}`,
`L ${right} ${top}`,
`L ${right} ${bottom - br}`,
`L ${right - br} ${bottom}`,
`L ${bevelSizeSmall} ${bottom}`,
'Z',
].join(' ');
}
/**
* Build the full progress bar fill area path (inset 3px from frame on all sides).
* This is the region where the surface/accent gradient fills.
*/
function buildProgressAreaPath(
cardH: number,
bw: number,
bevelSizeSmall: number,
): string {
const left = bw;
const right = bevelSizeSmall - bw;
const top = bw;
const bevelStartY = cardH - bevelSizeSmall;
const bottomRight = cardH - bw * 2; // y at bottom-right of fill area (on bevel diagonal)
if (right <= left || bevelStartY <= top) return '';
return [
`M ${left} ${top}`,
`L ${right} ${top}`,
`L ${right} ${bottomRight}`,
`L ${left} ${bevelStartY}`,
'Z',
].join(' ');
}
/**
* Build the accent-colored portion of the progress bar (fills from bottom).
* Returns empty string when progress is 0.
*/
function buildProgressFillPath(
cardH: number,
bw: number,
bevelSizeSmall: number,
progress: number,
): string {
const p = Math.max(0, Math.min(1, progress));
if (p === 0) return '';
const left = bw;
const right = bevelSizeSmall - bw;
const top = bw;
const bevelStartY = cardH - bevelSizeSmall;
const bottomRight = cardH - bw * 2;
if (right <= left || bevelStartY <= top) return '';
// Total fill area height (along the left edge, from top to bevelStartY)
const areaH = bevelStartY - top;
// How much of the area is filled from the bottom
const fillTop = top + areaH * (1 - p);
if (fillTop >= bevelStartY) {
// Fill is entirely within the bevel zone
// The bevel diagonal goes from (left, bevelStartY) to (right, bottomRight)
// At y=fillTop, x on the diagonal: x = left + (fillTop - bevelStartY) * (right - left) / (bottomRight - bevelStartY)
const bevelH = bottomRight - bevelStartY;
if (bevelH <= 0) return '';
const xAtFillTop = left + ((fillTop - bevelStartY) / bevelH) * (right - left);
if (xAtFillTop >= right) return '';
return [
`M ${xAtFillTop} ${fillTop}`,
`L ${right} ${fillTop}`,
`L ${right} ${bottomRight}`,
'Z',
].join(' ');
}
// Fill extends above the bevel zone — rectangle + bevel triangle
return [
`M ${left} ${fillTop}`,
`L ${right} ${fillTop}`,
`L ${right} ${bottomRight}`,
`L ${left} ${bevelStartY}`,
'Z',
].join(' ');
}
/**
* DT-styled Card component with SVG beveled corners
*
@@ -96,6 +214,7 @@ export function DTCard({
borderColor,
title,
showHeader,
progress = 0,
style,
contentStyle,
padding = 16,
@@ -118,9 +237,21 @@ export function DTCard({
const outerPath = useBevels && hasDimensions
? buildCardBevelPath(width, height, bevelSize, bevelSizeSmall, 0)
: '';
// Inner bevel path at border inset (for background + frame cutout)
// Inner bevel path — left edge at bevelSizeSmall (progress bar zone)
const innerPath = useBevels && hasDimensions
? buildCardBevelPath(width, height, bevelSize - borderWidth, bevelSizeSmall - borderWidth, borderWidth)
? buildInnerCardPath(width, height, bevelSize, borderWidth, bevelSizeSmall)
: '';
// Progress bar paths — computed once, reused in progress SVG and frame overlay
const progressAreaPath = useBevels && hasDimensions
? buildProgressAreaPath(height, borderWidth, bevelSizeSmall)
: '';
const progressFillPath = useBevels && hasDimensions && progress > 0
? buildProgressFillPath(height, borderWidth, bevelSizeSmall, progress)
: '';
// Frame overlay: outer + inner + progressArea hole (evenodd punches window for progress bar)
const framePath = useBevels && hasDimensions
? outerPath + ' ' + innerPath + (progressAreaPath ? ' ' + progressAreaPath : '')
: '';
const content = (
@@ -147,7 +278,54 @@ export function DTCard({
<Path d={innerPath} fill={bgColor} />
</Svg>
)}
<View style={styles.innerContainer}>
{/* Progress bar fill area — beveled mode */}
{useBevels && hasDimensions && (
<Svg
style={[StyleSheet.absoluteFill, {zIndex: 1}]}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
pointerEvents="none">
{/* Accent base — visible through the semi-transparent surface above.
Matches web where ::after sits over the accent-colored card bg. */}
<Path d={progressAreaPath} fill={accentColor} />
{/* Surface fill over accent base — 0.6 opacity lets accent bleed through.
Matches web --dt-progress-empty-opacity default. */}
<Path
d={progressAreaPath}
fill={bgColor}
opacity={0.6}
/>
{/* Accent fill from bottom (progressed portion) */}
{progressFillPath !== '' && (
<Path
d={progressFillPath}
fill={accentColor}
/>
)}
</Svg>
)}
{/* Progress bar — non-beveled (classic) mode */}
{!useBevels && (
<View style={[styles.progressBar, {
width: bevelSizeSmall,
left: borderWidth,
top: borderWidth,
bottom: borderWidth,
borderBottomLeftRadius: theme.custom.radius > 0 ? Math.max(0, theme.custom.radius - borderWidth) : 0,
}]}>
<View
style={[
styles.progressFill,
{
backgroundColor: accentColor,
height: `${Math.round(Math.max(0, Math.min(1, progress)) * 100)}%`,
},
]}
/>
</View>
)}
<View style={[styles.innerContainer, useBevels && {paddingLeft: bevelSizeSmall - borderWidth}]}>
{shouldShowHeader && (
<View style={[styles.header, {backgroundColor: accentColor}]}>
{title && (
@@ -161,7 +339,10 @@ export function DTCard({
)}
<View style={[styles.content, {padding}, contentStyle]}>{children}</View>
</View>
{/* Frame overlay (above content) — beveled mode only */}
{/* Frame overlay (above content) — beveled mode only.
Uses evenodd with 3 sub-paths: outer + inner + progressArea.
The progress area path punches a hole in the frame so the
progress bar SVG underneath (zIndex:1) shows through. */}
{useBevels && hasDimensions && (
<Svg
style={[StyleSheet.absoluteFill, styles.frameOverlay]}
@@ -170,7 +351,7 @@ export function DTCard({
viewBox={`0 0 ${width} ${height}`}
pointerEvents="none">
<Path
d={outerPath + ' ' + innerPath}
d={framePath}
fillRule="evenodd"
fill={accentColor}
/>
@@ -221,7 +402,8 @@ const styles = StyleSheet.create({
overflow: 'hidden',
},
header: {
paddingHorizontal: 16,
paddingLeft: 8,
paddingRight: 16,
paddingVertical: 12,
},
headerText: {
@@ -232,4 +414,17 @@ const styles = StyleSheet.create({
frameOverlay: {
zIndex: 2,
},
progressBar: {
position: 'absolute' as const,
left: 0,
top: 0,
bottom: 0,
backgroundColor: 'transparent',
zIndex: 3,
justifyContent: 'flex-end' as const,
overflow: 'hidden' as const,
},
progressFill: {
width: '100%' as const,
},
});

View File

@@ -61,6 +61,7 @@ export function DTChip({
<Chip
{...props}
mode="outlined"
showSelectedCheck={false}
textStyle={[
styles.text,
{ color: selected ? theme.colors.onPrimary : color },

View File

@@ -0,0 +1,136 @@
/**
* DT Feature Legend
*
* Displays product features in a grid with icons and rotated labels.
* Source: dt-shopify-storefront UseCaseLegend component.
*/
import {ReactNode} from 'react';
import {StyleSheet, View, ViewStyle, StyleProp} from 'react-native';
import {Text} from 'react-native-paper';
import {useDTTheme} from '../theme/DTThemeProvider';
import {type DTVariant, getVariantColor} from '../utils/variantColors';
export interface DTFeatureItem {
key: string;
name: string;
icon: ReactNode;
state: 'supported' | 'disabled' | 'unsupported';
}
const stateToVariant: Record<DTFeatureItem['state'], DTVariant> = {
supported: 'normal',
disabled: 'emphasis',
unsupported: 'warning',
};
interface DTFeatureLegendProps {
features: DTFeatureItem[];
/**
* Header title text
*/
title?: string;
/**
* Color variant for the header bar
* @default 'normal'
*/
variant?: DTVariant;
/**
* Number of columns in the grid
* @default 5
*/
columns?: number;
/**
* Icon size in pixels
* @default 42
*/
iconSize?: number;
style?: StyleProp<ViewStyle>;
}
export function DTFeatureLegend({
features,
title,
variant = 'normal',
columns = 5,
iconSize = 42,
style,
}: DTFeatureLegendProps) {
const theme = useDTTheme();
const headerColor = getVariantColor(theme, variant);
const itemWidth = `${100 / columns}%` as const;
return (
<View style={[styles.container, style]}>
{title && (
<View style={[styles.header, {backgroundColor: headerColor}]}>
<Text
variant="labelLarge"
style={[styles.headerText, {color: theme.colors.onPrimary}]}>
{title}
</Text>
</View>
)}
<View style={styles.grid}>
{features.map(feature => {
const featureColor = getVariantColor(
theme,
stateToVariant[feature.state],
);
return (
<View
key={feature.key}
style={[styles.item, {width: itemWidth as unknown as number}]}>
<View style={[styles.iconContainer, {width: iconSize, height: iconSize}]}>
{feature.icon}
</View>
<View style={styles.labelContainer}>
<Text
style={[styles.label, {color: featureColor}]}>
{feature.name}
</Text>
</View>
</View>
);
})}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
overflow: 'hidden',
},
header: {
paddingHorizontal: 16,
paddingVertical: 8,
},
headerText: {
fontWeight: '700',
letterSpacing: 0.5,
textTransform: 'uppercase',
},
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
},
item: {
alignItems: 'center',
paddingVertical: 8,
paddingHorizontal: 4,
gap: 4,
},
iconContainer: {
alignItems: 'center',
justifyContent: 'center',
},
labelContainer: {
paddingTop: 4,
},
label: {
fontSize: 11,
fontWeight: '600',
textAlign: 'center',
},
});

View File

@@ -0,0 +1,230 @@
/**
* DT Mobile Filter Overlay
*
* Full-screen slide-up overlay for mobile filter menus.
* Source: dt-shopify-storefront MobileFilterMenu component.
*/
import {ReactNode, useRef, useEffect} from 'react';
import {
StyleSheet,
View,
ViewStyle,
StyleProp,
Pressable,
Animated,
Dimensions,
BackHandler,
} from 'react-native';
import {Text, Portal} from 'react-native-paper';
import {useDTTheme} from '../theme/DTThemeProvider';
import {type DTVariant, getVariantColor} from '../utils/variantColors';
interface DTMobileFilterOverlayProps {
visible: boolean;
onDismiss: () => void;
/**
* Header title
*/
heading?: string;
/**
* Number of active filters (shown as badge in header)
*/
activeFilterCount?: number;
/**
* Callback to clear all filters
*/
onClearAll?: () => void;
/**
* Color variant for the header
* @default 'normal'
*/
variant?: DTVariant;
children: ReactNode;
style?: StyleProp<ViewStyle>;
}
export function DTMobileFilterOverlay({
visible,
onDismiss,
heading = 'Filters',
activeFilterCount,
onClearAll,
variant = 'normal',
children,
style,
}: DTMobileFilterOverlayProps) {
const theme = useDTTheme();
const accentColor = getVariantColor(theme, variant);
const slideAnim = useRef(new Animated.Value(0)).current;
const backdropAnim = useRef(new Animated.Value(0)).current;
const screenHeight = Dimensions.get('window').height;
useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(backdropAnim, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
]).start();
} else {
Animated.parallel([
Animated.timing(backdropAnim, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}),
]).start();
}
}, [visible, backdropAnim, slideAnim]);
// Android back button
useEffect(() => {
if (!visible) return;
const handler = BackHandler.addEventListener('hardwareBackPress', () => {
onDismiss();
return true;
});
return () => handler.remove();
}, [visible, onDismiss]);
if (!visible) return null;
const translateY = slideAnim.interpolate({
inputRange: [0, 1],
outputRange: [screenHeight, 0],
});
return (
<Portal>
<View style={styles.overlay}>
{/* Backdrop */}
<Animated.View style={[styles.backdrop, {opacity: backdropAnim}]}>
<Pressable style={StyleSheet.absoluteFill} onPress={onDismiss} />
</Animated.View>
{/* Content */}
<Animated.View
style={[
styles.content,
{
backgroundColor: theme.colors.background,
transform: [{translateY}],
maxHeight: screenHeight * 0.85,
},
style,
]}>
{/* Header */}
<View style={[styles.header, {backgroundColor: accentColor}]}>
<View style={styles.headerLeft}>
<Text
variant="titleMedium"
style={[styles.headerText, {color: theme.colors.onPrimary}]}>
{heading}
</Text>
{activeFilterCount !== undefined && activeFilterCount > 0 && (
<View style={[styles.countBadge, {backgroundColor: theme.colors.onPrimary}]}>
<Text style={[styles.countText, {color: accentColor}]}>
{activeFilterCount}
</Text>
</View>
)}
</View>
<Pressable onPress={onDismiss} hitSlop={8}>
<Text style={[styles.closeText, {color: theme.colors.onPrimary}]}>
CLOSE
</Text>
</Pressable>
</View>
{/* Clear all button */}
{onClearAll && activeFilterCount !== undefined && activeFilterCount > 0 && (
<Pressable
onPress={onClearAll}
style={[styles.clearAll, {borderBottomColor: accentColor}]}>
<Text style={[styles.clearAllText, {color: accentColor}]}>
Clear All Filters
</Text>
</Pressable>
)}
{/* Filter content */}
<View style={styles.body}>{children}</View>
</Animated.View>
</View>
</Portal>
);
}
const styles = StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
zIndex: 1000,
justifyContent: 'flex-end',
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
content: {
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
overflow: 'hidden',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
},
headerLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
headerText: {
fontWeight: '700',
letterSpacing: 0.5,
},
countBadge: {
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 10,
minWidth: 24,
alignItems: 'center',
},
countText: {
fontSize: 12,
fontWeight: '700',
},
closeText: {
fontSize: 14,
fontWeight: '600',
letterSpacing: 1,
},
clearAll: {
paddingHorizontal: 16,
paddingVertical: 10,
borderBottomWidth: 1,
},
clearAllText: {
fontSize: 14,
fontWeight: '600',
},
body: {
flexGrow: 1,
},
});

View File

@@ -0,0 +1,66 @@
/**
* DT Stagger Container
*
* Animates children with staggered scale-in entrances.
* Source: dt-shopify-storefront framer-motion staggered card entrance pattern.
*/
import {ReactNode, Children, useRef, useEffect} from 'react';
import {Animated, ViewStyle, StyleProp} from 'react-native';
interface DTStaggerContainerProps {
children: ReactNode;
/**
* Duration of each child's scale-in animation in ms
* @default 330
*/
duration?: number;
/**
* Delay between each child's animation start in ms
* @default 75
*/
interval?: number;
/**
* Additional styles for the container
*/
style?: StyleProp<ViewStyle>;
}
export function DTStaggerContainer({
children,
duration = 330,
interval = 75,
style,
}: DTStaggerContainerProps) {
const childArray = Children.toArray(children);
const animations = useRef(childArray.map(() => new Animated.Value(0))).current;
useEffect(() => {
// Reset animations if child count changes
while (animations.length < childArray.length) {
animations.push(new Animated.Value(0));
}
const staggered = childArray.map((_, i) =>
Animated.timing(animations[i], {
toValue: 1,
duration,
delay: i * interval,
useNativeDriver: true,
}),
);
Animated.parallel(staggered).start();
}, [childArray.length, duration, interval]);
return (
<Animated.View style={style}>
{childArray.map((child, i) => (
<Animated.View
key={i}
style={{transform: [{scale: animations[i] ?? new Animated.Value(1)}]}}>
{child}
</Animated.View>
))}
</Animated.View>
);
}

View File

@@ -30,6 +30,7 @@ export type { DTVariant } from './utils/variantColors';
export { getVariantColor } from './utils/variantColors';
export { buildBeveledRectPath } from './utils/bevelPaths';
export { useComponentLayout } from './utils/useComponentLayout';
export { useScaleIn, usePulse } from './utils/animations';
// Component exports — existing
export { DTCard, DTCardClipPath } from './components/DTCard';
@@ -50,6 +51,10 @@ export { DTMediaFrame } from './components/DTMediaFrame';
export { DTAccordion } from './components/DTAccordion';
export type { DTAccordionSection } from './components/DTAccordion';
// Component exports — positioning & animation containers
export { DTBadgeOverlay } from './components/DTBadgeOverlay';
export { DTStaggerContainer } from './components/DTStaggerContainer';
// Component exports — complex interactive
export { DTModal } from './components/DTModal';
export { DTDrawer } from './components/DTDrawer';
@@ -57,6 +62,11 @@ export { DTGallery } from './components/DTGallery';
export type { DTGalleryItem } from './components/DTGallery';
export { DTSearchInput } from './components/DTSearchInput';
// Component exports — filter & feature
export { DTFeatureLegend } from './components/DTFeatureLegend';
export type { DTFeatureItem } from './components/DTFeatureLegend';
export { DTMobileFilterOverlay } from './components/DTMobileFilterOverlay';
// Component exports — navigation & decorative
export { DTMenu, DTMenuDropdown } from './components/DTMenu';
export type { DTMenuItem } from './components/DTMenu';

View File

@@ -0,0 +1,73 @@
/**
* Reusable animation hooks for DT components
*
* Provides common animation patterns (scale-in, pulse) as hooks
* that consumers and new components can use consistently.
*/
import {useRef, useEffect} from 'react';
import {Animated} from 'react-native';
/**
* Scale-in entrance animation.
* Returns an Animated.Value (0→1) to use as a scale transform.
*/
export function useScaleIn(options?: {
enabled?: boolean;
duration?: number;
delay?: number;
}): Animated.Value {
const {enabled = true, duration = 330, delay = 0} = options ?? {};
const anim = useRef(new Animated.Value(enabled ? 0 : 1)).current;
useEffect(() => {
if (enabled) {
anim.setValue(0);
Animated.timing(anim, {
toValue: 1,
duration,
delay,
useNativeDriver: true,
}).start();
} else {
anim.setValue(1);
}
}, [enabled, duration, delay, anim]);
return anim;
}
/**
* Pulse (ping) opacity animation loop.
* Returns an Animated.Value that oscillates between 1 and 0.3.
*/
export function usePulse(enabled = true): Animated.Value {
const anim = useRef(new Animated.Value(1)).current;
useEffect(() => {
if (enabled) {
const loop = Animated.loop(
Animated.sequence([
Animated.timing(anim, {
toValue: 0.3,
duration: 1000,
useNativeDriver: true,
}),
Animated.timing(anim, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
}),
]),
);
loop.start();
return () => {
loop.stop();
anim.setValue(1);
};
}
anim.setValue(1);
}, [enabled, anim]);
return anim;
}

View File

@@ -6,8 +6,10 @@
*/
import type {DTExtendedTheme} from '../theme/paperTheme';
import type {DTVariant} from '@dangerousthings/tokens';
export type DTVariant = 'normal' | 'emphasis' | 'warning' | 'success' | 'other';
// Re-export DTVariant from the canonical source (tokens package)
export type {DTVariant} from '@dangerousthings/tokens';
/** String-typed keys in DTExtendedTheme['custom'] that map to mode colors */
type ModeColorKey = 'modeNormal' | 'modeEmphasis' | 'modeWarning' | 'modeSuccess' | 'modeOther';