initial commit
This commit is contained in:
49
packages/react-native/package.json
Normal file
49
packages/react-native/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@dangerousthings/react-native",
|
||||
"version": "0.1.0",
|
||||
"description": "React Native themed components for the Dangerous Things design system",
|
||||
"license": "MIT",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dangerousthings/tokens": "*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.0.0",
|
||||
"react-native": ">=0.72.0",
|
||||
"react-native-paper": ">=5.0.0",
|
||||
"react-native-safe-area-context": ">=4.0.0",
|
||||
"react-native-svg": ">=13.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"expo-font": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-native": "^0.72.8",
|
||||
"react-native-paper": "^5.15.0",
|
||||
"react-native-safe-area-context": "^5.7.0",
|
||||
"react-native-svg": "^15.15.3",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
276
packages/react-native/src/components/DTAccordion.tsx
Normal file
276
packages/react-native/src/components/DTAccordion.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* DT Accordion Component
|
||||
*
|
||||
* Custom-built accordion with animated height expansion following
|
||||
* the Dangerous Things angular aesthetic.
|
||||
*
|
||||
* Web CSS reference (FilterAccordion):
|
||||
* - menu-item-clipped style headers with thick top border
|
||||
* - Expand/collapse with height animation
|
||||
* - Active sections switch to activeVariant color
|
||||
*/
|
||||
|
||||
import {useState, useCallback, useRef, useEffect} from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
ViewStyle,
|
||||
StyleProp,
|
||||
Pressable,
|
||||
Animated,
|
||||
} from 'react-native';
|
||||
import {Text} from 'react-native-paper';
|
||||
import {DTColors} from '../theme/colors';
|
||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||
|
||||
export interface DTAccordionSection {
|
||||
/**
|
||||
* Unique key for the section
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* Section header title
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Section content (rendered when expanded)
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface DTAccordionProps {
|
||||
/**
|
||||
* Array of accordion sections
|
||||
*/
|
||||
sections: DTAccordionSection[];
|
||||
/**
|
||||
* Visual variant for section headers
|
||||
* @default 'normal'
|
||||
*/
|
||||
variant?: DTVariant;
|
||||
/**
|
||||
* Variant color for active/expanded sections
|
||||
* @default 'emphasis'
|
||||
*/
|
||||
activeVariant?: DTVariant;
|
||||
/**
|
||||
* Allow multiple sections open simultaneously
|
||||
* @default false
|
||||
*/
|
||||
allowMultiple?: boolean;
|
||||
/**
|
||||
* Section keys to open initially
|
||||
*/
|
||||
initialOpenKeys?: string[];
|
||||
/**
|
||||
* Called when a section is toggled
|
||||
*/
|
||||
onSectionToggle?: (key: string, isOpen: boolean) => void;
|
||||
/**
|
||||
* Additional styles
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* DT-styled Accordion with animated height expansion
|
||||
*
|
||||
* @example
|
||||
* <DTAccordion
|
||||
* sections={[
|
||||
* { key: 'size', title: 'Size', children: <SizeFilter /> },
|
||||
* { key: 'color', title: 'Color', children: <ColorFilter /> },
|
||||
* ]}
|
||||
* variant="normal"
|
||||
* />
|
||||
*/
|
||||
export function DTAccordion({
|
||||
sections,
|
||||
variant = 'normal',
|
||||
activeVariant = 'emphasis',
|
||||
allowMultiple = false,
|
||||
initialOpenKeys,
|
||||
onSectionToggle,
|
||||
style,
|
||||
}: DTAccordionProps) {
|
||||
const [openKeys, setOpenKeys] = useState<Set<string>>(
|
||||
new Set(initialOpenKeys),
|
||||
);
|
||||
|
||||
const toggleSection = useCallback(
|
||||
(key: string) => {
|
||||
setOpenKeys(prev => {
|
||||
const next = new Set(allowMultiple ? prev : []);
|
||||
const isOpening = !prev.has(key);
|
||||
if (isOpening) {
|
||||
next.add(key);
|
||||
} else {
|
||||
next.delete(key);
|
||||
}
|
||||
onSectionToggle?.(key, isOpening);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[allowMultiple, onSectionToggle],
|
||||
);
|
||||
|
||||
const inactiveColor = getVariantColor(variant);
|
||||
const activeColor = getVariantColor(activeVariant);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
{sections.map(section => (
|
||||
<AccordionSection
|
||||
key={section.key}
|
||||
section={section}
|
||||
isOpen={openKeys.has(section.key)}
|
||||
onToggle={() => toggleSection(section.key)}
|
||||
inactiveColor={inactiveColor}
|
||||
activeColor={activeColor}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionSection({
|
||||
section,
|
||||
isOpen,
|
||||
onToggle,
|
||||
inactiveColor,
|
||||
activeColor,
|
||||
}: {
|
||||
section: DTAccordionSection;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
inactiveColor: string;
|
||||
activeColor: string;
|
||||
}) {
|
||||
const heightAnim = useRef(new Animated.Value(isOpen ? 1 : 0)).current;
|
||||
const chevronAnim = useRef(new Animated.Value(isOpen ? 1 : 0)).current;
|
||||
const [contentHeight, setContentHeight] = useState(0);
|
||||
const [measured, setMeasured] = useState(false);
|
||||
const sectionColor = isOpen ? activeColor : inactiveColor;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.parallel([
|
||||
Animated.timing(heightAnim, {
|
||||
toValue: isOpen ? 1 : 0,
|
||||
duration: 250,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
Animated.timing(chevronAnim, {
|
||||
toValue: isOpen ? 1 : 0,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}, [isOpen, heightAnim, chevronAnim]);
|
||||
|
||||
const animatedHeight = heightAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0, contentHeight],
|
||||
});
|
||||
|
||||
const chevronRotate = chevronAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0deg', '180deg'],
|
||||
});
|
||||
|
||||
return (
|
||||
<View>
|
||||
{/* Header */}
|
||||
<Pressable
|
||||
onPress={onToggle}
|
||||
style={({pressed}) => [
|
||||
styles.header,
|
||||
{
|
||||
borderColor: sectionColor,
|
||||
borderTopColor: sectionColor,
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
},
|
||||
]}>
|
||||
<Text style={[styles.title, {color: sectionColor}]}>
|
||||
{section.title}
|
||||
</Text>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.chevron,
|
||||
{transform: [{rotate: chevronRotate}]},
|
||||
]}>
|
||||
<View
|
||||
style={[styles.chevronArrow, {borderTopColor: sectionColor}]}
|
||||
/>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
|
||||
{/* Animated content */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.contentWrapper,
|
||||
{
|
||||
height: measured ? animatedHeight : undefined,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={styles.content}
|
||||
onLayout={e => {
|
||||
const h = e.nativeEvent.layout.height;
|
||||
if (h > 0 && !measured) {
|
||||
setContentHeight(h);
|
||||
setMeasured(true);
|
||||
if (!isOpen) {
|
||||
heightAnim.setValue(0);
|
||||
}
|
||||
}
|
||||
}}>
|
||||
{section.children}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
gap: 4,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: DTColors.dark,
|
||||
borderWidth: 1,
|
||||
borderTopWidth: 5,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
title: {
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.5,
|
||||
textTransform: 'uppercase',
|
||||
fontSize: 14,
|
||||
},
|
||||
contentWrapper: {},
|
||||
content: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: DTColors.dark,
|
||||
},
|
||||
chevron: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
chevronArrow: {
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeftWidth: 6,
|
||||
borderRightWidth: 6,
|
||||
borderTopWidth: 8,
|
||||
borderLeftColor: 'transparent',
|
||||
borderRightColor: 'transparent',
|
||||
},
|
||||
});
|
||||
151
packages/react-native/src/components/DTButton.tsx
Normal file
151
packages/react-native/src/components/DTButton.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* DT Button Component
|
||||
*
|
||||
* A themed button following the Dangerous Things design language
|
||||
* with SVG-based beveled corners.
|
||||
*/
|
||||
|
||||
import {StyleSheet, View, ViewStyle, StyleProp, Pressable} from 'react-native';
|
||||
import {Text} from 'react-native-paper';
|
||||
import Svg, {Path} from 'react-native-svg';
|
||||
import {DTColors} from '../theme/colors';
|
||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||
import {buildButtonBevelPath} from '../utils/bevelPaths';
|
||||
import {useComponentLayout} from '../utils/useComponentLayout';
|
||||
import {useState} from 'react';
|
||||
|
||||
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 (default)
|
||||
* - 'contained': Filled background
|
||||
* @default 'outlined'
|
||||
*/
|
||||
mode?: 'outlined' | 'contained';
|
||||
/**
|
||||
* 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
|
||||
* @default 8
|
||||
*/
|
||||
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',
|
||||
color,
|
||||
onPress,
|
||||
disabled = false,
|
||||
style,
|
||||
borderWidth = 2,
|
||||
bevelSize = 8,
|
||||
}: DTButtonProps) {
|
||||
const {dimensions, onLayout, hasDimensions} = useComponentLayout();
|
||||
const [pressed, setPressed] = useState(false);
|
||||
|
||||
const accentColor = getVariantColor(variant, color);
|
||||
const isContained = mode === 'contained';
|
||||
const bgColor = isContained ? accentColor : 'transparent';
|
||||
const textColor = isContained ? DTColors.dark : accentColor;
|
||||
|
||||
const {width, height} = dimensions;
|
||||
const opacity = disabled ? 0.5 : pressed ? 0.7 : 1;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
onPressIn={() => setPressed(true)}
|
||||
onPressOut={() => setPressed(false)}
|
||||
style={[{opacity}, style]}>
|
||||
<View style={styles.container} onLayout={onLayout}>
|
||||
{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}
|
||||
/>
|
||||
</Svg>
|
||||
)}
|
||||
<View style={styles.content}>
|
||||
{typeof children === 'string' ? (
|
||||
<Text variant="labelLarge" style={[styles.label, {color: textColor}]}>
|
||||
{children}
|
||||
</Text>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</View>
|
||||
</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,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
});
|
||||
221
packages/react-native/src/components/DTCard.tsx
Normal file
221
packages/react-native/src/components/DTCard.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* DT Card Component
|
||||
*
|
||||
* A themed card following the Dangerous Things design language
|
||||
* with SVG-based beveled corners matching the web storefront style.
|
||||
*
|
||||
* Web CSS reference:
|
||||
* - Bottom-right bevel: 2em (~32px)
|
||||
* - Bottom-left bevel: 1em (~16px)
|
||||
* - Border width: 0.2em (~3px)
|
||||
*/
|
||||
|
||||
import {ReactNode} from 'react';
|
||||
import {StyleSheet, View, ViewStyle, StyleProp, Pressable} from 'react-native';
|
||||
import {Text} from 'react-native-paper';
|
||||
import Svg, {Path} from 'react-native-svg';
|
||||
import {DTColors} from '../theme/colors';
|
||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||
import {buildCardBevelPath} from '../utils/bevelPaths';
|
||||
import {useComponentLayout} from '../utils/useComponentLayout';
|
||||
|
||||
interface DTCardProps {
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Color mode for the card accent
|
||||
* @default 'normal'
|
||||
*/
|
||||
mode?: DTVariant;
|
||||
/**
|
||||
* Custom border color (overrides mode)
|
||||
*/
|
||||
borderColor?: string;
|
||||
/**
|
||||
* Card title (displayed in header)
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* Whether to show the accent header bar
|
||||
* @default true when title is provided
|
||||
*/
|
||||
showHeader?: boolean;
|
||||
/**
|
||||
* Additional styles for the card container
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/**
|
||||
* Additional styles for the content area
|
||||
*/
|
||||
contentStyle?: StyleProp<ViewStyle>;
|
||||
/**
|
||||
* Inner content padding
|
||||
* @default 16
|
||||
*/
|
||||
padding?: number;
|
||||
/**
|
||||
* Border width in pixels
|
||||
* @default 3
|
||||
*/
|
||||
borderWidth?: number;
|
||||
/**
|
||||
* Bottom-right bevel size in pixels
|
||||
* @default 32
|
||||
*/
|
||||
bevelSize?: number;
|
||||
/**
|
||||
* Bottom-left bevel size in pixels (smaller accent bevel)
|
||||
* @default 16
|
||||
*/
|
||||
bevelSizeSmall?: number;
|
||||
/**
|
||||
* Background color
|
||||
* @default '#000000'
|
||||
*/
|
||||
backgroundColor?: string;
|
||||
/**
|
||||
* Press handler
|
||||
*/
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* DT-styled Card component with SVG beveled corners
|
||||
*
|
||||
* @example
|
||||
* <DTCard title="Detected Chip" mode="normal">
|
||||
* <Text>NTAG215</Text>
|
||||
* </DTCard>
|
||||
*
|
||||
* @example
|
||||
* <DTCard mode="emphasis" onPress={handlePress}>
|
||||
* <Text>Tap to scan</Text>
|
||||
* </DTCard>
|
||||
*/
|
||||
export function DTCard({
|
||||
children,
|
||||
mode = 'normal',
|
||||
borderColor,
|
||||
title,
|
||||
showHeader,
|
||||
style,
|
||||
contentStyle,
|
||||
padding = 16,
|
||||
borderWidth = 3,
|
||||
bevelSize = 32,
|
||||
bevelSizeSmall = 16,
|
||||
backgroundColor = '#000000',
|
||||
onPress,
|
||||
}: DTCardProps) {
|
||||
const {dimensions, onLayout, hasDimensions} = useComponentLayout();
|
||||
const accentColor = getVariantColor(mode, borderColor);
|
||||
const shouldShowHeader = showHeader ?? !!title;
|
||||
|
||||
const {width, height} = dimensions;
|
||||
|
||||
// Outer bevel path (full card shape)
|
||||
const outerPath = hasDimensions
|
||||
? buildCardBevelPath(width, height, bevelSize, bevelSizeSmall, 0)
|
||||
: '';
|
||||
// Inner bevel path at border inset (for background + frame cutout)
|
||||
const innerPath = hasDimensions
|
||||
? buildCardBevelPath(width, height, bevelSize - borderWidth, bevelSizeSmall - borderWidth, borderWidth)
|
||||
: '';
|
||||
|
||||
const content = (
|
||||
<View style={[styles.container, style]} onLayout={onLayout}>
|
||||
{/* Background fill (behind content) */}
|
||||
{hasDimensions && (
|
||||
<Svg
|
||||
style={StyleSheet.absoluteFill}
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}>
|
||||
<Path d={innerPath} fill={backgroundColor} />
|
||||
</Svg>
|
||||
)}
|
||||
<View style={styles.innerContainer}>
|
||||
{shouldShowHeader && (
|
||||
<View style={[styles.header, {backgroundColor: accentColor}]}>
|
||||
{title && (
|
||||
<Text
|
||||
variant="titleMedium"
|
||||
style={[styles.headerText, {color: DTColors.dark}]}>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
<View style={[styles.content, {padding}, contentStyle]}>{children}</View>
|
||||
</View>
|
||||
{/* Frame overlay (above content) — clips content at beveled border */}
|
||||
{hasDimensions && (
|
||||
<Svg
|
||||
style={[StyleSheet.absoluteFill, styles.frameOverlay]}
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
pointerEvents="none">
|
||||
<Path
|
||||
d={outerPath + ' ' + innerPath}
|
||||
fillRule="evenodd"
|
||||
fill={accentColor}
|
||||
/>
|
||||
</Svg>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
if (onPress) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({pressed}) => ({opacity: pressed ? 0.8 : 1})}>
|
||||
{content}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* DT design system constants matching web CSS variables
|
||||
*/
|
||||
export const DTCardClipPath = {
|
||||
// CSS-style clip path (for web reference)
|
||||
css: `polygon(
|
||||
0% 0%,
|
||||
100% 0%,
|
||||
100% calc(100% - 2em),
|
||||
calc(100% - 2em) 100%,
|
||||
1em 100%,
|
||||
0% calc(100% - 1em)
|
||||
)`,
|
||||
// Default bevel sizes in pixels (matching 2em and 1em at 16px base)
|
||||
bevelSize: 32,
|
||||
bevelSizeSmall: 16,
|
||||
borderWidth: 3,
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'relative',
|
||||
},
|
||||
innerContainer: {
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
headerText: {
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
content: {},
|
||||
frameOverlay: {
|
||||
zIndex: 2,
|
||||
},
|
||||
});
|
||||
140
packages/react-native/src/components/DTCheckbox.tsx
Normal file
140
packages/react-native/src/components/DTCheckbox.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* DT Checkbox Component
|
||||
*
|
||||
* Custom-built angular checkbox following the Dangerous Things design language.
|
||||
* Uses SVG for beveled shape and checkmark — RNP Checkbox enforces
|
||||
* rounded corners (borderRadius: 18) so we build from scratch.
|
||||
*
|
||||
* Uses diagonal-symmetry bevels (top-left + bottom-right) on the indicator.
|
||||
*/
|
||||
|
||||
import {StyleSheet, ViewStyle, TextStyle, StyleProp, Pressable} from 'react-native';
|
||||
import {Text} from 'react-native-paper';
|
||||
import Svg, {Path} from 'react-native-svg';
|
||||
import {DTColors} from '../theme/colors';
|
||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||
import {buildBeveledRectPath} from '../utils/bevelPaths';
|
||||
|
||||
interface DTCheckboxProps {
|
||||
/**
|
||||
* Whether the checkbox is checked
|
||||
*/
|
||||
checked: boolean;
|
||||
/**
|
||||
* Press handler to toggle state
|
||||
*/
|
||||
onPress: () => void;
|
||||
/**
|
||||
* Visual variant
|
||||
* @default 'normal'
|
||||
*/
|
||||
variant?: DTVariant;
|
||||
/**
|
||||
* Label text displayed beside the checkbox
|
||||
*/
|
||||
label?: string;
|
||||
/**
|
||||
* Whether the checkbox is disabled
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Size of the checkbox in pixels
|
||||
* @default 24
|
||||
*/
|
||||
size?: number;
|
||||
/**
|
||||
* Custom color (overrides variant)
|
||||
*/
|
||||
color?: string;
|
||||
/**
|
||||
* Additional styles for the container
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/**
|
||||
* Additional styles for the label text
|
||||
*/
|
||||
labelStyle?: StyleProp<TextStyle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* DT-styled Checkbox with beveled opposing corners (top-left + bottom-right)
|
||||
*
|
||||
* @example
|
||||
* <DTCheckbox checked={isChecked} onPress={() => setChecked(!isChecked)} label="Remember me" />
|
||||
*
|
||||
* @example
|
||||
* <DTCheckbox checked={true} onPress={toggle} variant="success" label="Accepted" />
|
||||
*/
|
||||
export function DTCheckbox({
|
||||
checked,
|
||||
onPress,
|
||||
variant = 'normal',
|
||||
label,
|
||||
disabled = false,
|
||||
size = 24,
|
||||
color,
|
||||
style,
|
||||
labelStyle,
|
||||
}: DTCheckboxProps) {
|
||||
const accentColor = getVariantColor(variant, color);
|
||||
const opacity = disabled ? 0.5 : 1;
|
||||
const borderWidth = 2;
|
||||
const bevelSize = Math.round(size * 0.3);
|
||||
|
||||
// Outer beveled path (border)
|
||||
const outerPath = buildBeveledRectPath(size, size, {
|
||||
corners: {topLeft: bevelSize, bottomRight: bevelSize},
|
||||
strokeWidth: borderWidth,
|
||||
});
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
style={({pressed}) => [
|
||||
styles.container,
|
||||
{opacity: pressed ? 0.7 : opacity},
|
||||
style,
|
||||
]}>
|
||||
<Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
{/* Beveled border */}
|
||||
<Path
|
||||
d={outerPath}
|
||||
fill={checked ? accentColor : 'transparent'}
|
||||
stroke={accentColor}
|
||||
strokeWidth={borderWidth}
|
||||
/>
|
||||
{/* Checkmark path (only when checked) */}
|
||||
{checked && (
|
||||
<Path
|
||||
d={`M ${size * 0.2} ${size * 0.5} L ${size * 0.4} ${size * 0.7} L ${size * 0.8} ${size * 0.25}`}
|
||||
fill="none"
|
||||
stroke={DTColors.dark}
|
||||
strokeWidth={borderWidth + 0.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)}
|
||||
</Svg>
|
||||
{label && (
|
||||
<Text style={[styles.label, {color: DTColors.light}, labelStyle]}>
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
minHeight: 44,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
});
|
||||
95
packages/react-native/src/components/DTChip.tsx
Normal file
95
packages/react-native/src/components/DTChip.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* DT Chip Component
|
||||
*
|
||||
* A themed chip/tag following the Dangerous Things design language.
|
||||
* Useful for displaying chip types, statuses, and labels.
|
||||
*/
|
||||
|
||||
// React import not needed with new JSX transform
|
||||
import { StyleSheet, ViewStyle, StyleProp } from 'react-native';
|
||||
import { Chip, ChipProps } from 'react-native-paper';
|
||||
import { DTColors } from '../theme/colors';
|
||||
|
||||
type DTChipVariant = 'normal' | 'emphasis' | 'warning' | 'success' | 'other';
|
||||
|
||||
interface DTChipProps extends Omit<ChipProps, 'mode'> {
|
||||
/**
|
||||
* Visual variant of the chip
|
||||
* @default 'normal'
|
||||
*/
|
||||
variant?: DTChipVariant;
|
||||
/**
|
||||
* Whether the chip is in selected state
|
||||
* @default false
|
||||
*/
|
||||
selected?: boolean;
|
||||
/**
|
||||
* Additional styles
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
const variantColors: Record<DTChipVariant, string> = {
|
||||
normal: DTColors.modeNormal,
|
||||
emphasis: DTColors.modeEmphasis,
|
||||
warning: DTColors.modeWarning,
|
||||
success: DTColors.modeSuccess,
|
||||
other: DTColors.modeOther,
|
||||
};
|
||||
|
||||
/**
|
||||
* DT-styled Chip component
|
||||
*
|
||||
* @example
|
||||
* <DTChip variant="normal">NTAG215</DTChip>
|
||||
*
|
||||
* @example
|
||||
* <DTChip variant="success" selected>Cloneable</DTChip>
|
||||
*
|
||||
* @example
|
||||
* <DTChip variant="warning">Non-Cloneable</DTChip>
|
||||
*/
|
||||
export function DTChip({
|
||||
variant = 'normal',
|
||||
selected = false,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: DTChipProps) {
|
||||
const color = variantColors[variant];
|
||||
|
||||
const chipStyle: ViewStyle = {
|
||||
backgroundColor: selected ? color : 'transparent',
|
||||
borderColor: color,
|
||||
borderWidth: 1,
|
||||
borderRadius: 0, // Angular DT style
|
||||
};
|
||||
|
||||
return (
|
||||
<Chip
|
||||
{...props}
|
||||
mode="outlined"
|
||||
textStyle={[
|
||||
styles.text,
|
||||
{ color: selected ? DTColors.dark : color },
|
||||
]}
|
||||
style={[styles.chip, chipStyle, style]}
|
||||
selectedColor={color}
|
||||
selected={selected}
|
||||
>
|
||||
{children}
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
chip: {
|
||||
marginRight: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
text: {
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.5,
|
||||
fontSize: 12,
|
||||
},
|
||||
});
|
||||
279
packages/react-native/src/components/DTDrawer.tsx
Normal file
279
packages/react-native/src/components/DTDrawer.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* 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 {DTColors} from '../theme/colors';
|
||||
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 slideAnim = useRef(new Animated.Value(0)).current;
|
||||
const overlayAnim = useRef(new Animated.Value(0)).current;
|
||||
const headerColor = getVariantColor(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, styles.overlay]}
|
||||
onPress={onDismiss}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
{/* Drawer panel */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.panel,
|
||||
position === 'right' ? styles.panelRight : styles.panelLeft,
|
||||
{width: effectiveWidth, transform: [{translateX}]},
|
||||
style,
|
||||
]}>
|
||||
{/* Dual-SVG-path technique: outer border + inner dark fill */}
|
||||
<View style={StyleSheet.absoluteFill}>
|
||||
<Svg width={effectiveWidth} height={screenHeight}>
|
||||
{/* Outer path: accent border color */}
|
||||
<Path
|
||||
d={buildDrawerBevelPath(
|
||||
effectiveWidth,
|
||||
screenHeight,
|
||||
bevelSize,
|
||||
position,
|
||||
)}
|
||||
fill={headerColor}
|
||||
/>
|
||||
{/* Inner path: dark background (inset by borderWidth) */}
|
||||
<Path
|
||||
d={buildDrawerBevelPath(
|
||||
effectiveWidth - borderWidth * 2,
|
||||
screenHeight - borderWidth * 2,
|
||||
bevelSize - borderWidth,
|
||||
position,
|
||||
)}
|
||||
fill={DTColors.dark}
|
||||
transform={`translate(${borderWidth}, ${borderWidth})`}
|
||||
/>
|
||||
</Svg>
|
||||
</View>
|
||||
|
||||
{/* Header bar */}
|
||||
<View style={[styles.header, {backgroundColor: headerColor, paddingTop: insets.top + 16}]}>
|
||||
<Text style={styles.headerText}>{heading}</Text>
|
||||
<Pressable
|
||||
onPress={onDismiss}
|
||||
style={({pressed}) => ({opacity: pressed ? 0.7 : 1})}>
|
||||
<Text style={styles.closeButton}>✕</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({
|
||||
overlay: {
|
||||
backgroundColor: DTColors.overlay,
|
||||
},
|
||||
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: {
|
||||
color: DTColors.dark,
|
||||
fontWeight: '700',
|
||||
fontSize: 18,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
closeButton: {
|
||||
color: DTColors.dark,
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
zIndex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
padding: 16,
|
||||
},
|
||||
});
|
||||
366
packages/react-native/src/components/DTGallery.tsx
Normal file
366
packages/react-native/src/components/DTGallery.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* DT Gallery Component
|
||||
*
|
||||
* Custom-built image gallery with thumbnail navigation following
|
||||
* the Dangerous Things design language. No RNP equivalent.
|
||||
*
|
||||
* Uses the dual-SVG-path technique for the main image frame,
|
||||
* consistent with DTCard/DTMediaFrame.
|
||||
*
|
||||
* Web CSS reference (CyberGallery):
|
||||
* - Horizontal thumbnail strip with arrow navigation
|
||||
* - Active thumbnail with glow effect (box-shadow: 0 0 8px)
|
||||
* - Beveled arrow buttons
|
||||
* - Main image in beveled media frame
|
||||
*/
|
||||
|
||||
import {useRef, useCallback} from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
ViewStyle,
|
||||
StyleProp,
|
||||
Pressable,
|
||||
Image,
|
||||
FlatList,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import Svg, {Path} from 'react-native-svg';
|
||||
import {DTColors} from '../theme/colors';
|
||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||
import {buildButtonBevelPath, buildMediaFrameBevelPath} from '../utils/bevelPaths';
|
||||
import {useComponentLayout} from '../utils/useComponentLayout';
|
||||
|
||||
export interface DTGalleryItem {
|
||||
/**
|
||||
* Unique identifier
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Image URI
|
||||
*/
|
||||
uri: string;
|
||||
/**
|
||||
* Accessibility label
|
||||
*/
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
interface DTGalleryProps {
|
||||
/**
|
||||
* Gallery items
|
||||
*/
|
||||
items: DTGalleryItem[];
|
||||
/**
|
||||
* Currently active item index
|
||||
*/
|
||||
activeIndex: number;
|
||||
/**
|
||||
* Called when an item is selected
|
||||
*/
|
||||
onSelect: (index: number) => void;
|
||||
/**
|
||||
* Visual variant
|
||||
* @default 'normal'
|
||||
*/
|
||||
variant?: DTVariant;
|
||||
/**
|
||||
* Thumbnail size in pixels
|
||||
* @default 64
|
||||
*/
|
||||
thumbnailSize?: number;
|
||||
/**
|
||||
* Bevel size for the main image frame
|
||||
* @default 24
|
||||
*/
|
||||
bevelSize?: number;
|
||||
/**
|
||||
* Border width for the main image frame
|
||||
* @default 3
|
||||
*/
|
||||
borderWidth?: number;
|
||||
/**
|
||||
* Additional styles
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
const ARROW_SIZE = 32;
|
||||
const ARROW_BEVEL = 4;
|
||||
const ARROW_BORDER = 2;
|
||||
|
||||
/**
|
||||
* DT-styled Gallery with thumbnail strip and arrow navigation
|
||||
*
|
||||
* @example
|
||||
* <DTGallery
|
||||
* items={images}
|
||||
* activeIndex={activeIdx}
|
||||
* onSelect={setActiveIdx}
|
||||
* variant="normal"
|
||||
* />
|
||||
*/
|
||||
export function DTGallery({
|
||||
items,
|
||||
activeIndex,
|
||||
onSelect,
|
||||
variant = 'normal',
|
||||
thumbnailSize = 64,
|
||||
bevelSize = 24,
|
||||
borderWidth = 3,
|
||||
style,
|
||||
}: DTGalleryProps) {
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
const {dimensions, onLayout, hasDimensions} = useComponentLayout();
|
||||
const accentColor = getVariantColor(variant);
|
||||
const atStart = activeIndex <= 0;
|
||||
const atEnd = activeIndex >= items.length - 1;
|
||||
|
||||
const handlePrev = useCallback(() => {
|
||||
if (activeIndex > 0) {
|
||||
onSelect(activeIndex - 1);
|
||||
}
|
||||
}, [activeIndex, onSelect]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (activeIndex < items.length - 1) {
|
||||
onSelect(activeIndex + 1);
|
||||
}
|
||||
}, [activeIndex, items.length, onSelect]);
|
||||
|
||||
const scrollToActive = useCallback(
|
||||
(index: number) => {
|
||||
flatListRef.current?.scrollToIndex({
|
||||
index,
|
||||
animated: true,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(index: number) => {
|
||||
onSelect(index);
|
||||
scrollToActive(index);
|
||||
},
|
||||
[onSelect, scrollToActive],
|
||||
);
|
||||
|
||||
const getItemLayout = useCallback(
|
||||
(_: unknown, index: number) => ({
|
||||
length: thumbnailSize + 8,
|
||||
offset: (thumbnailSize + 8) * index,
|
||||
index,
|
||||
}),
|
||||
[thumbnailSize],
|
||||
);
|
||||
|
||||
const renderArrow = (direction: 'left' | 'right', onPress: () => void, isDisabled: boolean) => {
|
||||
const center = ARROW_SIZE / 2;
|
||||
const arrowSize = ARROW_SIZE * 0.2;
|
||||
const iconPath =
|
||||
direction === 'left'
|
||||
? `M ${center + arrowSize} ${center - arrowSize} L ${center - arrowSize} ${center} L ${center + arrowSize} ${center + arrowSize}`
|
||||
: `M ${center - arrowSize} ${center - arrowSize} L ${center + arrowSize} ${center} L ${center - arrowSize} ${center + arrowSize}`;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={isDisabled}
|
||||
style={({pressed}) => ({
|
||||
opacity: isDisabled ? 0.3 : pressed ? 0.7 : 1,
|
||||
})}>
|
||||
<Svg width={ARROW_SIZE} height={ARROW_SIZE} viewBox={`0 0 ${ARROW_SIZE} ${ARROW_SIZE}`}>
|
||||
<Path
|
||||
d={buildButtonBevelPath(ARROW_SIZE, ARROW_SIZE, ARROW_BEVEL, ARROW_BORDER)}
|
||||
fill="transparent"
|
||||
stroke={accentColor}
|
||||
strokeWidth={ARROW_BORDER}
|
||||
/>
|
||||
<Path
|
||||
d={iconPath}
|
||||
fill="none"
|
||||
stroke={accentColor}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const renderThumbnail = ({item, index}: {item: DTGalleryItem; index: number}) => {
|
||||
const isActive = index === activeIndex;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => handleSelect(index)}
|
||||
style={({pressed}) => ({opacity: pressed ? 0.7 : 1})}>
|
||||
<View
|
||||
style={[
|
||||
styles.thumbnail,
|
||||
{
|
||||
width: thumbnailSize,
|
||||
height: thumbnailSize,
|
||||
borderColor: isActive ? accentColor : 'transparent',
|
||||
},
|
||||
isActive && styles.thumbnailActive,
|
||||
]}>
|
||||
{/* Android glow: colored border overlay since elevation is gray-only */}
|
||||
{isActive && Platform.OS === 'android' && (
|
||||
<View
|
||||
style={[
|
||||
StyleSheet.absoluteFill,
|
||||
{
|
||||
borderWidth: 3,
|
||||
borderColor: accentColor,
|
||||
opacity: 0.5,
|
||||
margin: -3,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<Image
|
||||
source={{uri: item.uri}}
|
||||
style={styles.thumbnailImage}
|
||||
accessibilityLabel={item.alt}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const {width, height} = dimensions;
|
||||
|
||||
// Outer and inner bevel paths for frame overlay
|
||||
const outerPath = hasDimensions
|
||||
? buildMediaFrameBevelPath(width, height, bevelSize)
|
||||
: '';
|
||||
const innerPath = hasDimensions
|
||||
? buildMediaFrameBevelPath(width, height, bevelSize - borderWidth, borderWidth)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
{/* Main image with beveled frame */}
|
||||
<View style={styles.mainImageContainer} onLayout={onLayout}>
|
||||
{/* Background fill */}
|
||||
{hasDimensions && (
|
||||
<Svg
|
||||
style={StyleSheet.absoluteFill}
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}>
|
||||
<Path d={innerPath} fill={DTColors.surfaceVariant} />
|
||||
</Svg>
|
||||
)}
|
||||
<View style={[styles.mainImageContent, {padding: borderWidth}]}>
|
||||
<Image
|
||||
source={{uri: items[activeIndex]?.uri}}
|
||||
style={styles.mainImage}
|
||||
accessibilityLabel={items[activeIndex]?.alt}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
{/* Frame overlay (above content) — clips image at beveled border */}
|
||||
{hasDimensions && (
|
||||
<Svg
|
||||
style={[StyleSheet.absoluteFill, styles.frameOverlay]}
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
pointerEvents="none">
|
||||
{/* Corner mask: fill area outside outer bevel with dark to hide overflow */}
|
||||
<Path
|
||||
d={`M 0 0 L ${width} 0 L ${width} ${height} L 0 ${height} Z ` + outerPath}
|
||||
fillRule="evenodd"
|
||||
fill={DTColors.dark}
|
||||
/>
|
||||
{/* Colored border between outer and inner bevel paths */}
|
||||
<Path
|
||||
d={outerPath + ' ' + innerPath}
|
||||
fillRule="evenodd"
|
||||
fill={accentColor}
|
||||
/>
|
||||
</Svg>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Thumbnail strip with arrows */}
|
||||
{items.length > 1 && (
|
||||
<View style={styles.thumbnailStrip}>
|
||||
{renderArrow('left', handlePrev, atStart)}
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={items}
|
||||
renderItem={renderThumbnail}
|
||||
keyExtractor={item => item.id}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
getItemLayout={getItemLayout}
|
||||
contentContainerStyle={styles.thumbnailList}
|
||||
style={styles.thumbnailFlatList}
|
||||
initialNumToRender={8}
|
||||
maxToRenderPerBatch={4}
|
||||
windowSize={5}
|
||||
removeClippedSubviews={Platform.OS === 'android'}
|
||||
/>
|
||||
{renderArrow('right', handleNext, atEnd)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
gap: 12,
|
||||
},
|
||||
mainImageContainer: {
|
||||
aspectRatio: 1,
|
||||
position: 'relative',
|
||||
},
|
||||
mainImageContent: {
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
mainImage: {
|
||||
flex: 1,
|
||||
},
|
||||
thumbnailStrip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
thumbnailFlatList: {
|
||||
flex: 1,
|
||||
},
|
||||
thumbnailList: {
|
||||
gap: 8,
|
||||
},
|
||||
thumbnail: {
|
||||
borderWidth: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
thumbnailActive: {
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowOffset: {width: 0, height: 0},
|
||||
shadowOpacity: 0.8,
|
||||
shadowRadius: 8,
|
||||
},
|
||||
}),
|
||||
},
|
||||
thumbnailImage: {
|
||||
flex: 1,
|
||||
},
|
||||
frameOverlay: {
|
||||
zIndex: 2,
|
||||
},
|
||||
});
|
||||
210
packages/react-native/src/components/DTHexagon.tsx
Normal file
210
packages/react-native/src/components/DTHexagon.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* DT Hexagon Component
|
||||
*
|
||||
* Decorative hexagon shape following the Dangerous Things design language.
|
||||
* No RNP equivalent — purely SVG-based.
|
||||
*
|
||||
* Web CSS reference (AnimatedHexagon / HexagonHamburgerMenu):
|
||||
* - Flat-top hexagon shape
|
||||
* - Optional rotation and pulse animations
|
||||
* - Used for loading indicators, backgrounds, menu icons
|
||||
*/
|
||||
|
||||
import {useRef, useEffect} from 'react';
|
||||
import {StyleSheet, View, ViewStyle, StyleProp, Animated} from 'react-native';
|
||||
import Svg, {Polygon} from 'react-native-svg';
|
||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||
|
||||
interface DTHexagonProps {
|
||||
/**
|
||||
* Hexagon diameter in pixels
|
||||
*/
|
||||
size: number;
|
||||
/**
|
||||
* Visual variant
|
||||
* @default 'normal'
|
||||
*/
|
||||
variant?: DTVariant;
|
||||
/**
|
||||
* Whether the hexagon has a solid fill
|
||||
* @default true
|
||||
*/
|
||||
filled?: boolean;
|
||||
/**
|
||||
* Whether to animate
|
||||
* @default false
|
||||
*/
|
||||
animated?: boolean;
|
||||
/**
|
||||
* Animation type
|
||||
* @default 'none'
|
||||
*/
|
||||
animationType?: 'rotate' | 'pulse' | 'none';
|
||||
/**
|
||||
* Animation duration in milliseconds
|
||||
* @default 2000
|
||||
*/
|
||||
animationDuration?: number;
|
||||
/**
|
||||
* Stroke/border width when not filled
|
||||
* @default 2
|
||||
*/
|
||||
borderWidth?: number;
|
||||
/**
|
||||
* Custom color (overrides variant)
|
||||
*/
|
||||
color?: string;
|
||||
/**
|
||||
* Opacity
|
||||
* @default 1
|
||||
*/
|
||||
opacity?: number;
|
||||
/**
|
||||
* Additional styles
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/**
|
||||
* Content rendered centered inside the hexagon
|
||||
*/
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build flat-top hexagon SVG polygon points
|
||||
*/
|
||||
function buildHexagonPoints(size: number): string {
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const r = size / 2;
|
||||
|
||||
return [0, 1, 2, 3, 4, 5]
|
||||
.map(i => {
|
||||
const angle = (Math.PI / 3) * i - Math.PI / 6; // flat-top orientation
|
||||
const x = cx + r * Math.cos(angle);
|
||||
const y = cy + r * Math.sin(angle);
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* DT-styled decorative Hexagon
|
||||
*
|
||||
* @example
|
||||
* <DTHexagon size={60} variant="normal" />
|
||||
*
|
||||
* @example
|
||||
* <DTHexagon size={40} variant="emphasis" animated animationType="rotate" />
|
||||
*
|
||||
* @example
|
||||
* <DTHexagon size={80} variant="success" filled={false} animated animationType="pulse" />
|
||||
*/
|
||||
export function DTHexagon({
|
||||
size,
|
||||
variant = 'normal',
|
||||
filled = true,
|
||||
animated = false,
|
||||
animationType = 'none',
|
||||
animationDuration = 2000,
|
||||
borderWidth = 2,
|
||||
color,
|
||||
opacity: opacityProp = 1,
|
||||
style,
|
||||
children,
|
||||
}: DTHexagonProps) {
|
||||
const accentColor = getVariantColor(variant, color);
|
||||
const rotateAnim = useRef(new Animated.Value(0)).current;
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (!animated) {
|
||||
rotateAnim.setValue(0);
|
||||
pulseAnim.setValue(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (animationType === 'rotate') {
|
||||
const animation = Animated.loop(
|
||||
Animated.timing(rotateAnim, {
|
||||
toValue: 1,
|
||||
duration: animationDuration,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
);
|
||||
animation.start();
|
||||
return () => {
|
||||
animation.stop();
|
||||
rotateAnim.setValue(0);
|
||||
};
|
||||
}
|
||||
|
||||
if (animationType === 'pulse') {
|
||||
const animation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 0.3,
|
||||
duration: animationDuration / 2,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1,
|
||||
duration: animationDuration / 2,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
animation.start();
|
||||
return () => {
|
||||
animation.stop();
|
||||
pulseAnim.setValue(1);
|
||||
};
|
||||
}
|
||||
}, [animated, animationType, animationDuration, rotateAnim, pulseAnim]);
|
||||
|
||||
const rotation = rotateAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0deg', '360deg'],
|
||||
});
|
||||
|
||||
const animatedOpacity =
|
||||
animated && animationType === 'pulse' ? pulseAnim : opacityProp;
|
||||
|
||||
const transform =
|
||||
animated && animationType === 'rotate'
|
||||
? [{rotate: rotation}]
|
||||
: [];
|
||||
|
||||
const points = buildHexagonPoints(size);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
{width: size, height: size, opacity: animatedOpacity},
|
||||
transform.length > 0 ? {transform} : undefined,
|
||||
style,
|
||||
]}>
|
||||
<Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
<Polygon
|
||||
points={points}
|
||||
fill={filled ? accentColor : 'transparent'}
|
||||
stroke={accentColor}
|
||||
strokeWidth={filled ? 0 : borderWidth}
|
||||
/>
|
||||
</Svg>
|
||||
{children && (
|
||||
<View style={[StyleSheet.absoluteFill, styles.childrenContainer]}>
|
||||
{children}
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {},
|
||||
childrenContainer: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
313
packages/react-native/src/components/DTLabel.tsx
Normal file
313
packages/react-native/src/components/DTLabel.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* DT Label Component
|
||||
*
|
||||
* A themed label following the Dangerous Things design language
|
||||
* with an SVG-based beveled top-right corner.
|
||||
*
|
||||
* Web CSS reference (CyberLabel):
|
||||
* - Top-right bevel: 1em (~16px)
|
||||
* - Solid filled background using mode color
|
||||
* - Black text on colored background
|
||||
*/
|
||||
|
||||
import {ReactNode, useEffect, useRef} from 'react';
|
||||
import {StyleSheet, View, ViewStyle, StyleProp, Animated} from 'react-native';
|
||||
import {Text} from 'react-native-paper';
|
||||
import Svg, {Path} from 'react-native-svg';
|
||||
import {DTColors} from '../theme/colors';
|
||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||
import {buildLabelBevelPath} from '../utils/bevelPaths';
|
||||
import {useComponentLayout} from '../utils/useComponentLayout';
|
||||
|
||||
type DTLabelSize = 'small' | 'medium' | 'large';
|
||||
|
||||
interface SizeConfig {
|
||||
fontSize: number;
|
||||
fontSizeSecondary: number;
|
||||
paddingHorizontal: number;
|
||||
paddingVertical: number;
|
||||
bevel: number;
|
||||
indicatorWidth: number;
|
||||
gap: number;
|
||||
}
|
||||
|
||||
const sizeConfigs: Record<DTLabelSize, SizeConfig> = {
|
||||
small: {
|
||||
fontSize: 11,
|
||||
fontSizeSecondary: 11,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
bevel: 10,
|
||||
indicatorWidth: 12,
|
||||
gap: 3,
|
||||
},
|
||||
medium: {
|
||||
fontSize: 14,
|
||||
fontSizeSecondary: 14,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
bevel: 16,
|
||||
indicatorWidth: 16,
|
||||
gap: 4,
|
||||
},
|
||||
large: {
|
||||
fontSize: 18,
|
||||
fontSizeSecondary: 18,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
bevel: 22,
|
||||
indicatorWidth: 20,
|
||||
gap: 6,
|
||||
},
|
||||
};
|
||||
|
||||
interface DTLabelProps {
|
||||
/**
|
||||
* Primary text content
|
||||
*/
|
||||
primaryText: ReactNode;
|
||||
/**
|
||||
* Secondary text (displayed bold, after primary)
|
||||
*/
|
||||
secondaryText?: ReactNode;
|
||||
/**
|
||||
* Color mode for the label
|
||||
* @default 'normal'
|
||||
*/
|
||||
mode?: DTVariant;
|
||||
/**
|
||||
* Size of the label - affects text size and scales all dimensions
|
||||
* @default 'medium'
|
||||
*/
|
||||
size?: DTLabelSize;
|
||||
/**
|
||||
* Whether the label should fill its container width
|
||||
* @default false
|
||||
*/
|
||||
fullWidth?: boolean;
|
||||
/**
|
||||
* Whether to animate the label on mount
|
||||
* @default true
|
||||
*/
|
||||
animated?: boolean;
|
||||
/**
|
||||
* Animation delay in milliseconds
|
||||
* @default 100
|
||||
*/
|
||||
animationDelay?: number;
|
||||
/**
|
||||
* Animation duration in milliseconds
|
||||
* @default 330
|
||||
*/
|
||||
animationDuration?: number;
|
||||
/**
|
||||
* Whether to show the pinging indicator
|
||||
* @default true
|
||||
*/
|
||||
showIndicator?: boolean;
|
||||
/**
|
||||
* Top-right bevel size in pixels (overrides size-based default)
|
||||
*/
|
||||
bevelSize?: number;
|
||||
/**
|
||||
* Additional styles for the label container
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* DT-styled Label component with SVG beveled top-right corner
|
||||
*
|
||||
* Based on CyberLabel from the Dangerous Things web storefront.
|
||||
* Features a solid colored background with black text and an optional
|
||||
* animated ping indicator.
|
||||
*
|
||||
* @example
|
||||
* <DTLabel primaryText="Detected" secondaryText="NTAG215" mode="normal" />
|
||||
*
|
||||
* @example
|
||||
* <DTLabel primaryText="Status" secondaryText="Ready to scan" mode="success" fullWidth />
|
||||
*
|
||||
* @example
|
||||
* <DTLabel primaryText="Warning" mode="warning" showIndicator={false} />
|
||||
*
|
||||
* @example
|
||||
* <DTLabel primaryText="Small label" size="small" />
|
||||
*
|
||||
* @example
|
||||
* <DTLabel primaryText="Large label" size="large" mode="emphasis" />
|
||||
*/
|
||||
export function DTLabel({
|
||||
primaryText,
|
||||
secondaryText,
|
||||
mode = 'normal',
|
||||
size = 'medium',
|
||||
fullWidth = false,
|
||||
animated = true,
|
||||
animationDelay = 100,
|
||||
animationDuration = 330,
|
||||
showIndicator = true,
|
||||
bevelSize,
|
||||
style,
|
||||
}: DTLabelProps) {
|
||||
const {dimensions, onLayout, hasDimensions} = useComponentLayout();
|
||||
const scaleAnim = useRef(new Animated.Value(animated ? 0 : 1)).current;
|
||||
const pingAnim = useRef(new Animated.Value(1)).current;
|
||||
const backgroundColor = getVariantColor(mode);
|
||||
const sizeConfig = sizeConfigs[size];
|
||||
const effectiveBevelSize = bevelSize ?? sizeConfig.bevel;
|
||||
|
||||
// Mount animation
|
||||
useEffect(() => {
|
||||
if (animated) {
|
||||
Animated.timing(scaleAnim, {
|
||||
toValue: 1,
|
||||
duration: animationDuration,
|
||||
delay: animationDelay,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}
|
||||
}, [animated, animationDelay, animationDuration, scaleAnim]);
|
||||
|
||||
// Ping animation loop
|
||||
useEffect(() => {
|
||||
if (showIndicator) {
|
||||
const pingAnimation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pingAnim, {
|
||||
toValue: 0.3,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pingAnim, {
|
||||
toValue: 1,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
pingAnimation.start();
|
||||
return () => {
|
||||
pingAnimation.stop();
|
||||
pingAnim.setValue(1);
|
||||
};
|
||||
}
|
||||
pingAnim.setValue(1);
|
||||
}, [showIndicator, pingAnim]);
|
||||
|
||||
const {width, height} = dimensions;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
fullWidth && styles.fullWidth,
|
||||
{transform: [{scale: scaleAnim}]},
|
||||
style,
|
||||
]}
|
||||
onLayout={onLayout}>
|
||||
{hasDimensions && (
|
||||
<Svg
|
||||
style={StyleSheet.absoluteFill}
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}>
|
||||
<Path d={buildLabelBevelPath(width, height, effectiveBevelSize)} fill={backgroundColor} />
|
||||
</Svg>
|
||||
)}
|
||||
<View style={styles.innerContainer}>
|
||||
<View
|
||||
style={[
|
||||
styles.textContainer,
|
||||
{
|
||||
paddingHorizontal: sizeConfig.paddingHorizontal,
|
||||
paddingVertical: sizeConfig.paddingVertical,
|
||||
gap: sizeConfig.gap,
|
||||
},
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.primaryText,
|
||||
{fontSize: sizeConfig.fontSize},
|
||||
]}>
|
||||
{primaryText}
|
||||
</Text>
|
||||
{secondaryText && (
|
||||
<Text
|
||||
style={[
|
||||
styles.secondaryText,
|
||||
{fontSize: sizeConfig.fontSizeSecondary},
|
||||
]}>
|
||||
{secondaryText}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{showIndicator && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.indicator,
|
||||
{opacity: pingAnim, paddingLeft: sizeConfig.paddingHorizontal / 1.5},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
styles.indicatorLine,
|
||||
{borderColor: DTColors.dark, width: sizeConfig.indicatorWidth},
|
||||
]}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DT Label design constants matching web CSS
|
||||
*/
|
||||
export const DTLabelClipPath = {
|
||||
// CSS-style clip path (for web reference)
|
||||
css: `polygon(
|
||||
0% 0%,
|
||||
calc(100% - 1em) 0%,
|
||||
100% 1em,
|
||||
100% 100%,
|
||||
0% 100%
|
||||
)`,
|
||||
// Default bevel size in pixels (matching 1em at 16px base)
|
||||
bevelSize: 16,
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'relative',
|
||||
alignSelf: 'flex-start',
|
||||
margin: 8,
|
||||
},
|
||||
fullWidth: {
|
||||
alignSelf: 'stretch',
|
||||
},
|
||||
innerContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
},
|
||||
textContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
},
|
||||
primaryText: {
|
||||
color: DTColors.dark,
|
||||
fontWeight: '500',
|
||||
},
|
||||
secondaryText: {
|
||||
color: DTColors.dark,
|
||||
fontWeight: '800',
|
||||
},
|
||||
indicator: {},
|
||||
indicatorLine: {
|
||||
borderBottomWidth: 2,
|
||||
},
|
||||
});
|
||||
210
packages/react-native/src/components/DTMediaFrame.tsx
Normal file
210
packages/react-native/src/components/DTMediaFrame.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* DT MediaFrame Component
|
||||
*
|
||||
* A beveled frame for images and video content following the
|
||||
* Dangerous Things design language. No RNP equivalent.
|
||||
*
|
||||
* Web CSS reference (CyberMediaFrame / .media-frame-clipped):
|
||||
* - Top-left + bottom-right diagonal symmetry bevels
|
||||
* - Colored border with dark inner background
|
||||
* - Optional mount scale animation
|
||||
*/
|
||||
|
||||
import {ReactNode, useRef, useEffect} from 'react';
|
||||
import {StyleSheet, View, ViewStyle, StyleProp, Animated} from 'react-native';
|
||||
import Svg, {Path, Defs, ClipPath} from 'react-native-svg';
|
||||
import {DTColors} from '../theme/colors';
|
||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||
import {buildMediaFrameBevelPath} from '../utils/bevelPaths';
|
||||
import {useComponentLayout} from '../utils/useComponentLayout';
|
||||
|
||||
interface DTMediaFrameProps {
|
||||
/**
|
||||
* Content to frame (Image, Video, etc.)
|
||||
*/
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Visual variant
|
||||
* @default 'normal'
|
||||
*/
|
||||
variant?: DTVariant;
|
||||
/**
|
||||
* Aspect ratio (e.g., 16/9, 1, 4/3)
|
||||
*/
|
||||
aspectRatio?: number;
|
||||
/**
|
||||
* Border width in pixels
|
||||
* @default 3
|
||||
*/
|
||||
borderWidth?: number;
|
||||
/**
|
||||
* Bevel size in pixels (top-left and bottom-right)
|
||||
* @default 32
|
||||
*/
|
||||
bevelSize?: number;
|
||||
/**
|
||||
* Whether to animate on mount
|
||||
* @default true
|
||||
*/
|
||||
animated?: boolean;
|
||||
/**
|
||||
* Animation delay in milliseconds
|
||||
* @default 0
|
||||
*/
|
||||
animationDelay?: number;
|
||||
/**
|
||||
* Custom color (overrides variant)
|
||||
*/
|
||||
color?: string;
|
||||
/**
|
||||
* Additional styles for outer container
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/**
|
||||
* Additional styles for the content area
|
||||
*/
|
||||
contentStyle?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* DT-styled MediaFrame with diagonal-symmetry beveled corners
|
||||
*
|
||||
* @example
|
||||
* <DTMediaFrame variant="normal" aspectRatio={16/9}>
|
||||
* <Image source={{uri: imageUrl}} style={{flex: 1}} />
|
||||
* </DTMediaFrame>
|
||||
*
|
||||
* @example
|
||||
* <DTMediaFrame variant="emphasis" bevelSize={24}>
|
||||
* <Video source={videoSource} />
|
||||
* </DTMediaFrame>
|
||||
*/
|
||||
export function DTMediaFrame({
|
||||
children,
|
||||
variant = 'normal',
|
||||
aspectRatio,
|
||||
borderWidth = 3,
|
||||
bevelSize = 32,
|
||||
animated = true,
|
||||
animationDelay = 0,
|
||||
color,
|
||||
style,
|
||||
contentStyle,
|
||||
}: DTMediaFrameProps) {
|
||||
const {dimensions, onLayout, hasDimensions} = useComponentLayout();
|
||||
const scaleAnim = useRef(new Animated.Value(animated ? 0 : 1)).current;
|
||||
const accentColor = getVariantColor(variant, color);
|
||||
|
||||
useEffect(() => {
|
||||
if (animated) {
|
||||
Animated.timing(scaleAnim, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
delay: animationDelay,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}
|
||||
}, [animated, animationDelay, scaleAnim]);
|
||||
|
||||
const {width, height} = dimensions;
|
||||
|
||||
// Outer bevel path (full frame shape)
|
||||
const outerPath = hasDimensions
|
||||
? buildMediaFrameBevelPath(width, height, bevelSize)
|
||||
: '';
|
||||
// Inner bevel path at border inset
|
||||
const innerPath = hasDimensions
|
||||
? buildMediaFrameBevelPath(width, height, bevelSize - borderWidth, borderWidth)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
aspectRatio ? {aspectRatio} : undefined,
|
||||
{transform: [{scale: scaleAnim}]},
|
||||
style,
|
||||
]}
|
||||
onLayout={onLayout}>
|
||||
{/* Background fill (behind content) */}
|
||||
{hasDimensions && (
|
||||
<Svg
|
||||
style={StyleSheet.absoluteFill}
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}>
|
||||
<Path d={innerPath} fill={DTColors.dark} />
|
||||
</Svg>
|
||||
)}
|
||||
{/* Content clipped to the inner bevel shape */}
|
||||
{hasDimensions ? (
|
||||
<View style={[styles.content, contentStyle]}>
|
||||
<Svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
style={StyleSheet.absoluteFill}>
|
||||
<Defs>
|
||||
<ClipPath id="mediaClip">
|
||||
<Path d={innerPath} />
|
||||
</ClipPath>
|
||||
</Defs>
|
||||
</Svg>
|
||||
<View
|
||||
style={[
|
||||
StyleSheet.absoluteFill,
|
||||
{
|
||||
margin: borderWidth,
|
||||
// Use borderRadius 0 to keep angular, overflow hidden clips rectangular part
|
||||
overflow: 'hidden',
|
||||
},
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View style={[styles.content, {padding: borderWidth}, contentStyle]}>
|
||||
{children}
|
||||
</View>
|
||||
)}
|
||||
{/* Frame overlay (above content) — masks content at beveled corners */}
|
||||
{hasDimensions && (
|
||||
<Svg
|
||||
style={[StyleSheet.absoluteFill, styles.frameOverlay]}
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
pointerEvents="none">
|
||||
{/* Outer frame border */}
|
||||
<Path
|
||||
d={outerPath + ' ' + innerPath}
|
||||
fillRule="evenodd"
|
||||
fill={accentColor}
|
||||
/>
|
||||
{/* Corner masks: fill the rectangular area outside the bevel shape with black
|
||||
to hide content that overflows past the beveled corners */}
|
||||
<Path
|
||||
d={`M 0 0 L ${width} 0 L ${width} ${height} L 0 ${height} Z ` + outerPath}
|
||||
fillRule="evenodd"
|
||||
fill={DTColors.dark}
|
||||
/>
|
||||
</Svg>
|
||||
)}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'relative',
|
||||
},
|
||||
content: {
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
overflow: 'hidden',
|
||||
flex: 1,
|
||||
},
|
||||
frameOverlay: {
|
||||
zIndex: 2,
|
||||
},
|
||||
});
|
||||
371
packages/react-native/src/components/DTMenu.tsx
Normal file
371
packages/react-native/src/components/DTMenu.tsx
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* DT Menu Component
|
||||
*
|
||||
* Wraps React Native Paper's Menu for dropdown positioning, with custom
|
||||
* menu items styled to the Dangerous Things aesthetic.
|
||||
*
|
||||
* Web CSS reference (CyberMenu):
|
||||
* - Hierarchical items with menu-item-clipped styling
|
||||
* - Active/selected state with emphasis color fill
|
||||
* - borderTopWidth: 5 thick accent border
|
||||
*/
|
||||
|
||||
import {useState, useCallback, useRef, useEffect} from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
ViewStyle,
|
||||
StyleProp,
|
||||
Pressable,
|
||||
Animated,
|
||||
} from 'react-native';
|
||||
import {Menu, Text} from 'react-native-paper';
|
||||
import {DTColors} from '../theme/colors';
|
||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||
|
||||
export interface DTMenuItem {
|
||||
/**
|
||||
* Unique identifier
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Display title
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Press handler (leaf items)
|
||||
*/
|
||||
onPress?: () => void;
|
||||
/**
|
||||
* Sub-menu items
|
||||
*/
|
||||
items?: DTMenuItem[];
|
||||
/**
|
||||
* Whether this item is currently active
|
||||
*/
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
interface DTMenuProps {
|
||||
/**
|
||||
* Menu items
|
||||
*/
|
||||
items: DTMenuItem[];
|
||||
/**
|
||||
* Visual variant
|
||||
* @default 'normal'
|
||||
*/
|
||||
variant?: DTVariant;
|
||||
/**
|
||||
* Variant for active items
|
||||
* @default 'emphasis'
|
||||
*/
|
||||
activeVariant?: DTVariant;
|
||||
/**
|
||||
* Layout direction
|
||||
* @default 'vertical'
|
||||
*/
|
||||
layout?: 'horizontal' | 'vertical';
|
||||
/**
|
||||
* Additional styles
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* DT-styled Menu with hierarchical items
|
||||
*
|
||||
* For inline menu display (not dropdown). Items with sub-items
|
||||
* expand/collapse on press.
|
||||
*
|
||||
* @example
|
||||
* <DTMenu items={[
|
||||
* { id: '1', title: 'Home', onPress: goHome, isActive: true },
|
||||
* { id: '2', title: 'Products', items: [
|
||||
* { id: '2a', title: 'NFC Tags', onPress: goTags },
|
||||
* ]},
|
||||
* ]} />
|
||||
*/
|
||||
export function DTMenu({
|
||||
items,
|
||||
variant = 'normal',
|
||||
activeVariant = 'emphasis',
|
||||
layout = 'vertical',
|
||||
style,
|
||||
}: DTMenuProps) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
layout === 'horizontal' ? styles.horizontal : styles.vertical,
|
||||
style,
|
||||
]}>
|
||||
{items.map(item => (
|
||||
<DTMenuItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
variant={variant}
|
||||
activeVariant={activeVariant}
|
||||
level={0}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* For dropdown use, wraps RNP Menu for positioning/portal behavior
|
||||
*/
|
||||
interface DTMenuDropdownProps {
|
||||
/**
|
||||
* Menu items
|
||||
*/
|
||||
items: DTMenuItem[];
|
||||
/**
|
||||
* Visual variant
|
||||
* @default 'normal'
|
||||
*/
|
||||
variant?: DTVariant;
|
||||
/**
|
||||
* Anchor element or position
|
||||
*/
|
||||
anchor: React.ReactNode;
|
||||
/**
|
||||
* Whether the menu is visible
|
||||
*/
|
||||
visible: boolean;
|
||||
/**
|
||||
* Called when the menu is dismissed
|
||||
*/
|
||||
onDismiss: () => void;
|
||||
/**
|
||||
* Additional styles
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* DT-styled dropdown Menu wrapping React Native Paper Menu
|
||||
*
|
||||
* @example
|
||||
* <DTMenuDropdown
|
||||
* items={menuItems}
|
||||
* anchor={<DTButton onPress={open}>Open Menu</DTButton>}
|
||||
* visible={menuVisible}
|
||||
* onDismiss={() => setMenuVisible(false)}
|
||||
* />
|
||||
*/
|
||||
export function DTMenuDropdown({
|
||||
items,
|
||||
variant = 'normal',
|
||||
anchor,
|
||||
visible,
|
||||
onDismiss,
|
||||
style,
|
||||
}: DTMenuDropdownProps) {
|
||||
const accentColor = getVariantColor(variant);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
visible={visible}
|
||||
onDismiss={onDismiss}
|
||||
anchor={anchor}
|
||||
anchorPosition="bottom"
|
||||
contentStyle={[
|
||||
styles.dropdownContent,
|
||||
{borderColor: accentColor},
|
||||
style,
|
||||
]}
|
||||
theme={{
|
||||
colors: {
|
||||
elevation: {level2: DTColors.dark},
|
||||
},
|
||||
}}>
|
||||
{items.map(item => (
|
||||
<Menu.Item
|
||||
key={item.id}
|
||||
title={item.title}
|
||||
onPress={() => {
|
||||
item.onPress?.();
|
||||
onDismiss();
|
||||
}}
|
||||
titleStyle={[styles.dropdownItemTitle, {color: DTColors.light}]}
|
||||
style={styles.dropdownItem}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Internal DTMenuItemRow ---
|
||||
|
||||
function DTMenuItemRow({
|
||||
item,
|
||||
variant,
|
||||
activeVariant,
|
||||
level,
|
||||
}: {
|
||||
item: DTMenuItem;
|
||||
variant: DTVariant;
|
||||
activeVariant: DTVariant;
|
||||
level: number;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasChildren = item.items && item.items.length > 0;
|
||||
const isActive = item.isActive || expanded;
|
||||
const itemColor = getVariantColor(isActive ? activeVariant : variant);
|
||||
|
||||
const heightAnim = useRef(new Animated.Value(0)).current;
|
||||
const chevronAnim = useRef(new Animated.Value(0)).current;
|
||||
const [contentHeight, setContentHeight] = useState(0);
|
||||
const [measured, setMeasured] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasChildren) return;
|
||||
Animated.parallel([
|
||||
Animated.timing(heightAnim, {
|
||||
toValue: expanded ? 1 : 0,
|
||||
duration: 250,
|
||||
useNativeDriver: false,
|
||||
}),
|
||||
Animated.timing(chevronAnim, {
|
||||
toValue: expanded ? 1 : 0,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}, [expanded, heightAnim, chevronAnim, hasChildren]);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (hasChildren) {
|
||||
setExpanded(prev => !prev);
|
||||
} else {
|
||||
item.onPress?.();
|
||||
}
|
||||
}, [hasChildren, item]);
|
||||
|
||||
const animatedHeight = heightAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0, contentHeight],
|
||||
});
|
||||
|
||||
const chevronRotate = chevronAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0deg', '180deg'],
|
||||
});
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
style={({pressed}) => [
|
||||
styles.menuItem,
|
||||
{
|
||||
borderColor: itemColor,
|
||||
borderTopColor: itemColor,
|
||||
backgroundColor: isActive
|
||||
? `${itemColor}20`
|
||||
: DTColors.dark,
|
||||
paddingLeft: 16 + level * 16,
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
},
|
||||
]}>
|
||||
<Text style={[styles.menuItemTitle, {color: itemColor}]}>
|
||||
{item.title}
|
||||
</Text>
|
||||
{hasChildren && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.chevron,
|
||||
{transform: [{rotate: chevronRotate}]},
|
||||
]}>
|
||||
<View style={[styles.chevronArrow, {borderTopColor: itemColor}]} />
|
||||
</Animated.View>
|
||||
)}
|
||||
</Pressable>
|
||||
{hasChildren && (
|
||||
<Animated.View
|
||||
style={{
|
||||
height: measured ? animatedHeight : undefined,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<View
|
||||
onLayout={e => {
|
||||
const h = e.nativeEvent.layout.height;
|
||||
if (h > 0 && !measured) {
|
||||
setContentHeight(h);
|
||||
setMeasured(true);
|
||||
if (!expanded) {
|
||||
heightAnim.setValue(0);
|
||||
}
|
||||
}
|
||||
}}>
|
||||
{item.items?.map(child => (
|
||||
<DTMenuItemRow
|
||||
key={child.id}
|
||||
item={child}
|
||||
variant={variant}
|
||||
activeVariant={activeVariant}
|
||||
level={level + 1}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
gap: 4,
|
||||
},
|
||||
horizontal: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
vertical: {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
menuItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderWidth: 1,
|
||||
borderTopWidth: 5,
|
||||
paddingVertical: 12,
|
||||
paddingRight: 16,
|
||||
},
|
||||
menuItemTitle: {
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.5,
|
||||
textTransform: 'uppercase',
|
||||
fontSize: 14,
|
||||
},
|
||||
chevron: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
chevronArrow: {
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeftWidth: 5,
|
||||
borderRightWidth: 5,
|
||||
borderTopWidth: 7,
|
||||
borderLeftColor: 'transparent',
|
||||
borderRightColor: 'transparent',
|
||||
},
|
||||
dropdownContent: {
|
||||
borderWidth: 2,
|
||||
borderRadius: 0,
|
||||
backgroundColor: DTColors.dark,
|
||||
},
|
||||
dropdownItem: {
|
||||
borderRadius: 0,
|
||||
},
|
||||
dropdownItemTitle: {
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
});
|
||||
115
packages/react-native/src/components/DTModal.tsx
Normal file
115
packages/react-native/src/components/DTModal.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* DT Modal Component
|
||||
*
|
||||
* Wraps React Native Paper's Modal + Portal with the Dangerous Things
|
||||
* aesthetic. Content is rendered inside a DTCard for the beveled look.
|
||||
*
|
||||
* Web CSS reference (Modal.tsx):
|
||||
* - Centered overlay with dark backdrop
|
||||
* - Content in beveled card container
|
||||
*/
|
||||
|
||||
import {StyleSheet, ViewStyle, StyleProp, KeyboardAvoidingView, Platform} from 'react-native';
|
||||
import {Portal, Modal} from 'react-native-paper';
|
||||
import {DTColors} from '../theme/colors';
|
||||
import {type DTVariant} from '../utils/variantColors';
|
||||
import {DTCard} from './DTCard';
|
||||
|
||||
interface DTModalProps {
|
||||
/**
|
||||
* Whether the modal is visible
|
||||
*/
|
||||
visible: boolean;
|
||||
/**
|
||||
* Called when the modal is dismissed
|
||||
*/
|
||||
onDismiss: () => void;
|
||||
/**
|
||||
* Optional title for the modal header
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* Visual variant
|
||||
* @default 'normal'
|
||||
*/
|
||||
variant?: DTVariant;
|
||||
/**
|
||||
* Modal content
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Whether the modal can be dismissed by tapping the backdrop
|
||||
* @default true
|
||||
*/
|
||||
dismissable?: boolean;
|
||||
/**
|
||||
* Additional styles for the card content area
|
||||
*/
|
||||
contentStyle?: StyleProp<ViewStyle>;
|
||||
/**
|
||||
* Additional styles for the modal container
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* DT-styled Modal wrapping React Native Paper Modal + Portal
|
||||
*
|
||||
* @example
|
||||
* <DTModal visible={showModal} onDismiss={() => setShowModal(false)} title="Confirm">
|
||||
* <Text>Are you sure?</Text>
|
||||
* </DTModal>
|
||||
*
|
||||
* @example
|
||||
* <DTModal visible={isOpen} onDismiss={close} variant="warning" title="Error">
|
||||
* <Text>Something went wrong</Text>
|
||||
* </DTModal>
|
||||
*/
|
||||
export function DTModal({
|
||||
visible,
|
||||
onDismiss,
|
||||
title,
|
||||
variant = 'normal',
|
||||
children,
|
||||
dismissable = true,
|
||||
contentStyle,
|
||||
style,
|
||||
}: DTModalProps) {
|
||||
return (
|
||||
<Portal>
|
||||
<Modal
|
||||
visible={visible}
|
||||
onDismiss={onDismiss}
|
||||
dismissable={dismissable}
|
||||
contentContainerStyle={[styles.container, style]}
|
||||
style={styles.modal}
|
||||
theme={{
|
||||
colors: {
|
||||
backdrop: DTColors.overlay,
|
||||
},
|
||||
}}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
style={styles.keyboardAvoid}>
|
||||
<DTCard
|
||||
mode={variant}
|
||||
title={title}
|
||||
showHeader={!!title}
|
||||
contentStyle={contentStyle}>
|
||||
{children}
|
||||
</DTCard>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modal: {},
|
||||
container: {
|
||||
marginHorizontal: 24,
|
||||
},
|
||||
keyboardAvoid: {
|
||||
width: '100%',
|
||||
},
|
||||
});
|
||||
183
packages/react-native/src/components/DTProgressBar.tsx
Normal file
183
packages/react-native/src/components/DTProgressBar.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* DT ProgressBar Component
|
||||
*
|
||||
* A themed progress bar wrapping React Native Paper's ProgressBar
|
||||
* with the Dangerous Things angular aesthetic.
|
||||
*
|
||||
* Web CSS reference (.progress / .progress-indicator):
|
||||
* - Vertical or horizontal bar showing progress 0-100%
|
||||
* - Mode-colored fill
|
||||
*/
|
||||
|
||||
import {StyleSheet, View, ViewStyle, StyleProp} from 'react-native';
|
||||
import {ProgressBar, Text} from 'react-native-paper';
|
||||
import {DTColors} from '../theme/colors';
|
||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||
import {useComponentLayout} from '../utils/useComponentLayout';
|
||||
|
||||
interface DTProgressBarProps {
|
||||
/**
|
||||
* Progress value between 0 and 1
|
||||
*/
|
||||
value: number;
|
||||
/**
|
||||
* Visual variant
|
||||
* @default 'normal'
|
||||
*/
|
||||
variant?: DTVariant;
|
||||
/**
|
||||
* Orientation of the bar
|
||||
* @default 'horizontal'
|
||||
*/
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
/**
|
||||
* Bar thickness in pixels
|
||||
* @default 4
|
||||
*/
|
||||
height?: number;
|
||||
/**
|
||||
* Whether to animate progress changes
|
||||
* @default true
|
||||
*/
|
||||
animated?: boolean;
|
||||
/**
|
||||
* Show percentage label
|
||||
* @default false
|
||||
*/
|
||||
showLabel?: boolean;
|
||||
/**
|
||||
* Custom color (overrides variant)
|
||||
*/
|
||||
color?: string;
|
||||
/**
|
||||
* Additional styles
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* DT-styled ProgressBar wrapping React Native Paper ProgressBar
|
||||
*
|
||||
* @example
|
||||
* <DTProgressBar value={0.75} variant="normal" />
|
||||
*
|
||||
* @example
|
||||
* <DTProgressBar value={0.5} variant="emphasis" showLabel />
|
||||
*
|
||||
* @example
|
||||
* <DTProgressBar value={0.3} orientation="vertical" height={100} variant="success" />
|
||||
*/
|
||||
export function DTProgressBar({
|
||||
value,
|
||||
variant = 'normal',
|
||||
orientation = 'horizontal',
|
||||
height = 4,
|
||||
animated: _animated = true,
|
||||
showLabel = false,
|
||||
color,
|
||||
style,
|
||||
}: DTProgressBarProps) {
|
||||
const accentColor = getVariantColor(variant, color);
|
||||
const clampedValue = Math.max(0, Math.min(1, value));
|
||||
|
||||
if (orientation === 'vertical') {
|
||||
return (
|
||||
<VerticalProgressBar
|
||||
value={clampedValue}
|
||||
color={accentColor}
|
||||
width={height}
|
||||
showLabel={showLabel}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.horizontalContainer, style]}>
|
||||
<ProgressBar
|
||||
progress={clampedValue}
|
||||
color={accentColor}
|
||||
style={[styles.bar, {height}]}
|
||||
theme={{
|
||||
colors: {
|
||||
surfaceVariant: DTColors.disabledBackground,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{showLabel && (
|
||||
<Text style={[styles.label, {color: accentColor}]}>
|
||||
{Math.round(clampedValue * 100)}%
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical progress bar using measured pixel height instead of
|
||||
* percentage strings (which don't animate smoothly in RN).
|
||||
*/
|
||||
function VerticalProgressBar({
|
||||
value,
|
||||
color,
|
||||
width,
|
||||
showLabel,
|
||||
style,
|
||||
}: {
|
||||
value: number;
|
||||
color: string;
|
||||
width: number;
|
||||
showLabel: boolean;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}) {
|
||||
const {dimensions, onLayout, hasDimensions} = useComponentLayout();
|
||||
const fillHeight = hasDimensions ? dimensions.height * value : 0;
|
||||
|
||||
return (
|
||||
<View style={[styles.verticalContainer, {width}, style]}>
|
||||
<View style={styles.verticalTrack} onLayout={onLayout}>
|
||||
<View
|
||||
style={[
|
||||
styles.verticalFill,
|
||||
{
|
||||
backgroundColor: color,
|
||||
height: fillHeight,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
{showLabel && (
|
||||
<Text style={[styles.label, {color}]}>
|
||||
{Math.round(value * 100)}%
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
horizontalContainer: {
|
||||
gap: 8,
|
||||
},
|
||||
bar: {
|
||||
borderRadius: 0,
|
||||
},
|
||||
verticalContainer: {
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
verticalTrack: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
backgroundColor: DTColors.disabledBackground,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
verticalFill: {
|
||||
width: '100%',
|
||||
},
|
||||
label: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
234
packages/react-native/src/components/DTQuantityStepper.tsx
Normal file
234
packages/react-native/src/components/DTQuantityStepper.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* DT QuantityStepper Component
|
||||
*
|
||||
* Custom-built quantity increment/decrement control following the
|
||||
* Dangerous Things design language. No RNP equivalent exists.
|
||||
*
|
||||
* Web CSS reference (AddOnOptionItem quantity controls):
|
||||
* - Plus/minus buttons with beveled corners
|
||||
* - Quantity display between buttons
|
||||
*/
|
||||
|
||||
import {StyleSheet, View, ViewStyle, StyleProp, Pressable} from 'react-native';
|
||||
import {Text} from 'react-native-paper';
|
||||
import Svg, {Path} from 'react-native-svg';
|
||||
import {DTColors} from '../theme/colors';
|
||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||
import {buildButtonBevelPath} from '../utils/bevelPaths';
|
||||
|
||||
type DTStepperSize = 'small' | 'medium' | 'large';
|
||||
|
||||
interface SizeConfig {
|
||||
buttonSize: number;
|
||||
fontSize: number;
|
||||
bevelSize: number;
|
||||
iconStrokeWidth: number;
|
||||
minWidth: number;
|
||||
}
|
||||
|
||||
const sizeConfigs: Record<DTStepperSize, SizeConfig> = {
|
||||
small: {
|
||||
buttonSize: 28,
|
||||
fontSize: 12,
|
||||
bevelSize: 4,
|
||||
iconStrokeWidth: 1.5,
|
||||
minWidth: 28,
|
||||
},
|
||||
medium: {
|
||||
buttonSize: 36,
|
||||
fontSize: 16,
|
||||
bevelSize: 6,
|
||||
iconStrokeWidth: 2,
|
||||
minWidth: 36,
|
||||
},
|
||||
large: {
|
||||
buttonSize: 44,
|
||||
fontSize: 20,
|
||||
bevelSize: 8,
|
||||
iconStrokeWidth: 2.5,
|
||||
minWidth: 44,
|
||||
},
|
||||
};
|
||||
|
||||
interface DTQuantityStepperProps {
|
||||
/**
|
||||
* Current quantity value
|
||||
*/
|
||||
value: number;
|
||||
/**
|
||||
* Called when value changes
|
||||
*/
|
||||
onValueChange: (value: number) => void;
|
||||
/**
|
||||
* Minimum value
|
||||
* @default 0
|
||||
*/
|
||||
min?: number;
|
||||
/**
|
||||
* Maximum value
|
||||
* @default 99
|
||||
*/
|
||||
max?: number;
|
||||
/**
|
||||
* Step increment
|
||||
* @default 1
|
||||
*/
|
||||
step?: number;
|
||||
/**
|
||||
* Visual variant
|
||||
* @default 'normal'
|
||||
*/
|
||||
variant?: DTVariant;
|
||||
/**
|
||||
* Whether the stepper is disabled
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Label text displayed above the stepper
|
||||
*/
|
||||
label?: string;
|
||||
/**
|
||||
* Size preset
|
||||
* @default 'medium'
|
||||
*/
|
||||
size?: DTStepperSize;
|
||||
/**
|
||||
* Custom color (overrides variant)
|
||||
*/
|
||||
color?: string;
|
||||
/**
|
||||
* Additional styles
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* DT-styled QuantityStepper with beveled +/- buttons
|
||||
*
|
||||
* @example
|
||||
* <DTQuantityStepper value={qty} onValueChange={setQty} label="Quantity" />
|
||||
*
|
||||
* @example
|
||||
* <DTQuantityStepper value={qty} onValueChange={setQty} min={1} max={10} variant="emphasis" />
|
||||
*/
|
||||
export function DTQuantityStepper({
|
||||
value,
|
||||
onValueChange,
|
||||
min = 0,
|
||||
max = 99,
|
||||
step = 1,
|
||||
variant = 'normal',
|
||||
disabled = false,
|
||||
label,
|
||||
size = 'medium',
|
||||
color,
|
||||
style,
|
||||
}: DTQuantityStepperProps) {
|
||||
const accentColor = getVariantColor(variant, color);
|
||||
const config = sizeConfigs[size];
|
||||
const atMin = value <= min;
|
||||
const atMax = value >= max;
|
||||
const borderWidth = 2;
|
||||
|
||||
const handleDecrement = () => {
|
||||
const next = Math.max(min, value - step);
|
||||
onValueChange(next);
|
||||
};
|
||||
|
||||
const handleIncrement = () => {
|
||||
const next = Math.min(max, value + step);
|
||||
onValueChange(next);
|
||||
};
|
||||
|
||||
const renderStepButton = (
|
||||
type: 'minus' | 'plus',
|
||||
onPress: () => void,
|
||||
isDisabled: boolean,
|
||||
) => {
|
||||
const s = config.buttonSize;
|
||||
const center = s / 2;
|
||||
const iconSize = s * 0.35;
|
||||
const sw = config.iconStrokeWidth;
|
||||
|
||||
// Minus: horizontal line; Plus: cross
|
||||
const iconPath =
|
||||
type === 'minus'
|
||||
? `M ${center - iconSize} ${center} L ${center + iconSize} ${center}`
|
||||
: `M ${center - iconSize} ${center} L ${center + iconSize} ${center} M ${center} ${center - iconSize} L ${center} ${center + iconSize}`;
|
||||
|
||||
// Expand touch target to at least 44px
|
||||
const hitSlopSize = Math.max(0, (44 - s) / 2);
|
||||
const hitSlop = hitSlopSize > 0 ? hitSlopSize : undefined;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled || isDisabled}
|
||||
hitSlop={hitSlop}
|
||||
style={({pressed}) => ({
|
||||
opacity: disabled || isDisabled ? 0.3 : pressed ? 0.7 : 1,
|
||||
})}>
|
||||
<Svg width={s} height={s} viewBox={`0 0 ${s} ${s}`}>
|
||||
<Path
|
||||
d={buildButtonBevelPath(s, s, config.bevelSize, borderWidth)}
|
||||
fill="transparent"
|
||||
stroke={accentColor}
|
||||
strokeWidth={borderWidth}
|
||||
/>
|
||||
<Path
|
||||
d={iconPath}
|
||||
fill="none"
|
||||
stroke={accentColor}
|
||||
strokeWidth={sw}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Svg>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.wrapper, style]}>
|
||||
{label && (
|
||||
<Text style={[styles.label, {color: DTColors.light}]}>{label}</Text>
|
||||
)}
|
||||
<View style={styles.container}>
|
||||
{renderStepButton('minus', handleDecrement, atMin)}
|
||||
<View style={[styles.valueContainer, {minWidth: config.minWidth}]}>
|
||||
<Text
|
||||
style={[
|
||||
styles.valueText,
|
||||
{fontSize: config.fontSize, color: accentColor},
|
||||
]}>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
{renderStepButton('plus', handleIncrement, atMax)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
gap: 8,
|
||||
},
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
valueContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
valueText: {
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
229
packages/react-native/src/components/DTRadioGroup.tsx
Normal file
229
packages/react-native/src/components/DTRadioGroup.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* DT RadioGroup + RadioOption Components
|
||||
*
|
||||
* Custom-built angular radio selection following the Dangerous Things design language.
|
||||
* RNP RadioButton uses circular geometry with animated SVG circles,
|
||||
* so we build hexagonal indicators from scratch.
|
||||
*
|
||||
* Web CSS reference (AddOnSelector single-select):
|
||||
* - Selected: border highlight + semi-transparent fill (var(--selected) at 70% opacity)
|
||||
* - Hexagon indicator instead of circular radio button
|
||||
*/
|
||||
|
||||
import {createContext, useContext} from 'react';
|
||||
import {StyleSheet, View, ViewStyle, StyleProp, Pressable} from 'react-native';
|
||||
import {Text} from 'react-native-paper';
|
||||
import Svg, {Polygon} from 'react-native-svg';
|
||||
import {DTColors, getColor} from '../theme/colors';
|
||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||
|
||||
// --- Context ---
|
||||
|
||||
interface RadioContextValue {
|
||||
value: string | null;
|
||||
onValueChange: (value: string) => void;
|
||||
variant: DTVariant;
|
||||
}
|
||||
|
||||
const RadioContext = createContext<RadioContextValue | null>(null);
|
||||
|
||||
// --- DTRadioGroup ---
|
||||
|
||||
interface DTRadioGroupProps {
|
||||
/**
|
||||
* Currently selected value
|
||||
*/
|
||||
value: string | null;
|
||||
/**
|
||||
* Called when selection changes
|
||||
*/
|
||||
onValueChange: (value: string) => void;
|
||||
/**
|
||||
* Visual variant
|
||||
* @default 'normal'
|
||||
*/
|
||||
variant?: DTVariant;
|
||||
/**
|
||||
* Radio option children
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Additional styles for the container
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* DT-styled RadioGroup container
|
||||
*
|
||||
* @example
|
||||
* <DTRadioGroup value={selected} onValueChange={setSelected} variant="normal">
|
||||
* <DTRadioOption value="a" label="Option A" />
|
||||
* <DTRadioOption value="b" label="Option B" description="With description" />
|
||||
* </DTRadioGroup>
|
||||
*/
|
||||
export function DTRadioGroup({
|
||||
value,
|
||||
onValueChange,
|
||||
variant = 'normal',
|
||||
children,
|
||||
style,
|
||||
}: DTRadioGroupProps) {
|
||||
return (
|
||||
<RadioContext.Provider value={{value, onValueChange, variant}}>
|
||||
<View style={[styles.group, style]}>{children}</View>
|
||||
</RadioContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// --- DTRadioOption ---
|
||||
|
||||
interface DTRadioOptionProps {
|
||||
/**
|
||||
* Value identifier for this option
|
||||
*/
|
||||
value: string;
|
||||
/**
|
||||
* Label text
|
||||
*/
|
||||
label: string;
|
||||
/**
|
||||
* Optional description text
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* Whether this option is disabled
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Additional styles
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
const INDICATOR_SIZE = 22;
|
||||
const INDICATOR_INNER = 12;
|
||||
const INDICATOR_BORDER = 2;
|
||||
|
||||
/** Generate flat-top hexagon points string for SVG Polygon */
|
||||
function hexPoints(cx: number, cy: number, r: number): string {
|
||||
const pts: string[] = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const angle = (Math.PI / 3) * i - Math.PI / 6; // flat-top: start at -30°
|
||||
pts.push(`${cx + r * Math.cos(angle)},${cy + r * Math.sin(angle)}`);
|
||||
}
|
||||
return pts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* DT-styled RadioOption with hexagonal indicator
|
||||
*
|
||||
* Must be used inside a DTRadioGroup.
|
||||
*
|
||||
* @example
|
||||
* <DTRadioOption value="option1" label="First option" />
|
||||
*/
|
||||
export function DTRadioOption({
|
||||
value,
|
||||
label,
|
||||
description,
|
||||
disabled = false,
|
||||
style,
|
||||
}: DTRadioOptionProps) {
|
||||
const context = useContext(RadioContext);
|
||||
if (!context) {
|
||||
throw new Error('DTRadioOption must be used inside DTRadioGroup');
|
||||
}
|
||||
|
||||
const {value: selectedValue, onValueChange, variant} = context;
|
||||
const isSelected = selectedValue === value;
|
||||
const accentColor = getVariantColor(variant);
|
||||
const selectedBgColor = getColor(
|
||||
variant === 'normal'
|
||||
? 'modeNormalSelected'
|
||||
: variant === 'emphasis'
|
||||
? 'modeEmphasisSelected'
|
||||
: 'modeNormal',
|
||||
);
|
||||
const opacity = disabled ? 0.5 : 1;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => !disabled && onValueChange(value)}
|
||||
style={({pressed}) => [
|
||||
styles.option,
|
||||
{
|
||||
borderColor: isSelected ? accentColor : getColor('modeNormal', 0.3),
|
||||
backgroundColor: isSelected ? selectedBgColor : 'transparent',
|
||||
opacity: pressed ? 0.7 : opacity,
|
||||
},
|
||||
style,
|
||||
]}>
|
||||
{/* Hexagon indicator */}
|
||||
<Svg
|
||||
width={INDICATOR_SIZE}
|
||||
height={INDICATOR_SIZE}
|
||||
viewBox={`0 0 ${INDICATOR_SIZE} ${INDICATOR_SIZE}`}>
|
||||
<Polygon
|
||||
points={hexPoints(INDICATOR_SIZE / 2, INDICATOR_SIZE / 2, INDICATOR_SIZE / 2 - INDICATOR_BORDER / 2)}
|
||||
fill="transparent"
|
||||
stroke={accentColor}
|
||||
strokeWidth={INDICATOR_BORDER}
|
||||
strokeLinejoin="miter"
|
||||
/>
|
||||
{isSelected && (
|
||||
<Polygon
|
||||
points={hexPoints(INDICATOR_SIZE / 2, INDICATOR_SIZE / 2, INDICATOR_INNER / 2)}
|
||||
fill={accentColor}
|
||||
/>
|
||||
)}
|
||||
</Svg>
|
||||
{/* Text content */}
|
||||
<View style={styles.optionText}>
|
||||
<Text
|
||||
style={[
|
||||
styles.optionLabel,
|
||||
{color: isSelected ? DTColors.dark : DTColors.light},
|
||||
]}>
|
||||
{label}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text
|
||||
variant="bodySmall"
|
||||
style={[
|
||||
styles.optionDescription,
|
||||
{color: isSelected ? DTColors.dark : DTColors.disabled},
|
||||
]}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
group: {
|
||||
gap: 8,
|
||||
},
|
||||
option: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: 12,
|
||||
borderWidth: 2,
|
||||
},
|
||||
optionText: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
optionLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
optionDescription: {
|
||||
fontSize: 12,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
});
|
||||
163
packages/react-native/src/components/DTSearchInput.tsx
Normal file
163
packages/react-native/src/components/DTSearchInput.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* DT SearchInput Component
|
||||
*
|
||||
* A themed search input wrapping React Native Paper's Searchbar
|
||||
* with the Dangerous Things angular aesthetic.
|
||||
*
|
||||
* Styled input only — predictive results logic is app-specific
|
||||
* (composable principle from CLAUDE.md).
|
||||
*/
|
||||
|
||||
import {useState, useRef, useEffect} from 'react';
|
||||
import {StyleSheet, View, ViewStyle, TextStyle, StyleProp, Animated} from 'react-native';
|
||||
import {Searchbar, ActivityIndicator} from 'react-native-paper';
|
||||
import {DTColors} from '../theme/colors';
|
||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||
|
||||
interface DTSearchInputProps {
|
||||
/**
|
||||
* Current search text
|
||||
*/
|
||||
value: string;
|
||||
/**
|
||||
* Called when text changes
|
||||
*/
|
||||
onChangeText: (text: string) => void;
|
||||
/**
|
||||
* Called when search is submitted
|
||||
*/
|
||||
onSubmit?: () => void;
|
||||
/**
|
||||
* Placeholder text
|
||||
* @default 'Search...'
|
||||
*/
|
||||
placeholder?: string;
|
||||
/**
|
||||
* Visual variant
|
||||
* @default 'normal'
|
||||
*/
|
||||
variant?: DTVariant;
|
||||
/**
|
||||
* Show loading indicator instead of search icon
|
||||
* @default false
|
||||
*/
|
||||
loading?: boolean;
|
||||
/**
|
||||
* Whether the input is disabled
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Auto-focus the input on mount
|
||||
*/
|
||||
autoFocus?: boolean;
|
||||
/**
|
||||
* Custom color (overrides variant)
|
||||
*/
|
||||
color?: string;
|
||||
/**
|
||||
* Additional styles for the container
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/**
|
||||
* Additional styles for the input text
|
||||
*/
|
||||
inputStyle?: StyleProp<TextStyle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* DT-styled SearchInput wrapping React Native Paper Searchbar
|
||||
*
|
||||
* @example
|
||||
* <DTSearchInput value={query} onChangeText={setQuery} onSubmit={handleSearch} />
|
||||
*
|
||||
* @example
|
||||
* <DTSearchInput value={query} onChangeText={setQuery} loading={isSearching} variant="emphasis" />
|
||||
*/
|
||||
export function DTSearchInput({
|
||||
value,
|
||||
onChangeText,
|
||||
onSubmit,
|
||||
placeholder = 'Search...',
|
||||
variant = 'normal',
|
||||
loading = false,
|
||||
disabled = false,
|
||||
autoFocus = false,
|
||||
color,
|
||||
style,
|
||||
inputStyle,
|
||||
}: DTSearchInputProps) {
|
||||
const accentColor = getVariantColor(variant, color);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const focusAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
const anim = Animated.timing(focusAnim, {
|
||||
toValue: focused ? 1 : 0,
|
||||
duration: 150,
|
||||
useNativeDriver: false,
|
||||
});
|
||||
anim.start();
|
||||
return () => anim.stop();
|
||||
}, [focused, focusAnim]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<Searchbar
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
onSubmitEditing={onSubmit}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
returnKeyType="search"
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={DTColors.disabled}
|
||||
editable={!disabled}
|
||||
autoFocus={autoFocus}
|
||||
icon={
|
||||
loading
|
||||
? () => <ActivityIndicator size={20} color={accentColor} />
|
||||
: 'magnify'
|
||||
}
|
||||
iconColor={accentColor}
|
||||
inputStyle={[styles.input, {color: DTColors.light}, inputStyle]}
|
||||
style={[styles.searchbar, {borderColor: accentColor}]}
|
||||
rippleColor={accentColor}
|
||||
theme={{
|
||||
colors: {
|
||||
elevation: {level3: DTColors.dark},
|
||||
onSurface: DTColors.light,
|
||||
onSurfaceVariant: accentColor,
|
||||
},
|
||||
roundness: 0,
|
||||
}}
|
||||
/>
|
||||
{/* Focus glow bar — matches DTTextInput */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.focusBar,
|
||||
{
|
||||
backgroundColor: accentColor,
|
||||
opacity: focusAnim,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {},
|
||||
searchbar: {
|
||||
borderWidth: 2,
|
||||
backgroundColor: DTColors.dark,
|
||||
elevation: 0,
|
||||
},
|
||||
input: {
|
||||
fontSize: 14,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
focusBar: {
|
||||
height: 4,
|
||||
marginTop: 1,
|
||||
},
|
||||
});
|
||||
152
packages/react-native/src/components/DTSwitch.tsx
Normal file
152
packages/react-native/src/components/DTSwitch.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* DT Switch Component
|
||||
*
|
||||
* Custom-built angular toggle switch following the Dangerous Things design language.
|
||||
* RNP Switch delegates to native <NativeSwitch> with no shape control,
|
||||
* so we build a rectangular toggle from scratch.
|
||||
*/
|
||||
|
||||
import {useRef, useEffect} from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
ViewStyle,
|
||||
StyleProp,
|
||||
Pressable,
|
||||
Animated,
|
||||
} from 'react-native';
|
||||
import {Text} from 'react-native-paper';
|
||||
import {DTColors} from '../theme/colors';
|
||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||
|
||||
const TRACK_WIDTH = 48;
|
||||
const TRACK_HEIGHT = 26;
|
||||
const THUMB_SIZE = 20;
|
||||
const THUMB_MARGIN = 3;
|
||||
|
||||
interface DTSwitchProps {
|
||||
/**
|
||||
* Whether the switch is on
|
||||
*/
|
||||
value: boolean;
|
||||
/**
|
||||
* Called when the value changes
|
||||
*/
|
||||
onValueChange: (value: boolean) => void;
|
||||
/**
|
||||
* Visual variant
|
||||
* @default 'normal'
|
||||
*/
|
||||
variant?: DTVariant;
|
||||
/**
|
||||
* Whether the switch is disabled
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Label text displayed beside the switch
|
||||
*/
|
||||
label?: string;
|
||||
/**
|
||||
* Custom color (overrides variant)
|
||||
*/
|
||||
color?: string;
|
||||
/**
|
||||
* Additional styles for the container
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* DT-styled angular Switch/Toggle
|
||||
*
|
||||
* @example
|
||||
* <DTSwitch value={enabled} onValueChange={setEnabled} label="Enable NFC" />
|
||||
*
|
||||
* @example
|
||||
* <DTSwitch value={true} onValueChange={toggle} variant="success" />
|
||||
*/
|
||||
export function DTSwitch({
|
||||
value,
|
||||
onValueChange,
|
||||
variant = 'normal',
|
||||
disabled = false,
|
||||
label,
|
||||
color,
|
||||
style,
|
||||
}: DTSwitchProps) {
|
||||
const accentColor = getVariantColor(variant, color);
|
||||
const thumbAnim = useRef(new Animated.Value(value ? 1 : 0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(thumbAnim, {
|
||||
toValue: value ? 1 : 0,
|
||||
duration: 150,
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
}, [value, thumbAnim]);
|
||||
|
||||
const thumbTranslateX = thumbAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [THUMB_MARGIN, TRACK_WIDTH - THUMB_SIZE - THUMB_MARGIN],
|
||||
});
|
||||
|
||||
const trackColor = thumbAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [DTColors.disabledBackground, accentColor],
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => !disabled && onValueChange(!value)}
|
||||
style={({pressed}) => [
|
||||
styles.container,
|
||||
{opacity: disabled ? 0.5 : pressed ? 0.7 : 1},
|
||||
style,
|
||||
]}>
|
||||
{label && (
|
||||
<Text style={[styles.label, {color: DTColors.light}]}>{label}</Text>
|
||||
)}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.track,
|
||||
{
|
||||
backgroundColor: trackColor,
|
||||
borderColor: accentColor,
|
||||
},
|
||||
]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.thumb,
|
||||
{
|
||||
backgroundColor: value ? DTColors.dark : DTColors.light,
|
||||
transform: [{translateX: thumbTranslateX}],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
track: {
|
||||
width: TRACK_WIDTH,
|
||||
height: TRACK_HEIGHT,
|
||||
borderWidth: 2,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
thumb: {
|
||||
width: THUMB_SIZE,
|
||||
height: THUMB_SIZE,
|
||||
},
|
||||
});
|
||||
172
packages/react-native/src/components/DTTextInput.tsx
Normal file
172
packages/react-native/src/components/DTTextInput.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* DT TextInput Component
|
||||
*
|
||||
* A themed text input wrapping React Native Paper's TextInput
|
||||
* with the Dangerous Things angular aesthetic.
|
||||
*
|
||||
* Web CSS reference (.mode-input):
|
||||
* - 2px solid border in mode color
|
||||
* - Focus: box-shadow 0 4px 0 1px var(--mode)
|
||||
* - border-radius: 0
|
||||
*/
|
||||
|
||||
import {useState, useRef, useEffect} from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
ViewStyle,
|
||||
TextStyle,
|
||||
StyleProp,
|
||||
Animated,
|
||||
} from 'react-native';
|
||||
import {TextInput, TextInputProps} from 'react-native-paper';
|
||||
import {DTColors} from '../theme/colors';
|
||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||
|
||||
interface DTTextInputProps
|
||||
extends Omit<TextInputProps, 'mode' | 'theme' | 'error' | 'ref'> {
|
||||
/**
|
||||
* Visual variant
|
||||
* @default 'normal'
|
||||
*/
|
||||
variant?: DTVariant;
|
||||
/**
|
||||
* Custom color (overrides variant)
|
||||
*/
|
||||
color?: string;
|
||||
/**
|
||||
* Border width
|
||||
* @default 2
|
||||
*/
|
||||
borderWidth?: number;
|
||||
/**
|
||||
* Whether the input is in error state
|
||||
*/
|
||||
error?: boolean;
|
||||
/**
|
||||
* Error message text displayed below input
|
||||
*/
|
||||
errorMessage?: string;
|
||||
/**
|
||||
* Additional styles for the outer wrapper
|
||||
*/
|
||||
containerStyle?: StyleProp<ViewStyle>;
|
||||
/**
|
||||
* Additional styles for the input text
|
||||
*/
|
||||
inputStyle?: StyleProp<TextStyle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* DT-styled TextInput wrapping React Native Paper TextInput
|
||||
*
|
||||
* @example
|
||||
* <DTTextInput variant="normal" label="Username" value={name} onChangeText={setName} />
|
||||
*
|
||||
* @example
|
||||
* <DTTextInput variant="warning" error errorMessage="Required field" label="Email" />
|
||||
*/
|
||||
export function DTTextInput({
|
||||
variant = 'normal',
|
||||
color,
|
||||
borderWidth = 2,
|
||||
error = false,
|
||||
errorMessage,
|
||||
containerStyle,
|
||||
inputStyle,
|
||||
onFocus,
|
||||
onBlur,
|
||||
style,
|
||||
...props
|
||||
}: DTTextInputProps) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const focusAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
const accentColor = error
|
||||
? DTColors.modeWarning
|
||||
: getVariantColor(variant, color);
|
||||
|
||||
useEffect(() => {
|
||||
const anim = Animated.timing(focusAnim, {
|
||||
toValue: focused ? 1 : 0,
|
||||
duration: 150,
|
||||
useNativeDriver: false,
|
||||
});
|
||||
anim.start();
|
||||
return () => anim.stop();
|
||||
}, [focused, focusAnim]);
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<View
|
||||
style={[
|
||||
styles.inputWrapper,
|
||||
{borderColor: accentColor, borderWidth},
|
||||
]}>
|
||||
<TextInput
|
||||
{...props}
|
||||
mode="flat"
|
||||
error={false}
|
||||
onFocus={e => {
|
||||
setFocused(true);
|
||||
onFocus?.(e);
|
||||
}}
|
||||
onBlur={e => {
|
||||
setFocused(false);
|
||||
onBlur?.(e);
|
||||
}}
|
||||
style={[styles.input, inputStyle, style]}
|
||||
textColor={DTColors.light}
|
||||
placeholderTextColor={DTColors.disabled}
|
||||
activeUnderlineColor="transparent"
|
||||
underlineColor="transparent"
|
||||
theme={{
|
||||
colors: {
|
||||
primary: accentColor,
|
||||
onSurfaceVariant: accentColor,
|
||||
},
|
||||
roundness: 0,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
{/* Focus glow bar — simulates web box-shadow: 0 4px 0 1px */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.focusBar,
|
||||
{
|
||||
backgroundColor: accentColor,
|
||||
opacity: focusAnim,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{error && errorMessage && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Animated.Text style={[styles.errorText, {color: DTColors.modeWarning}]}>
|
||||
{errorMessage}
|
||||
</Animated.Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
inputWrapper: {
|
||||
backgroundColor: DTColors.dark,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
focusBar: {
|
||||
height: 4,
|
||||
marginTop: 1,
|
||||
},
|
||||
errorContainer: {
|
||||
marginTop: 4,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
77
packages/react-native/src/index.ts
Normal file
77
packages/react-native/src/index.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @anthropic-dangerous-things/react-native-theme
|
||||
*
|
||||
* Dangerous Things design system for React Native with React Native Paper.
|
||||
* Cyberpunk aesthetic with high-contrast neon colors.
|
||||
*/
|
||||
|
||||
// Theme exports
|
||||
export { DTColors, getColor } from './theme/colors';
|
||||
export type { DTColorKey } from './theme/colors';
|
||||
|
||||
export {
|
||||
DTTypography,
|
||||
DTFonts,
|
||||
DTFontWeights,
|
||||
} from './theme/typography';
|
||||
export type { DTTypographyKey } from './theme/typography';
|
||||
|
||||
export {
|
||||
DTDarkTheme,
|
||||
DTExtendedDarkTheme,
|
||||
usePaperTheme,
|
||||
} from './theme/paperTheme';
|
||||
export type { DTExtendedTheme } from './theme/paperTheme';
|
||||
|
||||
export { DTThemeProvider, useDTTheme } from './theme/DTThemeProvider';
|
||||
|
||||
// Shared utilities
|
||||
export type { DTVariant } from './utils/variantColors';
|
||||
export { variantColorMap, getVariantColor } from './utils/variantColors';
|
||||
export { buildBeveledRectPath } from './utils/bevelPaths';
|
||||
export { useComponentLayout } from './utils/useComponentLayout';
|
||||
|
||||
// Component exports — existing
|
||||
export { DTCard, DTCardClipPath } from './components/DTCard';
|
||||
export { DTButton } from './components/DTButton';
|
||||
export { DTChip } from './components/DTChip';
|
||||
export { DTLabel, DTLabelClipPath } from './components/DTLabel';
|
||||
|
||||
// Component exports — form primitives
|
||||
export { DTTextInput } from './components/DTTextInput';
|
||||
export { DTCheckbox } from './components/DTCheckbox';
|
||||
export { DTSwitch } from './components/DTSwitch';
|
||||
export { DTRadioGroup, DTRadioOption } from './components/DTRadioGroup';
|
||||
export { DTQuantityStepper } from './components/DTQuantityStepper';
|
||||
|
||||
// Component exports — layout & containers
|
||||
export { DTProgressBar } from './components/DTProgressBar';
|
||||
export { DTMediaFrame } from './components/DTMediaFrame';
|
||||
export { DTAccordion } from './components/DTAccordion';
|
||||
export type { DTAccordionSection } from './components/DTAccordion';
|
||||
|
||||
// Component exports — complex interactive
|
||||
export { DTModal } from './components/DTModal';
|
||||
export { DTDrawer } from './components/DTDrawer';
|
||||
export { DTGallery } from './components/DTGallery';
|
||||
export type { DTGalleryItem } from './components/DTGallery';
|
||||
export { DTSearchInput } from './components/DTSearchInput';
|
||||
|
||||
// Component exports — navigation & decorative
|
||||
export { DTMenu, DTMenuDropdown } from './components/DTMenu';
|
||||
export type { DTMenuItem } from './components/DTMenu';
|
||||
export { DTHexagon } from './components/DTHexagon';
|
||||
|
||||
// Re-export commonly used Paper components for convenience
|
||||
export {
|
||||
Text,
|
||||
Surface,
|
||||
IconButton,
|
||||
ActivityIndicator,
|
||||
Divider,
|
||||
List,
|
||||
Portal,
|
||||
Modal,
|
||||
Snackbar,
|
||||
TextInput,
|
||||
} from 'react-native-paper';
|
||||
80
packages/react-native/src/theme/DTThemeProvider.tsx
Normal file
80
packages/react-native/src/theme/DTThemeProvider.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* DT Theme Provider
|
||||
*
|
||||
* Wraps your app with the Dangerous Things theme.
|
||||
* Combines React Native Paper's PaperProvider with
|
||||
* custom theme context for extended DT colors.
|
||||
*/
|
||||
|
||||
import { createContext, useContext, ReactNode } from 'react';
|
||||
import { PaperProvider } from 'react-native-paper';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { StatusBar } from 'react-native';
|
||||
import { DTExtendedDarkTheme, DTExtendedTheme } from './paperTheme';
|
||||
|
||||
/**
|
||||
* Context for accessing extended theme properties
|
||||
*/
|
||||
const DTThemeContext = createContext<DTExtendedTheme>(DTExtendedDarkTheme);
|
||||
|
||||
/**
|
||||
* Hook to access the full DT theme including custom properties
|
||||
*
|
||||
* @example
|
||||
* const theme = useDTTheme();
|
||||
* const successColor = theme.custom.success;
|
||||
*/
|
||||
export function useDTTheme(): DTExtendedTheme {
|
||||
return useContext(DTThemeContext);
|
||||
}
|
||||
|
||||
interface DTThemeProviderProps {
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Override the default theme (useful for testing or custom variants)
|
||||
*/
|
||||
theme?: DTExtendedTheme;
|
||||
/**
|
||||
* Include SafeAreaProvider wrapper (default: true)
|
||||
* Set to false if you already have SafeAreaProvider higher in tree
|
||||
*/
|
||||
includeSafeArea?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme provider component
|
||||
*
|
||||
* @example
|
||||
* // In your App.tsx
|
||||
* import { DTThemeProvider } from '@anthropic-dangerous-things/react-native-theme';
|
||||
*
|
||||
* export default function App() {
|
||||
* return (
|
||||
* <DTThemeProvider>
|
||||
* <NavigationContainer>
|
||||
* <YourApp />
|
||||
* </NavigationContainer>
|
||||
* </DTThemeProvider>
|
||||
* );
|
||||
* }
|
||||
*/
|
||||
export function DTThemeProvider({
|
||||
children,
|
||||
theme = DTExtendedDarkTheme,
|
||||
includeSafeArea = true,
|
||||
}: DTThemeProviderProps) {
|
||||
const content = (
|
||||
<DTThemeContext.Provider value={theme}>
|
||||
<PaperProvider theme={theme}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="#000000" />
|
||||
{children}
|
||||
</PaperProvider>
|
||||
</DTThemeContext.Provider>
|
||||
);
|
||||
|
||||
if (includeSafeArea) {
|
||||
return <SafeAreaProvider>{content}</SafeAreaProvider>;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
69
packages/react-native/src/theme/colors.ts
Normal file
69
packages/react-native/src/theme/colors.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Dangerous Things Color Palette
|
||||
*
|
||||
* Based on the dt-shopify-storefront design system.
|
||||
* Cyberpunk aesthetic with high-contrast neon colors on dark backgrounds.
|
||||
*/
|
||||
|
||||
export const DTColors = {
|
||||
// Base colors
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF',
|
||||
|
||||
// Mode colors (primary semantic palette)
|
||||
modeNormal: '#00FFFF', // Cyan - primary actions, links, borders
|
||||
modeNormalSelected: 'rgba(0, 255, 255, 0.7)',
|
||||
modeEmphasis: '#FFFF00', // Yellow - highlights, emphasis, headers
|
||||
modeEmphasisSelected: 'rgba(255, 255, 0, 0.7)',
|
||||
modeWarning: '#FF0000', // Red - errors, warnings, destructive actions
|
||||
modeSuccess: '#00FF00', // Green - success states, confirmations
|
||||
modeOther: '#FF00FF', // Magenta - miscellaneous, secondary info
|
||||
|
||||
// Semantic aliases
|
||||
primary: '#00FFFF',
|
||||
primaryVariant: 'rgba(0, 255, 255, 0.7)',
|
||||
secondary: '#FFFF00',
|
||||
secondaryVariant: 'rgba(255, 255, 0, 0.7)',
|
||||
error: '#FF0000',
|
||||
success: '#00FF00',
|
||||
|
||||
// Surface colors
|
||||
background: '#000000',
|
||||
surface: '#000000',
|
||||
surfaceVariant: '#070707',
|
||||
|
||||
// Text colors
|
||||
onBackground: '#FFFFFF',
|
||||
onSurface: '#FFFFFF',
|
||||
onPrimary: '#000000',
|
||||
onSecondary: '#000000',
|
||||
onError: '#000000',
|
||||
|
||||
// Border colors
|
||||
border: '#00FFFF',
|
||||
borderEmphasis: '#FFFF00',
|
||||
|
||||
// Transparency variants (for overlays, disabled states)
|
||||
overlay: 'rgba(0, 0, 0, 0.7)',
|
||||
disabled: 'rgba(255, 255, 255, 0.3)',
|
||||
disabledBackground: 'rgba(255, 255, 255, 0.1)',
|
||||
} as const;
|
||||
|
||||
export type DTColorKey = keyof typeof DTColors;
|
||||
|
||||
/**
|
||||
* Get a color with optional opacity override
|
||||
*/
|
||||
export function getColor(color: DTColorKey, opacity?: number): string {
|
||||
const baseColor = DTColors[color];
|
||||
if (opacity === undefined || baseColor.startsWith('rgba')) {
|
||||
return baseColor;
|
||||
}
|
||||
|
||||
// Convert hex to rgba
|
||||
const hex = baseColor.replace('#', '');
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
}
|
||||
149
packages/react-native/src/theme/paperTheme.ts
Normal file
149
packages/react-native/src/theme/paperTheme.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* React Native Paper MD3 Theme Configuration
|
||||
*
|
||||
* This creates a Material Design 3 compliant theme that follows
|
||||
* the Dangerous Things cyberpunk aesthetic while respecting
|
||||
* React Native Paper's design principles.
|
||||
*/
|
||||
|
||||
import { MD3DarkTheme, MD3Theme, configureFonts } from 'react-native-paper';
|
||||
import { DTColors } from './colors';
|
||||
import { DTTypography, DTFonts } from './typography';
|
||||
|
||||
/**
|
||||
* MD3 Font configuration for React Native Paper
|
||||
* Maps our typography to RNP's expected font config
|
||||
*/
|
||||
const fontConfig = {
|
||||
fontFamily: DTFonts.regular,
|
||||
displayLarge: DTTypography.displayLarge,
|
||||
displayMedium: DTTypography.displayMedium,
|
||||
displaySmall: DTTypography.displaySmall,
|
||||
headlineLarge: DTTypography.headlineLarge,
|
||||
headlineMedium: DTTypography.headlineMedium,
|
||||
headlineSmall: DTTypography.headlineSmall,
|
||||
titleLarge: DTTypography.titleLarge,
|
||||
titleMedium: DTTypography.titleMedium,
|
||||
titleSmall: DTTypography.titleSmall,
|
||||
bodyLarge: DTTypography.bodyLarge,
|
||||
bodyMedium: DTTypography.bodyMedium,
|
||||
bodySmall: DTTypography.bodySmall,
|
||||
labelLarge: DTTypography.labelLarge,
|
||||
labelMedium: DTTypography.labelMedium,
|
||||
labelSmall: DTTypography.labelSmall,
|
||||
};
|
||||
|
||||
/**
|
||||
* Dangerous Things Dark Theme
|
||||
*
|
||||
* Based on MD3DarkTheme with cyberpunk color overrides.
|
||||
* Primary = Cyan, Secondary = Yellow (following DT brand)
|
||||
*/
|
||||
export const DTDarkTheme: MD3Theme = {
|
||||
...MD3DarkTheme,
|
||||
dark: true,
|
||||
roundness: 4, // Slightly rounded, but we use custom clip-path for bevels
|
||||
|
||||
fonts: configureFonts({ config: fontConfig }),
|
||||
|
||||
colors: {
|
||||
...MD3DarkTheme.colors,
|
||||
|
||||
// Primary (Cyan)
|
||||
primary: DTColors.modeNormal,
|
||||
onPrimary: DTColors.dark,
|
||||
primaryContainer: DTColors.modeNormalSelected,
|
||||
onPrimaryContainer: DTColors.dark,
|
||||
|
||||
// Secondary (Yellow)
|
||||
secondary: DTColors.modeEmphasis,
|
||||
onSecondary: DTColors.dark,
|
||||
secondaryContainer: DTColors.modeEmphasisSelected,
|
||||
onSecondaryContainer: DTColors.dark,
|
||||
|
||||
// Tertiary (Magenta)
|
||||
tertiary: DTColors.modeOther,
|
||||
onTertiary: DTColors.dark,
|
||||
tertiaryContainer: 'rgba(255, 0, 255, 0.3)',
|
||||
onTertiaryContainer: DTColors.light,
|
||||
|
||||
// Error (Red)
|
||||
error: DTColors.modeWarning,
|
||||
onError: DTColors.dark,
|
||||
errorContainer: 'rgba(255, 0, 0, 0.3)',
|
||||
onErrorContainer: DTColors.light,
|
||||
|
||||
// Background & Surface
|
||||
background: DTColors.background,
|
||||
onBackground: DTColors.onBackground,
|
||||
surface: DTColors.surface,
|
||||
onSurface: DTColors.onSurface,
|
||||
surfaceVariant: DTColors.surfaceVariant,
|
||||
onSurfaceVariant: DTColors.light,
|
||||
|
||||
// Surface elevation (MD3 uses tonal elevation)
|
||||
elevation: {
|
||||
level0: 'transparent',
|
||||
level1: DTColors.surfaceVariant,
|
||||
level2: '#0a0a0a',
|
||||
level3: '#0f0f0f',
|
||||
level4: '#121212',
|
||||
level5: '#151515',
|
||||
},
|
||||
|
||||
// Outline (borders)
|
||||
outline: DTColors.modeNormal,
|
||||
outlineVariant: 'rgba(0, 255, 255, 0.5)',
|
||||
|
||||
// Inverse (for snackbars, etc.)
|
||||
inverseSurface: DTColors.light,
|
||||
inverseOnSurface: DTColors.dark,
|
||||
inversePrimary: '#008B8B', // Darker cyan for light backgrounds
|
||||
|
||||
// Misc
|
||||
shadow: DTColors.dark,
|
||||
scrim: DTColors.overlay,
|
||||
surfaceDisabled: DTColors.disabledBackground,
|
||||
onSurfaceDisabled: DTColors.disabled,
|
||||
backdrop: DTColors.overlay,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Extended theme with DT-specific custom properties
|
||||
* Use this when you need access to non-MD3 colors
|
||||
*/
|
||||
export interface DTExtendedTheme extends MD3Theme {
|
||||
custom: {
|
||||
success: string;
|
||||
warning: string;
|
||||
modeNormal: string;
|
||||
modeEmphasis: string;
|
||||
modeWarning: string;
|
||||
modeSuccess: string;
|
||||
modeOther: string;
|
||||
border: string;
|
||||
borderEmphasis: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const DTExtendedDarkTheme: DTExtendedTheme = {
|
||||
...DTDarkTheme,
|
||||
custom: {
|
||||
success: DTColors.modeSuccess,
|
||||
warning: DTColors.modeWarning,
|
||||
modeNormal: DTColors.modeNormal,
|
||||
modeEmphasis: DTColors.modeEmphasis,
|
||||
modeWarning: DTColors.modeWarning,
|
||||
modeSuccess: DTColors.modeSuccess,
|
||||
modeOther: DTColors.modeOther,
|
||||
border: DTColors.border,
|
||||
borderEmphasis: DTColors.borderEmphasis,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to access extended theme properties
|
||||
* Usage: const { custom } = useDTTheme();
|
||||
*/
|
||||
export { useTheme as usePaperTheme } from 'react-native-paper';
|
||||
190
packages/react-native/src/theme/typography.ts
Normal file
190
packages/react-native/src/theme/typography.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Dangerous Things Typography
|
||||
*
|
||||
* Primary font: Tektur (variable font)
|
||||
* Fallback: System default sans-serif
|
||||
*
|
||||
* Note: To use Tektur, you must:
|
||||
* 1. Download Tektur from Google Fonts
|
||||
* 2. Add to your project's assets/fonts/
|
||||
* 3. Link fonts in react-native.config.js or use expo-font
|
||||
*/
|
||||
|
||||
import { Platform, TextStyle } from 'react-native';
|
||||
|
||||
/**
|
||||
* Font family configuration
|
||||
* Tektur is a variable font with weights 100-900
|
||||
*/
|
||||
export const DTFonts = {
|
||||
regular: Platform.select({
|
||||
ios: 'Tektur',
|
||||
android: 'Tektur-Regular',
|
||||
default: 'Tektur',
|
||||
}),
|
||||
medium: Platform.select({
|
||||
ios: 'Tektur',
|
||||
android: 'Tektur-Medium',
|
||||
default: 'Tektur',
|
||||
}),
|
||||
bold: Platform.select({
|
||||
ios: 'Tektur',
|
||||
android: 'Tektur-Bold',
|
||||
default: 'Tektur',
|
||||
}),
|
||||
// Fallback when Tektur is not available
|
||||
fallback: Platform.select({
|
||||
ios: 'System',
|
||||
android: 'sans-serif',
|
||||
default: 'sans-serif',
|
||||
}),
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Font weights for variable font
|
||||
*/
|
||||
export const DTFontWeights = {
|
||||
thin: '100' as const,
|
||||
light: '300' as const,
|
||||
regular: '400' as const,
|
||||
medium: '500' as const,
|
||||
semibold: '600' as const,
|
||||
bold: '700' as const,
|
||||
extrabold: '800' as const,
|
||||
black: '900' as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Base text styles
|
||||
*/
|
||||
const baseTextStyle: TextStyle = {
|
||||
fontFamily: DTFonts.regular,
|
||||
color: '#FFFFFF',
|
||||
letterSpacing: 0.5,
|
||||
};
|
||||
|
||||
/**
|
||||
* Typography scale following Material Design 3 naming
|
||||
*/
|
||||
export const DTTypography = {
|
||||
// Display styles (large headlines)
|
||||
displayLarge: {
|
||||
...baseTextStyle,
|
||||
fontSize: 57,
|
||||
fontWeight: DTFontWeights.bold,
|
||||
lineHeight: 64,
|
||||
letterSpacing: -0.25,
|
||||
} as TextStyle,
|
||||
|
||||
displayMedium: {
|
||||
...baseTextStyle,
|
||||
fontSize: 45,
|
||||
fontWeight: DTFontWeights.bold,
|
||||
lineHeight: 52,
|
||||
} as TextStyle,
|
||||
|
||||
displaySmall: {
|
||||
...baseTextStyle,
|
||||
fontSize: 36,
|
||||
fontWeight: DTFontWeights.bold,
|
||||
lineHeight: 44,
|
||||
} as TextStyle,
|
||||
|
||||
// Headline styles
|
||||
headlineLarge: {
|
||||
...baseTextStyle,
|
||||
fontSize: 32,
|
||||
fontWeight: DTFontWeights.semibold,
|
||||
lineHeight: 40,
|
||||
} as TextStyle,
|
||||
|
||||
headlineMedium: {
|
||||
...baseTextStyle,
|
||||
fontSize: 28,
|
||||
fontWeight: DTFontWeights.semibold,
|
||||
lineHeight: 36,
|
||||
} as TextStyle,
|
||||
|
||||
headlineSmall: {
|
||||
...baseTextStyle,
|
||||
fontSize: 24,
|
||||
fontWeight: DTFontWeights.semibold,
|
||||
lineHeight: 32,
|
||||
} as TextStyle,
|
||||
|
||||
// Title styles
|
||||
titleLarge: {
|
||||
...baseTextStyle,
|
||||
fontSize: 22,
|
||||
fontWeight: DTFontWeights.medium,
|
||||
lineHeight: 28,
|
||||
} as TextStyle,
|
||||
|
||||
titleMedium: {
|
||||
...baseTextStyle,
|
||||
fontSize: 16,
|
||||
fontWeight: DTFontWeights.medium,
|
||||
lineHeight: 24,
|
||||
letterSpacing: 0.15,
|
||||
} as TextStyle,
|
||||
|
||||
titleSmall: {
|
||||
...baseTextStyle,
|
||||
fontSize: 14,
|
||||
fontWeight: DTFontWeights.medium,
|
||||
lineHeight: 20,
|
||||
letterSpacing: 0.1,
|
||||
} as TextStyle,
|
||||
|
||||
// Body styles
|
||||
bodyLarge: {
|
||||
...baseTextStyle,
|
||||
fontSize: 16,
|
||||
fontWeight: DTFontWeights.regular,
|
||||
lineHeight: 24,
|
||||
letterSpacing: 0.5,
|
||||
} as TextStyle,
|
||||
|
||||
bodyMedium: {
|
||||
...baseTextStyle,
|
||||
fontSize: 14,
|
||||
fontWeight: DTFontWeights.regular,
|
||||
lineHeight: 20,
|
||||
letterSpacing: 0.25,
|
||||
} as TextStyle,
|
||||
|
||||
bodySmall: {
|
||||
...baseTextStyle,
|
||||
fontSize: 12,
|
||||
fontWeight: DTFontWeights.regular,
|
||||
lineHeight: 16,
|
||||
letterSpacing: 0.4,
|
||||
} as TextStyle,
|
||||
|
||||
// Label styles
|
||||
labelLarge: {
|
||||
...baseTextStyle,
|
||||
fontSize: 14,
|
||||
fontWeight: DTFontWeights.medium,
|
||||
lineHeight: 20,
|
||||
letterSpacing: 0.1,
|
||||
} as TextStyle,
|
||||
|
||||
labelMedium: {
|
||||
...baseTextStyle,
|
||||
fontSize: 12,
|
||||
fontWeight: DTFontWeights.medium,
|
||||
lineHeight: 16,
|
||||
letterSpacing: 0.5,
|
||||
} as TextStyle,
|
||||
|
||||
labelSmall: {
|
||||
...baseTextStyle,
|
||||
fontSize: 11,
|
||||
fontWeight: DTFontWeights.medium,
|
||||
lineHeight: 16,
|
||||
letterSpacing: 0.5,
|
||||
} as TextStyle,
|
||||
} as const;
|
||||
|
||||
export type DTTypographyKey = keyof typeof DTTypography;
|
||||
152
packages/react-native/src/utils/bevelPaths.ts
Normal file
152
packages/react-native/src/utils/bevelPaths.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Shared SVG bevel path builders
|
||||
*
|
||||
* Generic and preset functions for generating SVG path data
|
||||
* for beveled rectangle shapes used across DT components.
|
||||
*/
|
||||
|
||||
type BevelCorner = 'topLeft' | 'topRight' | 'bottomRight' | 'bottomLeft';
|
||||
|
||||
interface BevelConfig {
|
||||
corners: Partial<Record<BevelCorner, number>>;
|
||||
strokeWidth?: number;
|
||||
/** Additional inset applied to all coordinates (used for frame overlays) */
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an SVG path for a rectangle with any combination of beveled corners.
|
||||
*
|
||||
* Each corner can have an independent bevel size. Corners without a bevel
|
||||
* value (or with 0) are rendered as sharp 90-degree angles.
|
||||
*/
|
||||
export function buildBeveledRectPath(
|
||||
width: number,
|
||||
height: number,
|
||||
config: BevelConfig,
|
||||
): string {
|
||||
const sw = config.strokeWidth ?? 0;
|
||||
const off = config.offset ?? 0;
|
||||
const inset = sw / 2 + off;
|
||||
const w = width - sw - off * 2;
|
||||
const h = height - sw - off * 2;
|
||||
|
||||
if (w <= 0 || h <= 0) return '';
|
||||
|
||||
const maxBevel = Math.min(w / 3, h / 3);
|
||||
const tl = Math.min(config.corners.topLeft ?? 0, maxBevel);
|
||||
const tr = Math.min(config.corners.topRight ?? 0, maxBevel);
|
||||
const br = Math.min(config.corners.bottomRight ?? 0, maxBevel);
|
||||
const bl = Math.min(config.corners.bottomLeft ?? 0, maxBevel);
|
||||
|
||||
// Build path clockwise from top-left
|
||||
return [
|
||||
// Top-left corner
|
||||
`M ${inset + tl} ${inset}`,
|
||||
// Top edge → top-right corner
|
||||
`L ${w + inset - tr} ${inset}`,
|
||||
tr > 0 ? `L ${w + inset} ${inset + tr}` : `L ${w + inset} ${inset}`,
|
||||
// Right edge → bottom-right corner
|
||||
br > 0
|
||||
? `L ${w + inset} ${h + inset - br} L ${w + inset - br} ${h + inset}`
|
||||
: `L ${w + inset} ${h + inset}`,
|
||||
// Bottom edge → bottom-left corner
|
||||
bl > 0
|
||||
? `L ${inset + bl} ${h + inset} L ${inset} ${h + inset - bl}`
|
||||
: `L ${inset} ${h + inset}`,
|
||||
// Left edge → back to top-left
|
||||
tl > 0 ? `L ${inset} ${inset + tl}` : `L ${inset} ${inset}`,
|
||||
'Z',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* DTButton shape: bottom-right bevel only
|
||||
*/
|
||||
export function buildButtonBevelPath(
|
||||
width: number,
|
||||
height: number,
|
||||
bevelSize: number,
|
||||
strokeWidth: number,
|
||||
): string {
|
||||
return buildBeveledRectPath(width, height, {
|
||||
corners: {bottomRight: bevelSize},
|
||||
strokeWidth,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DTCard shape: bottom-right (large) + bottom-left (small) bevels
|
||||
*/
|
||||
export function buildCardBevelPath(
|
||||
width: number,
|
||||
height: number,
|
||||
bevelBR: number,
|
||||
bevelBL: number,
|
||||
inset: number,
|
||||
): string {
|
||||
const w = width - inset * 2;
|
||||
const h = height - inset * 2;
|
||||
|
||||
if (w <= 0 || h <= 0) return '';
|
||||
|
||||
const br = Math.min(bevelBR, w / 3, h / 3);
|
||||
const bl = Math.min(bevelBL, w / 3, h / 3);
|
||||
|
||||
return `
|
||||
M ${inset} ${inset}
|
||||
L ${w + inset} ${inset}
|
||||
L ${w + inset} ${h - br + inset}
|
||||
L ${w - br + inset} ${h + inset}
|
||||
L ${bl + inset} ${h + inset}
|
||||
L ${inset} ${h - bl + inset}
|
||||
Z
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTLabel shape: top-right bevel only
|
||||
*/
|
||||
export function buildLabelBevelPath(
|
||||
width: number,
|
||||
height: number,
|
||||
bevelSize: number,
|
||||
): string {
|
||||
return buildBeveledRectPath(width, height, {
|
||||
corners: {topRight: bevelSize},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DTMediaFrame shape: top-left + bottom-right (diagonal symmetry)
|
||||
*/
|
||||
export function buildMediaFrameBevelPath(
|
||||
width: number,
|
||||
height: number,
|
||||
bevelSize: number,
|
||||
offset = 0,
|
||||
): string {
|
||||
return buildBeveledRectPath(width, height, {
|
||||
corners: {topLeft: bevelSize, bottomRight: bevelSize},
|
||||
offset,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DTDrawer shape (right-side): top-left + bottom-left bevels
|
||||
*/
|
||||
export function buildDrawerBevelPath(
|
||||
width: number,
|
||||
height: number,
|
||||
bevelSize: number,
|
||||
position: 'left' | 'right' = 'right',
|
||||
): string {
|
||||
if (position === 'right') {
|
||||
return buildBeveledRectPath(width, height, {
|
||||
corners: {topLeft: bevelSize, bottomLeft: bevelSize},
|
||||
});
|
||||
}
|
||||
return buildBeveledRectPath(width, height, {
|
||||
corners: {topRight: bevelSize, bottomRight: bevelSize},
|
||||
});
|
||||
}
|
||||
33
packages/react-native/src/utils/useComponentLayout.ts
Normal file
33
packages/react-native/src/utils/useComponentLayout.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Shared layout measurement hook
|
||||
*
|
||||
* Extracts the common pattern of tracking component dimensions
|
||||
* via onLayout for SVG rendering.
|
||||
*/
|
||||
|
||||
import {useState, useCallback} from 'react';
|
||||
import {LayoutChangeEvent} from 'react-native';
|
||||
|
||||
interface ComponentDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function useComponentLayout() {
|
||||
const [dimensions, setDimensions] = useState<ComponentDimensions>({
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
const onLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
const {width, height} = event.nativeEvent.layout;
|
||||
setDimensions(prev => {
|
||||
if (prev.width === width && prev.height === height) return prev;
|
||||
return {width, height};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const hasDimensions = dimensions.width > 0 && dimensions.height > 0;
|
||||
|
||||
return {dimensions, onLayout, hasDimensions};
|
||||
}
|
||||
27
packages/react-native/src/utils/variantColors.ts
Normal file
27
packages/react-native/src/utils/variantColors.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Centralized variant type and color mapping
|
||||
*
|
||||
* Used by all DT components for consistent mode/variant color resolution.
|
||||
*/
|
||||
|
||||
import {DTColors} from '../theme/colors';
|
||||
|
||||
export type DTVariant = 'normal' | 'emphasis' | 'warning' | 'success' | 'other';
|
||||
|
||||
export const variantColorMap: Record<DTVariant, string> = {
|
||||
normal: DTColors.modeNormal,
|
||||
emphasis: DTColors.modeEmphasis,
|
||||
warning: DTColors.modeWarning,
|
||||
success: DTColors.modeSuccess,
|
||||
other: DTColors.modeOther,
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve variant to color, with optional custom color override.
|
||||
*/
|
||||
export function getVariantColor(
|
||||
variant: DTVariant,
|
||||
customColor?: string,
|
||||
): string {
|
||||
return customColor ?? variantColorMap[variant];
|
||||
}
|
||||
15
packages/react-native/tsconfig.json
Normal file
15
packages/react-native/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2022"],
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user