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,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"
|
||||
|
||||
45
packages/react-native/src/components/DTBadgeOverlay.tsx
Normal file
45
packages/react-native/src/components/DTBadgeOverlay.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (0–1) 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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -61,6 +61,7 @@ export function DTChip({
|
||||
<Chip
|
||||
{...props}
|
||||
mode="outlined"
|
||||
showSelectedCheck={false}
|
||||
textStyle={[
|
||||
styles.text,
|
||||
{ color: selected ? theme.colors.onPrimary : color },
|
||||
|
||||
136
packages/react-native/src/components/DTFeatureLegend.tsx
Normal file
136
packages/react-native/src/components/DTFeatureLegend.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
230
packages/react-native/src/components/DTMobileFilterOverlay.tsx
Normal file
230
packages/react-native/src/components/DTMobileFilterOverlay.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
66
packages/react-native/src/components/DTStaggerContainer.tsx
Normal file
66
packages/react-native/src/components/DTStaggerContainer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
73
packages/react-native/src/utils/animations.ts
Normal file
73
packages/react-native/src/utils/animations.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user