initial commit
This commit is contained in:
8
.changeset/README.md
Normal file
8
.changeset/README.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Changesets
|
||||||
|
|
||||||
|
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
||||||
|
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
||||||
|
find the full documentation for it [in our repository](https://github.com/changesets/changesets).
|
||||||
|
|
||||||
|
We have a quick list of common questions to get you started engaging with this project in
|
||||||
|
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md).
|
||||||
11
.changeset/config.json
Normal file
11
.changeset/config.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://unpkg.com/@changesets/config@3.1.3/schema.json",
|
||||||
|
"changelog": "@changesets/cli/changelog",
|
||||||
|
"commit": false,
|
||||||
|
"fixed": [],
|
||||||
|
"linked": [],
|
||||||
|
"access": "public",
|
||||||
|
"baseBranch": "main",
|
||||||
|
"updateInternalDependencies": "patch",
|
||||||
|
"ignore": []
|
||||||
|
}
|
||||||
11
.changeset/initial-release.md
Normal file
11
.changeset/initial-release.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
"@dangerousthings/tokens": minor
|
||||||
|
"@dangerousthings/web": minor
|
||||||
|
"@dangerousthings/react-native": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Initial release of the DT design system monorepo.
|
||||||
|
|
||||||
|
- `@dangerousthings/tokens`: Canonical design tokens for DT, Classic, and Supra brands with CSS generation
|
||||||
|
- `@dangerousthings/web`: Web CSS theme with bevels, glows, form styles, and Tektur font
|
||||||
|
- `@dangerousthings/react-native`: 18 React Native themed components (DTButton, DTCard, DTTextInput, etc.)
|
||||||
52
.github/workflows/ci.yml
vendored
Normal file
52
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node-version: [18, 20, 22]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node-version }}
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build all packages
|
||||||
|
run: npx turbo run build
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: npx turbo run typecheck
|
||||||
|
|
||||||
|
publish-check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
- run: npx turbo run build
|
||||||
|
|
||||||
|
- name: Check for changesets
|
||||||
|
run: npx changeset status
|
||||||
37
.github/workflows/release.yml
vendored
Normal file
37
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
id-token: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
registry-url: https://registry.npmjs.org
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
- run: npx turbo run build
|
||||||
|
|
||||||
|
- name: Create release PR or publish
|
||||||
|
uses: changesets/action@v1
|
||||||
|
with:
|
||||||
|
publish: npx changeset publish
|
||||||
|
title: "chore: version packages"
|
||||||
|
commit: "chore: version packages"
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Turbo cache
|
||||||
|
.turbo/
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
5378
package-lock.json
generated
Normal file
5378
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
package.json
Normal file
35
package.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "dt-design-system",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Dangerous Things design system — shared tokens, web CSS, and React Native components",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/dangerous-tac0s/dt-design-system"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"packages/*"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "turbo run build",
|
||||||
|
"build:tokens": "npm run build -w packages/tokens",
|
||||||
|
"build:web": "npm run build -w packages/web",
|
||||||
|
"build:react-native": "npm run build -w packages/react-native",
|
||||||
|
"clean": "turbo run clean",
|
||||||
|
"typecheck": "turbo run typecheck",
|
||||||
|
"lint": "turbo run lint",
|
||||||
|
"changeset": "changeset",
|
||||||
|
"version-packages": "changeset version",
|
||||||
|
"release": "turbo run build && changeset publish"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@changesets/cli": "^2.30.0",
|
||||||
|
"turbo": "^2.4.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"packageManager": "npm@10.0.0"
|
||||||
|
}
|
||||||
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"]
|
||||||
|
}
|
||||||
36
packages/tokens/package.json
Normal file
36
packages/tokens/package.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "@dangerousthings/tokens",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Canonical design tokens for the Dangerous Things design system",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./brands/*": {
|
||||||
|
"types": "./dist/brands/*.d.ts",
|
||||||
|
"import": "./dist/brands/*.js"
|
||||||
|
},
|
||||||
|
"./css/*": "./dist/css/*"
|
||||||
|
},
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc && node dist/scripts/generate-css.js",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.3.3",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
66
packages/tokens/src/brands/classic.ts
Normal file
66
packages/tokens/src/brands/classic.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { BrandTokens } from "../types.js";
|
||||||
|
|
||||||
|
export const classic: BrandTokens = {
|
||||||
|
id: "classic",
|
||||||
|
name: "Classic Cyberpunk",
|
||||||
|
description: "Original dark navy aesthetic with magenta accents",
|
||||||
|
|
||||||
|
dark: {
|
||||||
|
bg: "#0a0e1a",
|
||||||
|
surface: "#121827",
|
||||||
|
surfaceHover: "#1a2332",
|
||||||
|
border: "#1e293b",
|
||||||
|
|
||||||
|
textPrimary: "#e2e8f0",
|
||||||
|
textSecondary: "#94a3b8",
|
||||||
|
textMuted: "#64748b",
|
||||||
|
|
||||||
|
primary: "#00ffff",
|
||||||
|
primaryDim: "#0891b2",
|
||||||
|
secondary: "#ff00ff",
|
||||||
|
accent: "#00ff88",
|
||||||
|
other: "#ff00ff",
|
||||||
|
|
||||||
|
success: "#00ff88",
|
||||||
|
warning: "#ffaa00",
|
||||||
|
error: "#ff0055",
|
||||||
|
info: "#00ffff",
|
||||||
|
},
|
||||||
|
|
||||||
|
light: {
|
||||||
|
bg: "#ffffff",
|
||||||
|
surface: "#f8fafc",
|
||||||
|
surfaceHover: "#f1f5f9",
|
||||||
|
border: "#e2e8f0",
|
||||||
|
|
||||||
|
textPrimary: "#0f172a",
|
||||||
|
textSecondary: "#475569",
|
||||||
|
textMuted: "#94a3b8",
|
||||||
|
|
||||||
|
primary: "#0891b2",
|
||||||
|
primaryDim: "#0e7490",
|
||||||
|
secondary: "#c026d3",
|
||||||
|
accent: "#00ff88",
|
||||||
|
other: "#c026d3",
|
||||||
|
|
||||||
|
success: "#16a34a",
|
||||||
|
warning: "#d97706",
|
||||||
|
error: "#dc2626",
|
||||||
|
info: "#0891b2",
|
||||||
|
},
|
||||||
|
|
||||||
|
typography: {
|
||||||
|
heading: "var(--font-sans)",
|
||||||
|
body: "var(--font-sans)",
|
||||||
|
mono: "'SF Mono', Monaco, 'Cascadia Code', monospace",
|
||||||
|
},
|
||||||
|
|
||||||
|
shape: {
|
||||||
|
bevelSm: "0px",
|
||||||
|
bevelMd: "0px",
|
||||||
|
bevelLg: "0px",
|
||||||
|
radiusSm: "0.25rem",
|
||||||
|
radius: "0.5rem",
|
||||||
|
radiusLg: "0.75rem",
|
||||||
|
},
|
||||||
|
};
|
||||||
66
packages/tokens/src/brands/dt.ts
Normal file
66
packages/tokens/src/brands/dt.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { BrandTokens } from "../types.js";
|
||||||
|
|
||||||
|
export const dt: BrandTokens = {
|
||||||
|
id: "dt",
|
||||||
|
name: "Dangerous Things",
|
||||||
|
description: "Official DT brand — Tektur font, neon cyberpunk, beveled corners",
|
||||||
|
|
||||||
|
dark: {
|
||||||
|
bg: "#000000",
|
||||||
|
surface: "#0a0a0a",
|
||||||
|
surfaceHover: "#141414",
|
||||||
|
border: "#00ffff",
|
||||||
|
|
||||||
|
textPrimary: "#ffffff",
|
||||||
|
textSecondary: "#e2e8f0",
|
||||||
|
textMuted: "#94a3b8",
|
||||||
|
|
||||||
|
primary: "#00ffff",
|
||||||
|
primaryDim: "#0891b2",
|
||||||
|
secondary: "#ffff00",
|
||||||
|
accent: "#00ff00",
|
||||||
|
other: "#ff00ff",
|
||||||
|
|
||||||
|
success: "#00ff00",
|
||||||
|
warning: "#ffff00",
|
||||||
|
error: "#ff0000",
|
||||||
|
info: "#00ffff",
|
||||||
|
},
|
||||||
|
|
||||||
|
light: {
|
||||||
|
bg: "#ffffff",
|
||||||
|
surface: "#f8fafc",
|
||||||
|
surfaceHover: "#f1f5f9",
|
||||||
|
border: "#000000",
|
||||||
|
|
||||||
|
textPrimary: "#000000",
|
||||||
|
textSecondary: "#475569",
|
||||||
|
textMuted: "#94a3b8",
|
||||||
|
|
||||||
|
primary: "#0891b2",
|
||||||
|
primaryDim: "#0e7490",
|
||||||
|
secondary: "#a16207",
|
||||||
|
accent: "#15803d",
|
||||||
|
other: "#ff00ff",
|
||||||
|
|
||||||
|
success: "#15803d",
|
||||||
|
warning: "#a16207",
|
||||||
|
error: "#dc2626",
|
||||||
|
info: "#0891b2",
|
||||||
|
},
|
||||||
|
|
||||||
|
typography: {
|
||||||
|
heading: "'Tektur', var(--font-sans)",
|
||||||
|
body: "'Tektur', var(--font-sans)",
|
||||||
|
mono: "'SF Mono', Monaco, 'Cascadia Code', monospace",
|
||||||
|
},
|
||||||
|
|
||||||
|
shape: {
|
||||||
|
bevelSm: "1rem",
|
||||||
|
bevelMd: "2rem",
|
||||||
|
bevelLg: "2.5rem",
|
||||||
|
radiusSm: "0",
|
||||||
|
radius: "0",
|
||||||
|
radiusLg: "0",
|
||||||
|
},
|
||||||
|
};
|
||||||
66
packages/tokens/src/brands/supra.ts
Normal file
66
packages/tokens/src/brands/supra.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { BrandTokens } from "../types.js";
|
||||||
|
|
||||||
|
export const supra: BrandTokens = {
|
||||||
|
id: "supra",
|
||||||
|
name: "VivoKey Supra",
|
||||||
|
description: "Material Design 3 with VivoKey blue, rounded corners, elevation shadows",
|
||||||
|
|
||||||
|
dark: {
|
||||||
|
bg: "#000000",
|
||||||
|
surface: "#28292E",
|
||||||
|
surfaceHover: "#303136",
|
||||||
|
border: "#42667E",
|
||||||
|
|
||||||
|
textPrimary: "#ffffff",
|
||||||
|
textSecondary: "#e2e8f0",
|
||||||
|
textMuted: "#94a3b8",
|
||||||
|
|
||||||
|
primary: "#42667E",
|
||||||
|
primaryDim: "#385872",
|
||||||
|
secondary: "#8BAEC7",
|
||||||
|
accent: "#8BAEC7",
|
||||||
|
other: "#F67448",
|
||||||
|
|
||||||
|
success: "#8BB174",
|
||||||
|
warning: "#F67448",
|
||||||
|
error: "#ED4337",
|
||||||
|
info: "#42667E",
|
||||||
|
},
|
||||||
|
|
||||||
|
light: {
|
||||||
|
bg: "#ffffff",
|
||||||
|
surface: "#AFB8C2",
|
||||||
|
surfaceHover: "#C5CCD4",
|
||||||
|
border: "#000000",
|
||||||
|
|
||||||
|
textPrimary: "#000000",
|
||||||
|
textSecondary: "#374151",
|
||||||
|
textMuted: "#6b7280",
|
||||||
|
|
||||||
|
primary: "#000000",
|
||||||
|
primaryDim: "#1f2937",
|
||||||
|
secondary: "#F67448",
|
||||||
|
accent: "#FDFE1B",
|
||||||
|
other: "#F67448",
|
||||||
|
|
||||||
|
success: "#8BB174",
|
||||||
|
warning: "#F67448",
|
||||||
|
error: "#ED4337",
|
||||||
|
info: "#42667E",
|
||||||
|
},
|
||||||
|
|
||||||
|
typography: {
|
||||||
|
heading: "var(--font-sans)",
|
||||||
|
body: "var(--font-sans)",
|
||||||
|
mono: "'SF Mono', Monaco, 'Cascadia Code', monospace",
|
||||||
|
},
|
||||||
|
|
||||||
|
shape: {
|
||||||
|
bevelSm: "0px",
|
||||||
|
bevelMd: "0px",
|
||||||
|
bevelLg: "0px",
|
||||||
|
radiusSm: "0.5rem",
|
||||||
|
radius: "0.625rem",
|
||||||
|
radiusLg: "0.75rem",
|
||||||
|
},
|
||||||
|
};
|
||||||
46
packages/tokens/src/index.ts
Normal file
46
packages/tokens/src/index.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
export type {
|
||||||
|
ThemeBrand,
|
||||||
|
ThemeMode,
|
||||||
|
ThemeDefinition,
|
||||||
|
ColorTokens,
|
||||||
|
TypographyTokens,
|
||||||
|
ShapeTokens,
|
||||||
|
BrandTokens,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export { dt } from "./brands/dt.js";
|
||||||
|
export { classic } from "./brands/classic.js";
|
||||||
|
export { supra } from "./brands/supra.js";
|
||||||
|
|
||||||
|
import { dt } from "./brands/dt.js";
|
||||||
|
import { classic } from "./brands/classic.js";
|
||||||
|
import { supra } from "./brands/supra.js";
|
||||||
|
import type { BrandTokens, ThemeDefinition } from "./types.js";
|
||||||
|
|
||||||
|
/** All brand tokens keyed by id */
|
||||||
|
export const brands: Record<string, BrandTokens> = { dt, classic, supra };
|
||||||
|
|
||||||
|
/** Theme registry for UI selectors */
|
||||||
|
export const themes: ThemeDefinition[] = [
|
||||||
|
{
|
||||||
|
id: "dt",
|
||||||
|
name: dt.name,
|
||||||
|
description: dt.description,
|
||||||
|
supportsModes: ["dark", "light", "auto"],
|
||||||
|
defaultMode: "dark",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "classic",
|
||||||
|
name: classic.name,
|
||||||
|
description: classic.description,
|
||||||
|
supportsModes: ["dark", "light", "auto"],
|
||||||
|
defaultMode: "dark",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "supra",
|
||||||
|
name: supra.name,
|
||||||
|
description: supra.description,
|
||||||
|
supportsModes: ["dark", "light", "auto"],
|
||||||
|
defaultMode: "dark",
|
||||||
|
},
|
||||||
|
];
|
||||||
129
packages/tokens/src/scripts/generate-css.ts
Normal file
129
packages/tokens/src/scripts/generate-css.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Generates CSS custom property files from canonical TypeScript token definitions.
|
||||||
|
* Run via: node dist/scripts/generate-css.js
|
||||||
|
* Output: dist/css/{brand}.css
|
||||||
|
*/
|
||||||
|
import { writeFileSync, mkdirSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import type { BrandTokens, ColorTokens } from "../types.js";
|
||||||
|
import { dt } from "../brands/dt.js";
|
||||||
|
import { classic } from "../brands/classic.js";
|
||||||
|
import { supra } from "../brands/supra.js";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const outDir = join(__dirname, "..", "css");
|
||||||
|
|
||||||
|
/** Convert hex #rrggbb to "r, g, b" triplet for rgba() usage */
|
||||||
|
function hexToRgb(hex: string): string {
|
||||||
|
const h = hex.replace("#", "");
|
||||||
|
const r = parseInt(h.substring(0, 2), 16);
|
||||||
|
const g = parseInt(h.substring(2, 4), 16);
|
||||||
|
const b = parseInt(h.substring(4, 6), 16);
|
||||||
|
return `${r}, ${g}, ${b}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate CSS custom properties from color tokens */
|
||||||
|
function colorVars(colors: ColorTokens): string {
|
||||||
|
return ` /* Backgrounds */
|
||||||
|
--color-bg: ${colors.bg};
|
||||||
|
--color-bg-rgb: ${hexToRgb(colors.bg)};
|
||||||
|
--color-surface: ${colors.surface};
|
||||||
|
--color-surface-rgb: ${hexToRgb(colors.surface)};
|
||||||
|
--color-surface-hover: ${colors.surfaceHover};
|
||||||
|
--color-border: ${colors.border};
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--color-text-primary: ${colors.textPrimary};
|
||||||
|
--color-text-secondary: ${colors.textSecondary};
|
||||||
|
--color-text-muted: ${colors.textMuted};
|
||||||
|
|
||||||
|
/* Brand Palette */
|
||||||
|
--color-primary: ${colors.primary};
|
||||||
|
--color-primary-rgb: ${hexToRgb(colors.primary)};
|
||||||
|
--color-primary-dim: ${colors.primaryDim};
|
||||||
|
--color-secondary: ${colors.secondary};
|
||||||
|
--color-secondary-rgb: ${hexToRgb(colors.secondary)};
|
||||||
|
--color-accent: ${colors.accent};
|
||||||
|
--color-accent-rgb: ${hexToRgb(colors.accent)};
|
||||||
|
--color-other: ${colors.other};
|
||||||
|
--color-other-rgb: ${hexToRgb(colors.other)};
|
||||||
|
|
||||||
|
/* Status */
|
||||||
|
--color-success: ${colors.success};
|
||||||
|
--color-success-rgb: ${hexToRgb(colors.success)};
|
||||||
|
--color-warning: ${colors.warning};
|
||||||
|
--color-warning-rgb: ${hexToRgb(colors.warning)};
|
||||||
|
--color-error: ${colors.error};
|
||||||
|
--color-error-rgb: ${hexToRgb(colors.error)};
|
||||||
|
--color-info: ${colors.info};
|
||||||
|
--color-info-rgb: ${hexToRgb(colors.info)};`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a full brand CSS file */
|
||||||
|
function generateBrandCSS(brand: BrandTokens): string {
|
||||||
|
const id = brand.id;
|
||||||
|
|
||||||
|
// DT is also the default (when no data-brand is set)
|
||||||
|
const darkSelector =
|
||||||
|
id === "dt"
|
||||||
|
? `:root[data-brand="${id}"],\n:root:not([data-brand])`
|
||||||
|
: `:root[data-brand="${id}"]`;
|
||||||
|
|
||||||
|
let css = `/* @dangerousthings/tokens — Generated CSS for "${brand.name}" */\n/* DO NOT EDIT — regenerate with: npm run build -w packages/tokens */\n\n`;
|
||||||
|
|
||||||
|
// Dark mode (default)
|
||||||
|
css += `/* ${brand.name} — Dark Mode (Default) */\n`;
|
||||||
|
css += `${darkSelector} {\n`;
|
||||||
|
css += colorVars(brand.dark) + "\n\n";
|
||||||
|
css += ` /* Typography */\n`;
|
||||||
|
css += ` --font-heading: ${brand.typography.heading};\n`;
|
||||||
|
css += ` --font-body: ${brand.typography.body};\n\n`;
|
||||||
|
css += ` /* Shape */\n`;
|
||||||
|
css += ` --bevel-sm: ${brand.shape.bevelSm};\n`;
|
||||||
|
css += ` --bevel-md: ${brand.shape.bevelMd};\n`;
|
||||||
|
css += ` --bevel-lg: ${brand.shape.bevelLg};\n`;
|
||||||
|
css += ` --radius-sm: ${brand.shape.radiusSm};\n`;
|
||||||
|
css += ` --radius: ${brand.shape.radius};\n`;
|
||||||
|
css += ` --radius-lg: ${brand.shape.radiusLg};\n\n`;
|
||||||
|
css += ` color-scheme: dark;\n`;
|
||||||
|
css += `}\n\n`;
|
||||||
|
|
||||||
|
// Light mode (explicit)
|
||||||
|
css += `/* ${brand.name} — Light Mode */\n`;
|
||||||
|
css += `:root[data-brand="${id}"][data-theme="light"] {\n`;
|
||||||
|
css += colorVars(brand.light) + "\n\n";
|
||||||
|
css += ` color-scheme: light;\n`;
|
||||||
|
css += `}\n\n`;
|
||||||
|
|
||||||
|
// Auto mode (prefers-color-scheme)
|
||||||
|
css += `/* ${brand.name} — Auto Mode (follows system preference) */\n`;
|
||||||
|
css += `@media (prefers-color-scheme: light) {\n`;
|
||||||
|
css += ` :root[data-brand="${id}"][data-theme="auto"] {\n`;
|
||||||
|
css += colorVars(brand.light).replace(/^/gm, " ") + "\n\n";
|
||||||
|
css += ` color-scheme: light;\n`;
|
||||||
|
css += ` }\n`;
|
||||||
|
css += `}\n`;
|
||||||
|
|
||||||
|
// DT gets explicit dark override
|
||||||
|
if (id === "dt") {
|
||||||
|
css += `\n/* Force dark explicitly */\n`;
|
||||||
|
css += `:root[data-brand="${id}"][data-theme="dark"] {\n`;
|
||||||
|
css += ` color-scheme: dark;\n`;
|
||||||
|
css += `}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return css;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main ---
|
||||||
|
mkdirSync(outDir, { recursive: true });
|
||||||
|
|
||||||
|
for (const brand of [dt, classic, supra]) {
|
||||||
|
const css = generateBrandCSS(brand);
|
||||||
|
const path = join(outDir, `${brand.id}.css`);
|
||||||
|
writeFileSync(path, css, "utf-8");
|
||||||
|
console.log(` tokens: generated ${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(" tokens: CSS generation complete");
|
||||||
65
packages/tokens/src/types.ts
Normal file
65
packages/tokens/src/types.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/** Brand identifiers */
|
||||||
|
export type ThemeBrand = "dt" | "classic" | "supra";
|
||||||
|
|
||||||
|
/** Color mode */
|
||||||
|
export type ThemeMode = "dark" | "light" | "auto";
|
||||||
|
|
||||||
|
/** Theme metadata for UI selectors */
|
||||||
|
export interface ThemeDefinition {
|
||||||
|
id: ThemeBrand;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
supportsModes: ThemeMode[];
|
||||||
|
defaultMode: ThemeMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Color tokens for a single mode */
|
||||||
|
export interface ColorTokens {
|
||||||
|
bg: string;
|
||||||
|
surface: string;
|
||||||
|
surfaceHover: string;
|
||||||
|
border: string;
|
||||||
|
|
||||||
|
textPrimary: string;
|
||||||
|
textSecondary: string;
|
||||||
|
textMuted: string;
|
||||||
|
|
||||||
|
primary: string;
|
||||||
|
primaryDim: string;
|
||||||
|
secondary: string;
|
||||||
|
accent: string;
|
||||||
|
other: string;
|
||||||
|
|
||||||
|
success: string;
|
||||||
|
warning: string;
|
||||||
|
error: string;
|
||||||
|
info: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Typography tokens */
|
||||||
|
export interface TypographyTokens {
|
||||||
|
heading: string;
|
||||||
|
body: string;
|
||||||
|
mono: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shape tokens */
|
||||||
|
export interface ShapeTokens {
|
||||||
|
bevelSm: string;
|
||||||
|
bevelMd: string;
|
||||||
|
bevelLg: string;
|
||||||
|
radiusSm: string;
|
||||||
|
radius: string;
|
||||||
|
radiusLg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Complete brand definition */
|
||||||
|
export interface BrandTokens {
|
||||||
|
id: ThemeBrand;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
dark: ColorTokens;
|
||||||
|
light: ColorTokens;
|
||||||
|
typography: TypographyTokens;
|
||||||
|
shape: ShapeTokens;
|
||||||
|
}
|
||||||
8
packages/tokens/tsconfig.json
Normal file
8
packages/tokens/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
BIN
packages/web/fonts/Tektur-VariableFont_wdth,wght.ttf
Normal file
BIN
packages/web/fonts/Tektur-VariableFont_wdth,wght.ttf
Normal file
Binary file not shown.
30
packages/web/package.json
Normal file
30
packages/web/package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "@dangerousthings/web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Web CSS theme for the Dangerous Things design system — bevels, glows, form styles",
|
||||||
|
"license": "MIT",
|
||||||
|
"main": "dist/index.css",
|
||||||
|
"exports": {
|
||||||
|
".": "./dist/index.css",
|
||||||
|
"./tokens/*": "./dist/tokens/*",
|
||||||
|
"./components/*": "./dist/components/*",
|
||||||
|
"./fonts/*": "./dist/fonts/*",
|
||||||
|
"./theme-registry": {
|
||||||
|
"types": "./dist/theme-registry.d.ts",
|
||||||
|
"import": "./dist/theme-registry.js",
|
||||||
|
"default": "./dist/theme-registry.js"
|
||||||
|
},
|
||||||
|
"./dist/*": "./dist/*"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"files": ["dist"],
|
||||||
|
"scripts": {
|
||||||
|
"build": "node scripts/build.js",
|
||||||
|
"clean": "rm -rf dist"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dangerousthings/tokens": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
87
packages/web/scripts/build.js
Normal file
87
packages/web/scripts/build.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dangerousthings/web build script
|
||||||
|
*
|
||||||
|
* 1. Copies src/ to dist/ (component CSS, base tokens, index.css)
|
||||||
|
* 2. Copies fonts/ to dist/fonts/
|
||||||
|
* 3. Copies generated token CSS from @dangerousthings/tokens/dist/css/ into dist/tokens/
|
||||||
|
* 4. Copies theme-registry from @dangerousthings/tokens
|
||||||
|
*
|
||||||
|
* Consumers (Vite, webpack) resolve @import chains at build time.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const root = path.join(__dirname, "..");
|
||||||
|
const src = path.join(root, "src");
|
||||||
|
const dist = path.join(root, "dist");
|
||||||
|
const fontsDir = path.join(root, "fonts");
|
||||||
|
|
||||||
|
// Resolve @dangerousthings/tokens dist directory
|
||||||
|
const tokensDir = path.join(root, "..", "tokens", "dist");
|
||||||
|
|
||||||
|
function copyDir(srcDir, destDir) {
|
||||||
|
fs.mkdirSync(destDir, { recursive: true });
|
||||||
|
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
|
||||||
|
const srcPath = path.join(srcDir, entry.name);
|
||||||
|
const destPath = path.join(destDir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
copyDir(srcPath, destPath);
|
||||||
|
} else {
|
||||||
|
fs.copyFileSync(srcPath, destPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean dist
|
||||||
|
if (fs.existsSync(dist)) {
|
||||||
|
fs.rmSync(dist, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Copy src → dist
|
||||||
|
copyDir(src, dist);
|
||||||
|
|
||||||
|
// 2. Copy fonts → dist/fonts
|
||||||
|
if (fs.existsSync(fontsDir)) {
|
||||||
|
copyDir(fontsDir, path.join(dist, "fonts"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Copy generated token CSS from @dangerousthings/tokens → dist/tokens/
|
||||||
|
const tokensCssDir = path.join(tokensDir, "css");
|
||||||
|
if (fs.existsSync(tokensCssDir)) {
|
||||||
|
const destTokens = path.join(dist, "tokens");
|
||||||
|
fs.mkdirSync(destTokens, { recursive: true });
|
||||||
|
for (const file of fs.readdirSync(tokensCssDir)) {
|
||||||
|
if (file.endsWith(".css")) {
|
||||||
|
fs.copyFileSync(
|
||||||
|
path.join(tokensCssDir, file),
|
||||||
|
path.join(destTokens, file)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error("ERROR: @dangerousthings/tokens CSS not found at", tokensCssDir);
|
||||||
|
console.error("Run: npm run build -w packages/tokens first");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Copy theme-registry from tokens
|
||||||
|
const registryFiles = ["theme-registry.js", "theme-registry.d.ts"];
|
||||||
|
// The theme-registry is now in @dangerousthings/tokens as index.js — consumers
|
||||||
|
// import types from @dangerousthings/tokens directly. We generate a thin re-export
|
||||||
|
// for backward compatibility with dt-web-theme consumers.
|
||||||
|
const reExport = `// Re-export from @dangerousthings/tokens for backward compatibility
|
||||||
|
export { themes, brands } from "@dangerousthings/tokens";
|
||||||
|
export { DEFAULT_THEME } from "@dangerousthings/tokens";
|
||||||
|
`;
|
||||||
|
const reExportDts = `export { ThemeDefinition, ThemeBrand, ThemeMode } from "@dangerousthings/tokens";
|
||||||
|
export declare const themes: import("@dangerousthings/tokens").ThemeDefinition[];
|
||||||
|
export declare const brands: Record<string, import("@dangerousthings/tokens").BrandTokens>;
|
||||||
|
export declare const DEFAULT_THEME: string;
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(path.join(dist, "theme-registry.js"), reExport);
|
||||||
|
fs.writeFileSync(path.join(dist, "theme-registry.d.ts"), reExportDts);
|
||||||
|
|
||||||
|
console.log("@dangerousthings/web: built to dist/");
|
||||||
31
packages/web/src/base.css
Normal file
31
packages/web/src/base.css
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/* @dangerousthings/web: Base Tokens (shared across all brands) */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Spacing (4px base) */
|
||||||
|
--space-1: 0.25rem;
|
||||||
|
--space-2: 0.5rem;
|
||||||
|
--space-3: 0.75rem;
|
||||||
|
--space-4: 1rem;
|
||||||
|
--space-6: 1.5rem;
|
||||||
|
--space-8: 2rem;
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
--transition-fast: 150ms ease;
|
||||||
|
--transition: 250ms ease;
|
||||||
|
|
||||||
|
/* Font stacks — brands override --font-heading and --font-body */
|
||||||
|
--font-mono: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, monospace;
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
--font-heading: var(--font-sans);
|
||||||
|
--font-body: var(--font-sans);
|
||||||
|
|
||||||
|
/* Border Radius — brands override these */
|
||||||
|
--radius-sm: 0.25rem;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--radius-lg: 0.75rem;
|
||||||
|
|
||||||
|
/* Bevel sizes — DT sets real values; Classic/Supra leave at 0 */
|
||||||
|
--bevel-sm: 0px;
|
||||||
|
--bevel-md: 0px;
|
||||||
|
--bevel-lg: 0px;
|
||||||
|
}
|
||||||
290
packages/web/src/components/bevels.css
Normal file
290
packages/web/src/components/bevels.css
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
/* dt-web-theme: Bevel Utilities */
|
||||||
|
/* Angular/beveled clip-path patterns from the DT design system */
|
||||||
|
/* Source: dt-shopify-storefront clip-path patterns + react-native-dt-theme bevelPaths.ts */
|
||||||
|
/* */
|
||||||
|
/* clip-path clips CSS borders and box-shadow. This file uses the storefront's */
|
||||||
|
/* dual-element technique: the element's own background serves as the "border" color, */
|
||||||
|
/* and a ::before pseudo-element provides the inner surface fill. The gap between */
|
||||||
|
/* them follows the clip-path shape perfectly. */
|
||||||
|
/* */
|
||||||
|
/* For glow effects on clipped elements, use filter: drop-shadow() (not box-shadow). */
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Card Bevels — dual bottom bevels (bottom-right large + bottom-left small)
|
||||||
|
Source: storefront .card-container + .clip-corners-inner pattern
|
||||||
|
Outer bg = border color, ::before = surface fill
|
||||||
|
Border width: ~3px uniform (storefront --card-border-width: 0.2em)
|
||||||
|
============================================================================ */
|
||||||
|
[data-brand="dt"] .dt-bevel-card,
|
||||||
|
[data-brand="dt"] .card {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
background: var(--color-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
clip-path: polygon(
|
||||||
|
0% 0%,
|
||||||
|
100% 0%,
|
||||||
|
100% calc(100% - var(--bevel-md)),
|
||||||
|
calc(100% - var(--bevel-md)) 100%,
|
||||||
|
var(--bevel-sm) 100%,
|
||||||
|
0% calc(100% - var(--bevel-sm))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-brand="dt"] .dt-bevel-card::before,
|
||||||
|
[data-brand="dt"] .card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--color-surface);
|
||||||
|
z-index: -1;
|
||||||
|
/* Inner clip-path mirrors outer shape, inset 3px on all edges.
|
||||||
|
This creates a uniform cyan border along both straight edges AND bevel diagonals. */
|
||||||
|
clip-path: polygon(
|
||||||
|
3px 3px,
|
||||||
|
calc(100% - 3px) 3px,
|
||||||
|
calc(100% - 3px) calc(100% - var(--bevel-md)),
|
||||||
|
calc(100% - var(--bevel-md)) calc(100% - 3px),
|
||||||
|
var(--bevel-sm) calc(100% - 3px),
|
||||||
|
3px calc(100% - var(--bevel-sm))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Header — storefront .card-header pattern
|
||||||
|
Title extends edge-to-edge on the card's primary bg (cyan).
|
||||||
|
Below it, the ::before dark surface shows through for the body area. */
|
||||||
|
[data-brand="dt"] .card > .card-title,
|
||||||
|
[data-brand="dt"] .dt-bevel-card > .card-title {
|
||||||
|
/* Pull title to card edges, canceling card padding */
|
||||||
|
margin: calc(-1 * var(--space-6)) calc(-1 * var(--space-6)) var(--space-4) calc(-1 * var(--space-6));
|
||||||
|
padding: 1rem var(--space-6);
|
||||||
|
/* Sits above ::before so its own bg covers the dark surface */
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-bg);
|
||||||
|
font-weight: 900;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-size: 1rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Force icon spans to inherit header color (overrides inline styles) */
|
||||||
|
[data-brand="dt"] .card > .card-title > * {
|
||||||
|
color: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Button Bevels — top-right corner cut
|
||||||
|
Source: storefront .label-clipped pattern (solid fill + clip-path)
|
||||||
|
Filled buttons (primary/danger): direct clip-path, solid bg
|
||||||
|
Outline button (secondary): dual-element technique via ::before
|
||||||
|
============================================================================ */
|
||||||
|
[data-brand="dt"] .btn-primary,
|
||||||
|
[data-brand="dt"] .btn-danger {
|
||||||
|
border: none;
|
||||||
|
clip-path: polygon(
|
||||||
|
0% 0%,
|
||||||
|
calc(100% - var(--bevel-sm)) 0%,
|
||||||
|
100% var(--bevel-sm),
|
||||||
|
100% 100%,
|
||||||
|
0% 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-brand="dt"] .btn-secondary {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
background: var(--color-primary);
|
||||||
|
border: none;
|
||||||
|
clip-path: polygon(
|
||||||
|
0% 0%,
|
||||||
|
calc(100% - var(--bevel-sm)) 0%,
|
||||||
|
100% var(--bevel-sm),
|
||||||
|
100% 100%,
|
||||||
|
0% 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-brand="dt"] .btn-secondary::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--color-bg);
|
||||||
|
z-index: -1;
|
||||||
|
clip-path: polygon(
|
||||||
|
2px 2px,
|
||||||
|
calc(100% - var(--bevel-sm)) 2px,
|
||||||
|
calc(100% - 2px) var(--bevel-sm),
|
||||||
|
calc(100% - 2px) calc(100% - 2px),
|
||||||
|
2px calc(100% - 2px)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-brand="dt"] .btn-secondary:hover {
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-brand="dt"] .btn-secondary:hover::before {
|
||||||
|
background: rgba(var(--color-primary-rgb), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Badge/Label Bevels — top-right bevel
|
||||||
|
Source: storefront .label-clipped pattern
|
||||||
|
Dual-element technique: badge bg = border color, ::before = inner fill
|
||||||
|
============================================================================ */
|
||||||
|
[data-brand="dt"] .badge {
|
||||||
|
--badge-border-color: var(--color-border);
|
||||||
|
--badge-fill: var(--color-surface);
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
background: var(--badge-border-color);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
clip-path: polygon(
|
||||||
|
0% 0%,
|
||||||
|
calc(100% - var(--bevel-sm)) 0%,
|
||||||
|
100% var(--bevel-sm),
|
||||||
|
100% 100%,
|
||||||
|
0% 100%
|
||||||
|
);
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-brand="dt"] .badge::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--badge-fill);
|
||||||
|
z-index: -1;
|
||||||
|
clip-path: polygon(
|
||||||
|
2px 2px,
|
||||||
|
calc(100% - var(--bevel-sm)) 2px,
|
||||||
|
calc(100% - 2px) var(--bevel-sm),
|
||||||
|
calc(100% - 2px) calc(100% - 2px),
|
||||||
|
2px calc(100% - 2px)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-brand="dt"] .badge-success {
|
||||||
|
--badge-border-color: var(--color-success);
|
||||||
|
--badge-fill: rgba(var(--color-success-rgb), 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-brand="dt"] .badge-error {
|
||||||
|
--badge-border-color: var(--color-error);
|
||||||
|
--badge-fill: rgba(var(--color-error-rgb), 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-brand="dt"] .badge-warning {
|
||||||
|
--badge-border-color: var(--color-warning);
|
||||||
|
--badge-fill: rgba(var(--color-warning-rgb), 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-brand="dt"] .badge-info {
|
||||||
|
--badge-border-color: var(--color-info);
|
||||||
|
--badge-fill: rgba(var(--color-info-rgb), 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Opt-in Bevel Utility Classes
|
||||||
|
Use these for custom elements that need bevel shapes
|
||||||
|
============================================================================ */
|
||||||
|
[data-brand="dt"] .dt-bevel-btn {
|
||||||
|
clip-path: polygon(
|
||||||
|
0% 0%,
|
||||||
|
calc(100% - var(--bevel-sm)) 0%,
|
||||||
|
100% var(--bevel-sm),
|
||||||
|
100% 100%,
|
||||||
|
0% 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-brand="dt"] .dt-bevel-label {
|
||||||
|
clip-path: polygon(
|
||||||
|
0% 0%,
|
||||||
|
calc(100% - var(--bevel-sm)) 0%,
|
||||||
|
100% var(--bevel-sm),
|
||||||
|
100% 100%,
|
||||||
|
0% 100%
|
||||||
|
);
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Media Frame Bevels — diagonal opposite corners (top-left + bottom-right)
|
||||||
|
Source: storefront .media-frame-clipped pattern
|
||||||
|
============================================================================ */
|
||||||
|
.dt-bevel-media {
|
||||||
|
clip-path: polygon(
|
||||||
|
var(--bevel-md) 0%,
|
||||||
|
100% 0%,
|
||||||
|
100% calc(100% - var(--bevel-md)),
|
||||||
|
calc(100% - var(--bevel-md)) 100%,
|
||||||
|
0% 100%,
|
||||||
|
0% var(--bevel-md)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Modal/Drawer Bevels — dual bottom bevels (bottom-right + bottom-left)
|
||||||
|
============================================================================ */
|
||||||
|
.dt-bevel-modal {
|
||||||
|
clip-path: polygon(
|
||||||
|
0% 0%,
|
||||||
|
100% 0%,
|
||||||
|
100% calc(100% - var(--bevel-lg)),
|
||||||
|
calc(100% - var(--bevel-lg)) 100%,
|
||||||
|
var(--bevel-lg) 100%,
|
||||||
|
0% calc(100% - var(--bevel-lg))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Drawer Bevels — exposed-edge bevels
|
||||||
|
Source: storefront aside clip-path pattern
|
||||||
|
============================================================================ */
|
||||||
|
.dt-bevel-drawer-right {
|
||||||
|
clip-path: polygon(
|
||||||
|
0% var(--bevel-lg),
|
||||||
|
var(--bevel-lg) 0%,
|
||||||
|
100% 0%,
|
||||||
|
100% 100%,
|
||||||
|
var(--bevel-lg) 100%,
|
||||||
|
0% calc(100% - var(--bevel-lg))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-bevel-drawer-left {
|
||||||
|
clip-path: polygon(
|
||||||
|
0% 0%,
|
||||||
|
calc(100% - var(--bevel-lg)) 0%,
|
||||||
|
100% var(--bevel-lg),
|
||||||
|
100% calc(100% - var(--bevel-lg)),
|
||||||
|
calc(100% - var(--bevel-lg)) 100%,
|
||||||
|
0% 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Small Bevels — for compact elements (arrows, stepper buttons)
|
||||||
|
============================================================================ */
|
||||||
|
.dt-bevel-sm {
|
||||||
|
clip-path: polygon(
|
||||||
|
var(--bevel-sm) 0%,
|
||||||
|
100% 0%,
|
||||||
|
100% calc(100% - var(--bevel-sm)),
|
||||||
|
calc(100% - var(--bevel-sm)) 100%,
|
||||||
|
0% 100%,
|
||||||
|
0% var(--bevel-sm)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Accordion/Menu — thick top border accent (storefront menu-item-clipped)
|
||||||
|
============================================================================ */
|
||||||
|
[data-brand="dt"] .dt-accent-top,
|
||||||
|
[data-brand="dt"] .terminal {
|
||||||
|
border-top: 3px solid var(--color-primary);
|
||||||
|
}
|
||||||
96
packages/web/src/components/elevation.css
Normal file
96
packages/web/src/components/elevation.css
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/* dt-web-theme: Supra Elevation & MD3 Styles */
|
||||||
|
/* Source: react-native-supra-theme/src/theme/paperTheme.ts */
|
||||||
|
/* Rounded Material Design 3 aesthetic with elevation shadows */
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Card Elevation
|
||||||
|
============================================================================ */
|
||||||
|
[data-brand="supra"] .card {
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.18);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-brand="supra"] .card:hover {
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Button — Dual-layer inset shadow (hardware button feel)
|
||||||
|
Source: SupraButton.tsx — inner shadow for tactile appearance
|
||||||
|
============================================================================ */
|
||||||
|
[data-brand="supra"] .btn-primary {
|
||||||
|
box-shadow:
|
||||||
|
inset -2px -2px 4px rgba(0, 0, 0, 0.18),
|
||||||
|
inset 2px 2px 4px rgba(255, 255, 255, 0.25);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-brand="supra"] .btn-primary:hover {
|
||||||
|
box-shadow:
|
||||||
|
inset -2px -2px 6px rgba(0, 0, 0, 0.22),
|
||||||
|
inset 2px 2px 6px rgba(255, 255, 255, 0.3);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-brand="supra"] .btn-secondary {
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-brand="supra"] .btn-danger {
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Badge — Rounded pill (MD3 chip style)
|
||||||
|
============================================================================ */
|
||||||
|
[data-brand="supra"] .badge {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Input — Rounded with standard focus ring
|
||||||
|
============================================================================ */
|
||||||
|
[data-brand="supra"] .input,
|
||||||
|
[data-brand="supra"] input,
|
||||||
|
[data-brand="supra"] textarea,
|
||||||
|
[data-brand="supra"] select {
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-brand="supra"] .input:focus,
|
||||||
|
[data-brand="supra"] input:focus,
|
||||||
|
[data-brand="supra"] textarea:focus,
|
||||||
|
[data-brand="supra"] select:focus {
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb), 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Terminal — Rounded with subtle elevation
|
||||||
|
============================================================================ */
|
||||||
|
[data-brand="supra"] .terminal {
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Elevation Utility Classes
|
||||||
|
============================================================================ */
|
||||||
|
.supra-elevation-1 {
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.supra-elevation-2 {
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.supra-elevation-3 {
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.supra-elevation-4 {
|
||||||
|
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.supra-elevation-5 {
|
||||||
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
329
packages/web/src/components/forms-dt.css
Normal file
329
packages/web/src/components/forms-dt.css
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
/* dt-web-theme: DT Form Component Styles */
|
||||||
|
/* Source: react-native-dt-theme/src/components/DT*.tsx */
|
||||||
|
/* These styles exist in the RN DT theme but NOT the Shopify storefront */
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Text Input — Sharp corners + focus glow bar
|
||||||
|
Source: DTTextInput.tsx
|
||||||
|
============================================================================ */
|
||||||
|
[data-brand="dt"] input[type="text"],
|
||||||
|
[data-brand="dt"] input[type="password"],
|
||||||
|
[data-brand="dt"] input[type="email"],
|
||||||
|
[data-brand="dt"] input[type="number"],
|
||||||
|
[data-brand="dt"] input[type="search"],
|
||||||
|
[data-brand="dt"] input[type="url"],
|
||||||
|
[data-brand="dt"] input[type="tel"],
|
||||||
|
[data-brand="dt"] textarea,
|
||||||
|
[data-brand="dt"] select,
|
||||||
|
[data-brand="dt"] .input {
|
||||||
|
border: 2px solid var(--color-primary);
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-brand="dt"] input:focus,
|
||||||
|
[data-brand="dt"] textarea:focus,
|
||||||
|
[data-brand="dt"] select:focus,
|
||||||
|
[data-brand="dt"] .input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 4px 0 1px var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-brand="dt"] input.error,
|
||||||
|
[data-brand="dt"] .input.error {
|
||||||
|
border-color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-brand="dt"] input.error:focus,
|
||||||
|
[data-brand="dt"] .input.error:focus {
|
||||||
|
box-shadow: 0 4px 0 1px var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Checkbox — Beveled opposing corners (top-left + bottom-right)
|
||||||
|
Source: DTCheckbox.tsx — bevel at 30% of size
|
||||||
|
Uses drop-shadow instead of border (clip-path clips CSS borders)
|
||||||
|
============================================================================ */
|
||||||
|
[data-brand="dt"] input[type="checkbox"] {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(var(--color-primary-rgb), 0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
clip-path: polygon(
|
||||||
|
30% 0%, 100% 0%, 100% 70%, 70% 100%, 0% 100%, 0% 30%
|
||||||
|
);
|
||||||
|
filter: drop-shadow(0 0 1px var(--color-primary));
|
||||||
|
transition: background var(--transition-fast), filter var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-brand="dt"] input[type="checkbox"]:checked {
|
||||||
|
background: var(--color-primary);
|
||||||
|
filter: drop-shadow(0 0 2px var(--color-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-brand="dt"] input[type="checkbox"]:checked::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 6px;
|
||||||
|
top: 8px;
|
||||||
|
width: 10px;
|
||||||
|
height: 6px;
|
||||||
|
border: 2px solid var(--color-bg);
|
||||||
|
border-top: none;
|
||||||
|
border-right: none;
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-brand="dt"] input[type="checkbox"]:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Switch / Toggle — Angular track + sliding square thumb
|
||||||
|
Source: DTSwitch.tsx — 48x26 track, 20x20 thumb, 150ms
|
||||||
|
============================================================================ */
|
||||||
|
.dt-switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 48px;
|
||||||
|
height: 26px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-switch-track {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border: 2px solid var(--color-primary);
|
||||||
|
border-radius: 0;
|
||||||
|
background: rgba(var(--color-primary-rgb), 0.1);
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-switch input:checked + .dt-switch-track {
|
||||||
|
background: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-switch-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 3px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: #ffffff;
|
||||||
|
transition: left var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-switch input:checked ~ .dt-switch-thumb,
|
||||||
|
.dt-switch input:checked + .dt-switch-track + .dt-switch-thumb {
|
||||||
|
left: 25px;
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-switch input:disabled + .dt-switch-track {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Radio Button — Hexagonal indicator
|
||||||
|
Source: DTRadioGroup.tsx — flat-top hexagon, 22px default
|
||||||
|
Uses drop-shadow instead of border (clip-path clips CSS borders)
|
||||||
|
============================================================================ */
|
||||||
|
[data-brand="dt"] input[type="radio"] {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(var(--color-primary-rgb), 0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
clip-path: polygon(
|
||||||
|
50% 0%, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%
|
||||||
|
);
|
||||||
|
filter: drop-shadow(0 0 1px var(--color-primary));
|
||||||
|
transition: background var(--transition-fast), filter var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-brand="dt"] input[type="radio"]:checked {
|
||||||
|
background: var(--color-primary);
|
||||||
|
filter: drop-shadow(0 0 2px var(--color-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-brand="dt"] input[type="radio"]:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Radio group option container */
|
||||||
|
.dt-radio-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 2px solid rgba(var(--color-primary-rgb), 0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-radio-option.selected {
|
||||||
|
background: rgba(var(--color-primary-rgb), 0.15);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Progress Bar — Angular, no radius
|
||||||
|
Source: DTProgressBar.tsx — 4px height, no border-radius
|
||||||
|
============================================================================ */
|
||||||
|
.dt-progress {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-primary);
|
||||||
|
transition: width 300ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-progress-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin-top: 4px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vertical variant */
|
||||||
|
.dt-progress.vertical {
|
||||||
|
width: 8px;
|
||||||
|
height: 200px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-progress.vertical .dt-progress-fill {
|
||||||
|
width: 100%;
|
||||||
|
height: 0%;
|
||||||
|
transition: height 300ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Accordion — 5px top border, chevron rotation
|
||||||
|
Source: DTAccordion.tsx — 250ms animation
|
||||||
|
============================================================================ */
|
||||||
|
.dt-accordion {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-accordion-header {
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-primary);
|
||||||
|
border-top: 5px solid var(--color-primary);
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-accordion-header:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-accordion-header[aria-expanded="true"] {
|
||||||
|
color: var(--color-secondary);
|
||||||
|
border-top-color: var(--color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-accordion-chevron {
|
||||||
|
display: inline-block;
|
||||||
|
transition: transform 250ms ease-in-out;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 5px solid transparent;
|
||||||
|
border-right: 5px solid transparent;
|
||||||
|
border-top: 7px solid currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-accordion-header[aria-expanded="true"] .dt-accordion-chevron {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-accordion-content {
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 250ms ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-accordion-content[data-open="true"] {
|
||||||
|
max-height: 2000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Quantity Stepper — Beveled +/- buttons
|
||||||
|
Source: DTQuantityStepper.tsx — bottom-right bevel
|
||||||
|
============================================================================ */
|
||||||
|
.dt-stepper {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-stepper-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 2px solid var(--color-primary);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0;
|
||||||
|
transition: opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-stepper-btn:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-stepper-btn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-stepper-value {
|
||||||
|
min-width: 36px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
80
packages/web/src/components/glows.css
Normal file
80
packages/web/src/components/glows.css
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/* dt-web-theme: Glow & Neon Effects */
|
||||||
|
/* Source: dt-shopify-storefront glow patterns */
|
||||||
|
/* */
|
||||||
|
/* Elements with clip-path use filter: drop-shadow() instead of box-shadow */
|
||||||
|
/* because clip-path clips box-shadow but filter follows the clipped shape. */
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Button Glows — filter: drop-shadow (buttons have clip-path)
|
||||||
|
============================================================================ */
|
||||||
|
[data-brand="dt"] .btn-primary {
|
||||||
|
box-shadow: none;
|
||||||
|
filter: drop-shadow(0 0 10px rgba(var(--color-primary-rgb), 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-brand="dt"] .btn-primary:hover {
|
||||||
|
box-shadow: none;
|
||||||
|
filter: drop-shadow(0 0 15px rgba(var(--color-accent-rgb), 0.4));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-brand="dt"] .btn-secondary:hover {
|
||||||
|
filter: drop-shadow(0 0 8px rgba(var(--color-primary-rgb), 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-brand="dt"] .btn-danger {
|
||||||
|
box-shadow: none;
|
||||||
|
filter: drop-shadow(0 0 10px rgba(var(--color-error-rgb), 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-brand="dt"] .btn-danger:hover {
|
||||||
|
box-shadow: none;
|
||||||
|
filter: drop-shadow(0 0 15px rgba(var(--color-error-rgb), 0.4));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Link Glow
|
||||||
|
============================================================================ */
|
||||||
|
[data-brand="dt"] a:hover {
|
||||||
|
text-shadow: 0 0 8px var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Terminal Inset Glow (no clip-path, box-shadow OK)
|
||||||
|
============================================================================ */
|
||||||
|
[data-brand="dt"] .terminal {
|
||||||
|
box-shadow: inset 0 0 10px rgba(var(--color-accent-rgb), 0.05),
|
||||||
|
0 0 10px rgba(var(--color-accent-rgb), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Card Hover Glow — filter: drop-shadow (cards have clip-path)
|
||||||
|
============================================================================ */
|
||||||
|
[data-brand="dt"] .card:hover {
|
||||||
|
filter: drop-shadow(0 0 8px rgba(var(--color-primary-rgb), 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Input Focus Glow (no clip-path on inputs, box-shadow OK)
|
||||||
|
============================================================================ */
|
||||||
|
[data-brand="dt"] .input:focus {
|
||||||
|
box-shadow: 0 4px 0 1px var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Generic Glow Utilities
|
||||||
|
============================================================================ */
|
||||||
|
.dt-glow {
|
||||||
|
filter: drop-shadow(0 0 10px rgba(var(--color-primary-rgb), 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-glow-strong {
|
||||||
|
filter: drop-shadow(0 0 15px rgba(var(--color-primary-rgb), 0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-glow-inset {
|
||||||
|
box-shadow: inset 0 0 10px rgba(var(--color-primary-rgb), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-text-glow {
|
||||||
|
text-shadow: 0 0 8px currentColor;
|
||||||
|
}
|
||||||
9
packages/web/src/dt-font.css
Normal file
9
packages/web/src/dt-font.css
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/* @dangerousthings/web: Tektur Font Face */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Tektur';
|
||||||
|
src: url('../fonts/Tektur-VariableFont_wdth,wght.ttf') format('truetype');
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-stretch: 75% 125%;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
21
packages/web/src/index.css
Normal file
21
packages/web/src/index.css
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/* @dangerousthings/web: Main Entry Point */
|
||||||
|
/* Import this file to get all tokens and component styles */
|
||||||
|
|
||||||
|
/* Base tokens (spacing, transitions, font stacks) */
|
||||||
|
@import './base.css';
|
||||||
|
|
||||||
|
/* Tektur font face (used by DT brand) */
|
||||||
|
@import './dt-font.css';
|
||||||
|
|
||||||
|
/* Brand tokens — generated from @dangerousthings/tokens */
|
||||||
|
@import './tokens/dt.css';
|
||||||
|
@import './tokens/classic.css';
|
||||||
|
@import './tokens/supra.css';
|
||||||
|
|
||||||
|
/* Component styles — DT visual identity */
|
||||||
|
@import './components/bevels.css';
|
||||||
|
@import './components/glows.css';
|
||||||
|
@import './components/forms-dt.css';
|
||||||
|
|
||||||
|
/* Component styles — Supra/MD3 visual identity */
|
||||||
|
@import './components/elevation.css';
|
||||||
16
tsconfig.base.json
Normal file
16
tsconfig.base.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true
|
||||||
|
}
|
||||||
|
}
|
||||||
16
turbo.json
Normal file
16
turbo.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://turbo.build/schema.json",
|
||||||
|
"tasks": {
|
||||||
|
"build": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"outputs": ["dist/**"]
|
||||||
|
},
|
||||||
|
"clean": {
|
||||||
|
"cache": false
|
||||||
|
},
|
||||||
|
"typecheck": {
|
||||||
|
"dependsOn": ["^build"]
|
||||||
|
},
|
||||||
|
"lint": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user