Compare commits
43 Commits
@dangerous
...
@dangerous
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cfc945960 | ||
|
|
77c6a5a3bf | ||
|
|
27824ae189 | ||
|
|
1f67ebf48a | ||
|
|
5112e5c057 | ||
|
|
1be0070abf | ||
|
|
c8c92d187d | ||
|
|
195c47602d | ||
|
|
d1e2bf1964 | ||
|
|
926a282535 | ||
|
|
2dcf86c346 | ||
|
|
8a91b21a11 | ||
|
|
766266b799 | ||
|
|
36420868f5 | ||
|
|
c8afd90527 | ||
|
|
34be1d0b9e | ||
|
|
6816ef4825 | ||
|
|
b319f15092 | ||
|
|
68234e66f4 | ||
|
|
2c9f7b8495 | ||
|
|
b137a99c78 | ||
|
|
4f477a50d8 | ||
|
|
741ee69079 | ||
|
|
89aaac9548 | ||
|
|
09143edab8 | ||
|
|
5d2b245f5a | ||
|
|
0cfa83259e | ||
|
|
3d9ec57523 | ||
|
|
b9eb83fa23 | ||
|
|
54b58a6ac6 | ||
|
|
568a1490e3 | ||
|
|
a7fa6fba84 | ||
|
|
2d3a693797 | ||
|
|
358ecb05cb | ||
|
|
30a0510a5e | ||
|
|
21acd98260 | ||
|
|
8a8d4d4adc | ||
|
|
e74c285b70 | ||
|
|
47e8085581 | ||
|
|
1b0d09313c | ||
|
|
baf8cd3b2a | ||
|
|
da84736b4b | ||
|
|
852a3af854 |
267
.github/workflows/showcase-release.yml
vendored
Normal file
267
.github/workflows/showcase-release.yml
vendored
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
name: Showcase Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Release version (e.g., 0.1.0)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
build_desktop:
|
||||||
|
description: 'Build desktop (Electron) artifacts'
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
build_mobile:
|
||||||
|
description: 'Build mobile (Android APK) artifact'
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: showcase-release
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ─── Desktop Linux Build ───────────────────────────────────────
|
||||||
|
desktop-linux:
|
||||||
|
if: inputs.build_desktop
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build design system packages
|
||||||
|
run: npx turbo run build
|
||||||
|
|
||||||
|
- name: Build desktop showcase
|
||||||
|
working-directory: packages/showcase/desktop
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Package desktop (Linux)
|
||||||
|
working-directory: packages/showcase/desktop
|
||||||
|
run: npx electron-builder --linux
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Upload desktop artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: desktop-linux
|
||||||
|
path: |
|
||||||
|
packages/showcase/desktop/release/*.AppImage
|
||||||
|
packages/showcase/desktop/release/*.deb
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
# ─── Desktop Windows Build ──────────────────────────────────────
|
||||||
|
desktop-windows:
|
||||||
|
if: inputs.build_desktop
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build design system packages
|
||||||
|
run: npx turbo run build
|
||||||
|
|
||||||
|
- name: Build desktop showcase
|
||||||
|
working-directory: packages/showcase/desktop
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Package desktop (Windows)
|
||||||
|
working-directory: packages/showcase/desktop
|
||||||
|
run: npx electron-builder --win
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Upload desktop artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: desktop-windows
|
||||||
|
path: |
|
||||||
|
packages/showcase/desktop/release/*.exe
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
# ─── Desktop macOS Build ────────────────────────────────────────
|
||||||
|
desktop-macos:
|
||||||
|
if: inputs.build_desktop
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build design system packages
|
||||||
|
run: npx turbo run build
|
||||||
|
|
||||||
|
- name: Build desktop showcase
|
||||||
|
working-directory: packages/showcase/desktop
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Package desktop (macOS)
|
||||||
|
working-directory: packages/showcase/desktop
|
||||||
|
run: npx electron-builder --mac --x64 --arm64
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
# No CSC_LINK / CSC_KEY_PASSWORD = unsigned build
|
||||||
|
# Users will need to right-click > Open to bypass Gatekeeper
|
||||||
|
|
||||||
|
- name: Upload desktop artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: desktop-macos
|
||||||
|
path: |
|
||||||
|
packages/showcase/desktop/release/*.dmg
|
||||||
|
packages/showcase/desktop/release/*.zip
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
# ─── Mobile Android Build ──────────────────────────────────────
|
||||||
|
mobile-android:
|
||||||
|
if: inputs.build_mobile
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: '17'
|
||||||
|
|
||||||
|
- name: Setup Android SDK
|
||||||
|
uses: android-actions/setup-android@v3
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build design system packages
|
||||||
|
run: npx turbo run build
|
||||||
|
|
||||||
|
- name: Expo prebuild
|
||||||
|
working-directory: packages/showcase/mobile
|
||||||
|
run: npx expo prebuild --clean --platform android
|
||||||
|
|
||||||
|
- name: Cache Gradle
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/caches
|
||||||
|
~/.gradle/wrapper
|
||||||
|
key: gradle-${{ hashFiles('packages/showcase/mobile/android/**/*.gradle*', 'packages/showcase/mobile/android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
|
restore-keys: gradle-
|
||||||
|
|
||||||
|
- name: Build release APK
|
||||||
|
working-directory: packages/showcase/mobile/android
|
||||||
|
run: ./gradlew assembleRelease
|
||||||
|
|
||||||
|
- name: Upload APK artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: mobile-android
|
||||||
|
path: packages/showcase/mobile/android/app/build/outputs/apk/release/app-release.apk
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
# ─── Create GitHub Release ─────────────────────────────────────
|
||||||
|
create-release:
|
||||||
|
needs: [desktop-linux, desktop-windows, desktop-macos, mobile-android]
|
||||||
|
if: always() && (needs.desktop-linux.result == 'success' || needs.desktop-windows.result == 'success' || needs.desktop-macos.result == 'success' || needs.mobile-android.result == 'success')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download all artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: release-artifacts
|
||||||
|
|
||||||
|
- name: Rename artifacts with version
|
||||||
|
env:
|
||||||
|
RELEASE_VERSION: ${{ inputs.version }}
|
||||||
|
run: |
|
||||||
|
mkdir -p final-artifacts
|
||||||
|
|
||||||
|
# Desktop Linux artifacts
|
||||||
|
if [ -d release-artifacts/desktop-linux ]; then
|
||||||
|
for f in release-artifacts/desktop-linux/*; do
|
||||||
|
filename=$(basename "$f")
|
||||||
|
newname=$(echo "$filename" | sed "s/0\.0\.0/${RELEASE_VERSION}/g")
|
||||||
|
cp "$f" "final-artifacts/$newname"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Desktop Windows artifacts
|
||||||
|
if [ -d release-artifacts/desktop-windows ]; then
|
||||||
|
for f in release-artifacts/desktop-windows/*; do
|
||||||
|
filename=$(basename "$f")
|
||||||
|
newname=$(echo "$filename" | sed "s/0\.0\.0/${RELEASE_VERSION}/g")
|
||||||
|
cp "$f" "final-artifacts/$newname"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Desktop macOS artifacts
|
||||||
|
if [ -d release-artifacts/desktop-macos ]; then
|
||||||
|
for f in release-artifacts/desktop-macos/*; do
|
||||||
|
filename=$(basename "$f")
|
||||||
|
newname=$(echo "$filename" | sed "s/0\.0\.0/${RELEASE_VERSION}/g")
|
||||||
|
cp "$f" "final-artifacts/$newname"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Mobile APK
|
||||||
|
if [ -f release-artifacts/mobile-android/app-release.apk ]; then
|
||||||
|
cp release-artifacts/mobile-android/app-release.apk \
|
||||||
|
"final-artifacts/dt-showcase-mobile-${RELEASE_VERSION}.apk"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Release artifacts:"
|
||||||
|
ls -lh final-artifacts/
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: showcase-v${{ inputs.version }}
|
||||||
|
name: Showcase v${{ inputs.version }}
|
||||||
|
body: |
|
||||||
|
## DT Design System Showcase v${{ inputs.version }}
|
||||||
|
|
||||||
|
### Desktop (Electron)
|
||||||
|
- **AppImage** (Linux, portable) — download and `chmod +x` to run
|
||||||
|
- **deb** (Debian/Ubuntu) — install with `sudo dpkg -i <file>.deb`
|
||||||
|
- **exe** (Windows) — NSIS installer + portable exe
|
||||||
|
- **dmg** (macOS) — unsigned, right-click > Open to bypass Gatekeeper
|
||||||
|
- **zip** (macOS) — portable archive
|
||||||
|
|
||||||
|
### Mobile (Android)
|
||||||
|
- **APK** — sideload on any Android device (Settings > Install unknown apps)
|
||||||
|
|
||||||
|
---
|
||||||
|
Built from commit ${{ github.sha }}
|
||||||
|
files: final-artifacts/*
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -25,3 +25,10 @@ Thumbs.db
|
|||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Showcase build artifacts
|
||||||
|
release/
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
*.ipa
|
||||||
|
*.xcarchive
|
||||||
|
|||||||
127
CLAUDE.md
127
CLAUDE.md
@@ -2,13 +2,16 @@
|
|||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Monorepo for the Dangerous Things design system. Three npm packages publish under `@dangerousthings/*`:
|
Monorepo for the Dangerous Things design system. Six npm packages publish under `@dangerousthings/*`:
|
||||||
|
|
||||||
- **tokens** — canonical design tokens in TypeScript, compiled to JS and auto-generated as CSS custom properties
|
- **tokens** — canonical design tokens in TypeScript, compiled to JS and auto-generated as CSS custom properties
|
||||||
- **web** — CSS themes (bevels, glows, elevation, forms) consumed by web apps
|
- **web** — CSS themes (bevels, forms, animations) consumed by web apps
|
||||||
|
- **react** — React web components wrapping web CSS
|
||||||
- **react-native** — React Native components built on React Native Paper
|
- **react-native** — React Native components built on React Native Paper
|
||||||
|
- **tailwind-preset** — Tailwind CSS v3 preset mapping DT tokens
|
||||||
|
- **hex-background** — 3D hexagon grid background (Three.js)
|
||||||
|
|
||||||
Three brand themes: **dt** (neon cyberpunk / beveled), **classic** (dark navy / magenta), **supra** (MD3 / blue).
|
Two brand themes: **dt** (neon cyberpunk / beveled), **classic** (dark navy / magenta).
|
||||||
|
|
||||||
## Build & Tooling
|
## Build & Tooling
|
||||||
|
|
||||||
@@ -16,7 +19,7 @@ Three brand themes: **dt** (neon cyberpunk / beveled), **classic** (dark navy /
|
|||||||
- Build everything: `npm run build`
|
- Build everything: `npm run build`
|
||||||
- Typecheck everything: `npm run typecheck`
|
- Typecheck everything: `npm run typecheck`
|
||||||
- Tokens build: `tsc` then `node dist/scripts/generate-css.js`
|
- Tokens build: `tsc` then `node dist/scripts/generate-css.js`
|
||||||
- Web build: `node scripts/build.js` (copies CSS, assembles brand token CSS, copies fonts)
|
- Web build: `node scripts/build.cjs` (copies CSS, assembles brand token CSS, copies fonts)
|
||||||
- React Native build: `tsc`
|
- React Native build: `tsc`
|
||||||
- Versioning: Changesets (`npx changeset`, `npm run version-packages`, `npm run release`)
|
- Versioning: Changesets (`npx changeset`, `npm run version-packages`, `npm run release`)
|
||||||
|
|
||||||
@@ -28,20 +31,22 @@ Three brand themes: **dt** (neon cyberpunk / beveled), **classic** (dark navy /
|
|||||||
|
|
||||||
## Feature Parity
|
## Feature Parity
|
||||||
|
|
||||||
When adding or modifying a design token, component, or visual behavior, keep all three layers in sync:
|
When adding or modifying a design token, component, or visual behavior, keep all four layers in sync:
|
||||||
|
|
||||||
1. **Tokens** — add/update the value in every brand file (`packages/tokens/src/brands/{dt,classic,supra}.ts`) and in the shared type (`packages/tokens/src/types.ts`).
|
1. **Tokens** — add/update the value in every brand file (`packages/tokens/src/brands/{dt,classic}.ts`) and in the shared type (`packages/tokens/src/types.ts`).
|
||||||
2. **Web** — if the token maps to a CSS component, update the relevant CSS file under `packages/web/src/components/`. Ensure the CSS custom property name matches the token path (e.g., `--dt-color-brand-primary`).
|
2. **Web** — if the token maps to a CSS component, update the relevant CSS file under `packages/web/src/components/`. Ensure the CSS custom property name matches the token path (e.g., `--dt-color-brand-primary`).
|
||||||
3. **React Native** — update the corresponding component(s) under `packages/react-native/src/components/` and ensure the theme provider maps the new token.
|
3. **React** — if there is a React web component wrapper, update or create the corresponding component in `packages/react/src/components/`.
|
||||||
|
4. **React Native** — update the corresponding component(s) under `packages/react-native/src/components/` and ensure the theme provider maps the new token.
|
||||||
|
|
||||||
### Parity Checklist (use before closing any PR)
|
### Parity Checklist (use before closing any PR)
|
||||||
|
|
||||||
- [ ] New tokens exist in all three brand files with correct values
|
- [ ] New tokens exist in all brand files with correct values
|
||||||
- [ ] Token types are updated if the shape of the token object changed
|
- [ ] Token types are updated if the shape of the token object changed
|
||||||
- [ ] CSS generation script produces the expected custom properties (`npm run build:tokens` then inspect `dist/css/`)
|
- [ ] CSS generation script produces the expected custom properties (`npm run build:tokens` then inspect `dist/css/`)
|
||||||
- [ ] Web CSS files reference the new/changed custom properties
|
- [ ] Web CSS files reference the new/changed custom properties
|
||||||
|
- [ ] React web components are updated if applicable
|
||||||
- [ ] React Native components consume the new tokens via the theme
|
- [ ] React Native components consume the new tokens via the theme
|
||||||
- [ ] All three packages build cleanly (`npm run build`)
|
- [ ] All packages build cleanly (`npm run build`)
|
||||||
- [ ] TypeScript typechecks pass (`npm run typecheck`)
|
- [ ] TypeScript typechecks pass (`npm run typecheck`)
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
@@ -65,10 +70,10 @@ When adding or modifying a design token, component, or visual behavior, keep all
|
|||||||
|
|
||||||
### Adding a New Component
|
### Adding a New Component
|
||||||
|
|
||||||
1. Create the React Native component in `packages/react-native/src/components/DTFoo.tsx`.
|
1. If it needs new tokens, add them to all brand files and types first.
|
||||||
2. Re-export it from `packages/react-native/src/index.ts`.
|
2. If there is a web equivalent, add a CSS file in `packages/web/src/components/`.
|
||||||
3. If it needs new tokens, add them to all brand files and types first.
|
3. Create a React web component wrapper in `packages/react/src/components/DTFoo.tsx` and re-export from `packages/react/src/index.ts`.
|
||||||
4. If there is a web equivalent (CSS-only), add a CSS file in `packages/web/src/components/`.
|
4. Create the React Native component in `packages/react-native/src/components/DTFoo.tsx` and re-export from `packages/react-native/src/index.ts`.
|
||||||
5. Create a changeset: `npx changeset` — select affected packages and describe the change.
|
5. Create a changeset: `npx changeset` — select affected packages and describe the change.
|
||||||
|
|
||||||
### Adding a New Brand
|
### Adding a New Brand
|
||||||
@@ -98,3 +103,99 @@ GitHub Actions runs on push to `main` and on PRs:
|
|||||||
- CSS uses BEM-ish class names prefixed with `dt-` (e.g., `.dt-bevel-card`).
|
- CSS uses BEM-ish class names prefixed with `dt-` (e.g., `.dt-bevel-card`).
|
||||||
- React Native components are prefixed `DT` (e.g., `DTButton`).
|
- React Native components are prefixed `DT` (e.g., `DTButton`).
|
||||||
- Keep files focused — one component per file, one brand per file.
|
- Keep files focused — one component per file, one brand per file.
|
||||||
|
|
||||||
|
## Storefront Component Migration — Implementation Reference
|
||||||
|
|
||||||
|
The DT Shopify Storefront (`dt-shopify-storefront`) is the genesis project for this design system.
|
||||||
|
The storefront features documented below have been migrated into the design system. This section
|
||||||
|
serves as a reference for the implemented components and their locations.
|
||||||
|
|
||||||
|
### Implemented Features
|
||||||
|
|
||||||
|
| Feature | CSS (web) | React Component | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Card Color Modes | `.mode-normal`, `.mode-emphasis`, `.mode-warning`, `.mode-success`, `.mode-other` in `bevels.css` | `DTCard` | Five modes with `*-selected` variants at 70% opacity |
|
||||||
|
| Card Progress Bar | `.dt-card-progress` in `bevels.css` | `DTCard` (`progress` prop) | Vertical left-edge bar, 0-100%, fills on hover |
|
||||||
|
| Card Chip Badges | `.dt-badge-overlay` in `bevels.css` | `DTBadgeOverlay` | Positioned bottom-right, mode-color-aware |
|
||||||
|
| Card Hover/Selected | `.card.selected` and hover states in `bevels.css` | `DTCard` | Fills with `var(--dt-card-color-selected)` |
|
||||||
|
| Staggered Container | `.dt-stagger-container` in `animations.css` | `DTStaggerContainer` | CSS `nth-child` staggered `animation-delay` |
|
||||||
|
| Animation Library | `animations.css` | -- | `dt-scale-in`, `dt-fade-in`, `dt-slide-up`, `dt-pulse`, `dt-ping`, `dt-spin`, transition utilities |
|
||||||
|
| Filter System UI | `.dt-menu-item`, `.dt-filter-header`, `.dt-filter-overlay` in `forms-dt.css` | `DTMenu`, `DTMobileFilterOverlay` | Nested menus, filter accordions, mobile overlay |
|
||||||
|
| Feature Legend | `feature-legend.css` | `DTFeatureLegend` | Icon grid with rotated labels, mode-colored header, feature states |
|
||||||
|
| Theme Branching | `[data-brand="dt"]` / `[data-brand="classic"]` CSS scoping | -- | No JS conditionals needed; CSS handles visual differences |
|
||||||
|
| Scrollbar Styling | `scrollbar.css` (`.dt-scrollbar`) | -- | Thin scrollbar with mode-normal color |
|
||||||
|
|
||||||
|
**Card color modes reference:**
|
||||||
|
|
||||||
|
| Mode | Token | Default (DT dark) | Use Case |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `mode-normal` | `--color-primary` | `#00FFFF` cyan | Default product cards |
|
||||||
|
| `mode-emphasis` | `--color-secondary` | `#FFFF00` yellow | Highlighted/selected states |
|
||||||
|
| `mode-warning` | `--color-error` | `#FF0000` red | Lab products, warnings |
|
||||||
|
| `mode-success` | `--color-accent` | `#00FF00` green | Positive/success states |
|
||||||
|
| `mode-other` | `--color-other` | `#FF00FF` magenta | Bundles, alternative states |
|
||||||
|
|
||||||
|
**Theme branching differences (handled by CSS, not JS):**
|
||||||
|
|
||||||
|
| Aspect | Cyberpunk (`data-brand="dt"`) | Clean (`data-brand="classic"`) |
|
||||||
|
|---|---|---|
|
||||||
|
| Clip-paths | Heavy bevels | `border-radius: 0.75rem` |
|
||||||
|
| Colors | Neon (#00FFFF, #FF00FF) | B&W or muted tones |
|
||||||
|
| Hover effects | Pulse glow animations | None |
|
||||||
|
| Typography | Tektur | system-ui sans-serif |
|
||||||
|
| Text shadows | `0 0 8px var(--mode)` | None |
|
||||||
|
| Borders | Thick neon (5px top) | Subtle 1px |
|
||||||
|
|
||||||
|
### Icon Set — Product Features & Filtering
|
||||||
|
|
||||||
|
Canonical icon set for the DT design system, sourced from `react-icons`.
|
||||||
|
|
||||||
|
**Product Feature Icons (react-icons/md — Material Design):**
|
||||||
|
|
||||||
|
| Feature | Icon | Package |
|
||||||
|
|---|---|---|
|
||||||
|
| Illumination | `MdLightbulbOutline` | react-icons/md |
|
||||||
|
| Visibility/Glow | `MdOutlineVisibility` | react-icons/md |
|
||||||
|
| Data Sharing | `MdOutlineMobileScreenShare` | react-icons/md |
|
||||||
|
| UID Cloning/Magic | `MdOutlineCopyAll` | react-icons/md |
|
||||||
|
| Payment/NFC | `MdOutlineCreditCard` | react-icons/md |
|
||||||
|
| Smartphone Compat | `MdOutlinePhonelinkRing` | react-icons/md |
|
||||||
|
| Access Control | `MdOutlineVpnKey` | react-icons/md |
|
||||||
|
| Sensors | `MdOutlineThermostat` | react-icons/md |
|
||||||
|
| NFC Sensing | `MdOutlineSensors` | react-icons/md |
|
||||||
|
| Fitness/Bio | `MdOutlineFitbit` | react-icons/md |
|
||||||
|
| Vibration | `MdOutlineVibration` | react-icons/md |
|
||||||
|
| Exploration | `MdOutlineExplore` | react-icons/md |
|
||||||
|
| Cryptography | `LuBinary` | react-icons/lu |
|
||||||
|
| Digital Security | `FaUserShield` | react-icons/fa |
|
||||||
|
| Temperature (alt) | `BsThermometerHalf` | react-icons/bs |
|
||||||
|
|
||||||
|
**UI Control Icons:**
|
||||||
|
|
||||||
|
| Control | Icon | Package |
|
||||||
|
|---|---|---|
|
||||||
|
| Filter toggle | `MdFilterList` | react-icons/md |
|
||||||
|
| Expand/collapse | `MdExpandMore` | react-icons/md |
|
||||||
|
| Close/dismiss | `MdClose` | react-icons/md |
|
||||||
|
| Dropdown arrow | `MdOutlineKeyboardArrowDown` | react-icons/md |
|
||||||
|
| No image placeholder | `MdOutlineHideImage` | react-icons/md |
|
||||||
|
| More details | `CgMoreR` | react-icons/cg |
|
||||||
|
| Loading spinner | `FaSpinner` | react-icons/fa6 |
|
||||||
|
| Cart | `FaCartShopping` | react-icons/fa6 |
|
||||||
|
| Add to cart | `FaCartPlus` | react-icons/fa6 |
|
||||||
|
| Search | `FaMagnifyingGlass` | react-icons/fa6 |
|
||||||
|
| Back | `FaArrowLeft` | react-icons/fa6 |
|
||||||
|
| User (logged in) | `FaUser` | react-icons/fa6 |
|
||||||
|
| User (logged out) | `FaUserSlash` | react-icons/fa6 |
|
||||||
|
| Delete | `FaTrash` | react-icons/fa |
|
||||||
|
|
||||||
|
**Custom SVG Icons (ThemeToggle.tsx):**
|
||||||
|
- Sun icon (24x24) — switch to clean theme
|
||||||
|
- Terminal/matrix icon (24x24) — switch to cyberpunk theme
|
||||||
|
|
||||||
|
**Icon display standard:** Feature icons render at 42px in the UseCaseLegend grid. UI icons at 1.5em.
|
||||||
|
Icons inherit color from their parent's `--mode` variable.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
(none currently)
|
||||||
|
|||||||
69
README.md
69
README.md
@@ -5,18 +5,20 @@ Shared design tokens, web CSS, and React Native components for the Dangerous Thi
|
|||||||
## Packages
|
## Packages
|
||||||
|
|
||||||
| Package | Description |
|
| Package | Description |
|
||||||
|---------|-------------|
|
| -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||||
| [`@dangerousthings/tokens`](packages/tokens) | Canonical design tokens (colors, typography, shape) defined in TypeScript and exported as CSS custom properties |
|
| [`@dangerousthings/tokens`](packages/tokens) | Canonical design tokens (colors, typography, shape) defined in TypeScript and exported as CSS custom properties |
|
||||||
| [`@dangerousthings/web`](packages/web) | Web CSS themes — bevels, glows, elevation, and form styles |
|
| [`@dangerousthings/web`](packages/web) | Web CSS themes — bevels, animations, forms, and component styles |
|
||||||
|
| [`@dangerousthings/react`](packages/react) | React web components wrapping @dangerousthings/web CSS |
|
||||||
| [`@dangerousthings/react-native`](packages/react-native) | React Native components built on React Native Paper |
|
| [`@dangerousthings/react-native`](packages/react-native) | React Native components built on React Native Paper |
|
||||||
|
| [`@dangerousthings/tailwind-preset`](packages/tailwind-preset) | Tailwind CSS v3 preset mapping DT tokens to Tailwind theme |
|
||||||
|
| [`@dangerousthings/hex-background`](packages/hex-background) | 3D hexagon grid background using Three.js + React Three Fiber (web and React Native) |
|
||||||
|
|
||||||
## Brand Themes
|
## Brand Themes
|
||||||
|
|
||||||
Three brand themes ship out of the box:
|
Two brand themes ship out of the box:
|
||||||
|
|
||||||
- **DT** — neon cyberpunk aesthetic with beveled corners and glow effects (Tektur font)
|
- **DT** — neon cyberpunk aesthetic with beveled corners and baked in animations (Tektur font)
|
||||||
- **Classic** — dark navy palette with magenta accents and standard border radius
|
- **Classic** — dark navy palette with magenta accents and standard border radius
|
||||||
- **Supra** — Material Design 3–inspired with blue accent and rounded corners
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
@@ -50,6 +52,25 @@ npm run build:react-native
|
|||||||
@import "@dangerousthings/web/components/bevels.css";
|
@import "@dangerousthings/web/components/bevels.css";
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### React (Web)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import "@dangerousthings/web";
|
||||||
|
import { DTWebThemeProvider, DTButton, DTCard } from "@dangerousthings/react";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<DTWebThemeProvider brand="dt" theme="dark">
|
||||||
|
<DTCard title="PRODUCT" variant="emphasis" progress={60}>
|
||||||
|
<DTButton variant="normal" onClick={handleClick}>
|
||||||
|
Scan NFC
|
||||||
|
</DTButton>
|
||||||
|
</DTCard>
|
||||||
|
</DTWebThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### React Native
|
### React Native
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
@@ -76,16 +97,26 @@ import { dt } from "@dangerousthings/tokens/brands/dt";
|
|||||||
console.log(dt.color.brand.primary); // "#00ffff"
|
console.log(dt.color.brand.primary); // "#00ffff"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Tailwind
|
||||||
|
|
||||||
|
```js
|
||||||
|
// tailwind.config.js
|
||||||
|
import dtPreset from "@dangerousthings/tailwind-preset";
|
||||||
|
export default {
|
||||||
|
presets: [dtPreset],
|
||||||
|
content: ["./src/**/*.{tsx,ts,html}"],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Scripts
|
### Scripts
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
| ------------------- | ------------------------------ |
|
||||||
| `npm run build` | Build all packages (Turbo) |
|
| `npm run build` | Build all packages (Turbo) |
|
||||||
| `npm run clean` | Remove all `dist/` directories |
|
| `npm run clean` | Remove all `dist/` directories |
|
||||||
| `npm run typecheck` | Run TypeScript type checking |
|
| `npm run typecheck` | Run TypeScript type checking |
|
||||||
| `npm run lint` | Lint all packages |
|
|
||||||
|
|
||||||
### Release
|
### Release
|
||||||
|
|
||||||
@@ -97,16 +128,25 @@ npm run version-packages # bump versions from pending changesets
|
|||||||
npm run release # build + publish to npm
|
npm run release # build + publish to npm
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## React Web Components
|
||||||
|
|
||||||
|
The `@dangerousthings/react` package provides React web components that wrap the CSS from `@dangerousthings/web`. The component API mirrors the React Native package for consistency:
|
||||||
|
|
||||||
|
DTAccordion, DTBadgeOverlay, DTButton, DTCard, DTCheckbox, DTChip, DTDrawer, DTFeatureLegend, DTGallery, DTHexagon, DTLabel, DTMediaFrame, DTMenu, DTMobileFilterOverlay, DTModal, DTProgressBar, DTQuantityStepper, DTRadioGroup, DTSearchInput, DTStaggerContainer, DTSwitch, DTTextInput
|
||||||
|
|
||||||
## React Native Components
|
## React Native Components
|
||||||
|
|
||||||
DTAccordion, DTButton, DTCard, DTCheckbox, DTChip, DTDrawer, DTGallery, DTHexagon, DTLabel, DTMediaFrame, DTMenu, DTModal, DTProgressBar, DTQuantityStepper, DTRadioGroup, DTSearchInput, DTSwitch, DTTextInput
|
DTAccordion, DTBadgeOverlay, DTButton, DTCard, DTCheckbox, DTChip, DTDrawer, DTFeatureLegend, DTGallery, DTHexagon, DTLabel, DTMediaFrame, DTMenu, DTMobileFilterOverlay, DTModal, DTProgressBar, DTQuantityStepper, DTRadioGroup, DTSearchInput, DTStaggerContainer, DTSwitch, DTTextInput
|
||||||
|
|
||||||
|
**Animation hooks:** `useScaleIn`, `usePulse`
|
||||||
|
|
||||||
## Web CSS Modules
|
## Web CSS Modules
|
||||||
|
|
||||||
- `bevels.css` — angular clip-path patterns for the DT brand
|
- `bevels.css` — angular clip-path bevels, card modes, selected states, progress bars, badge overlays, interactive bevel buttons
|
||||||
- `glows.css` — neon drop-shadow effects for clipped elements
|
- `forms-dt.css` — text inputs, checkboxes, radio buttons, switches, menu items, filter headers
|
||||||
- `forms-dt.css` — text inputs, checkboxes, radio buttons, switches
|
- `animations.css` — entrance animations, stagger containers, transition utilities
|
||||||
- `elevation.css` — shadow elevation for Supra / MD3 brand
|
- `scrollbar.css` — thin neon scrollbar styling
|
||||||
|
- `feature-legend.css` — product feature grid with rotated labels
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
@@ -115,8 +155,11 @@ dt-design-system/
|
|||||||
├── packages/
|
├── packages/
|
||||||
│ ├── tokens/ # Design token definitions + CSS generation
|
│ ├── tokens/ # Design token definitions + CSS generation
|
||||||
│ ├── web/ # CSS themes and components
|
│ ├── web/ # CSS themes and components
|
||||||
|
│ ├── react/ # React web components
|
||||||
│ ├── react-native/ # React Native Paper components
|
│ ├── react-native/ # React Native Paper components
|
||||||
│ └── showcase/ # Demo app (placeholder)
|
│ ├── tailwind-preset/ # Tailwind CSS v3 preset
|
||||||
|
│ ├── hex-background/ # 3D hexagon grid (Three.js)
|
||||||
|
│ └── showcase/ # Demo apps (desktop + mobile)
|
||||||
├── turbo.json # Turbo pipeline config
|
├── turbo.json # Turbo pipeline config
|
||||||
└── package.json # Workspace root
|
└── package.json # Workspace root
|
||||||
```
|
```
|
||||||
|
|||||||
12448
package-lock.json
generated
12448
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,13 +9,16 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*",
|
||||||
|
"packages/showcase/*"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"build:tokens": "npm run build -w packages/tokens",
|
"build:tokens": "npm run build -w packages/tokens",
|
||||||
"build:web": "npm run build -w packages/web",
|
"build:web": "npm run build -w packages/web",
|
||||||
"build:react-native": "npm run build -w packages/react-native",
|
"build:react-native": "npm run build -w packages/react-native",
|
||||||
|
"start:mobile": "npm run start -w @dangerousthings/showcase-mobile",
|
||||||
|
"start:desktop": "npm run start -w @dangerousthings/showcase-desktop",
|
||||||
"clean": "turbo run clean",
|
"clean": "turbo run clean",
|
||||||
"typecheck": "turbo run typecheck",
|
"typecheck": "turbo run typecheck",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
@@ -25,6 +28,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@changesets/cli": "^2.30.0",
|
"@changesets/cli": "^2.30.0",
|
||||||
|
"puppeteer": "^24.38.0",
|
||||||
"turbo": "^2.4.0",
|
"turbo": "^2.4.0",
|
||||||
"typescript": "^5.7.0"
|
"typescript": "^5.7.0"
|
||||||
},
|
},
|
||||||
|
|||||||
15
packages/hex-background/CHANGELOG.md
Normal file
15
packages/hex-background/CHANGELOG.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# @dangerousthings/hex-background
|
||||||
|
|
||||||
|
## 0.2.1
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Animation no longer degrades to noise over time — replaced frame-rate-dependent lerp with duration-based smoothstep easing
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `animationDuration` prop (default 1200ms) controls how long each hexagon takes to reach its target height
|
||||||
|
|
||||||
|
## 0.2.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- e74c285: New package: 3D hexagon grid background using Three.js + React Three Fiber. Includes HexGridBackground (full-viewport web component), HexGrid (instanced mesh with animated heights), and HexCamera (orbiting camera). Supports both web and React Native via separate entry points.
|
||||||
21
packages/hex-background/LICENSE
Normal file
21
packages/hex-background/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Dangerous Things
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
84
packages/hex-background/README.md
Normal file
84
packages/hex-background/README.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# @dangerousthings/hex-background
|
||||||
|
|
||||||
|
3D hexagon grid background for the Dangerous Things design system. Renders an animated, instanced WebGL hex grid using Three.js and React Three Fiber. Hexagons animate to random heights on a loop, with a slowly orbiting camera.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @dangerousthings/hex-background
|
||||||
|
```
|
||||||
|
|
||||||
|
### Peer Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install react @react-three/fiber three
|
||||||
|
```
|
||||||
|
|
||||||
|
For React Native, also install `expo-gl`.
|
||||||
|
|
||||||
|
## Web Usage
|
||||||
|
|
||||||
|
Drop-in full-viewport background that renders behind page content:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { HexGridBackground } from "@dangerousthings/hex-background";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HexGridBackground />
|
||||||
|
<main>{/* your content */}</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The component renders a fixed-position Canvas at `z-index: -1` with `pointer-events: none`, so it sits behind all other content. It automatically sizes the grid to the viewport and skips rendering in automated browsers (Selenium, Puppeteer).
|
||||||
|
|
||||||
|
### Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `opacity` | `number` | `0.5` | Canvas opacity |
|
||||||
|
| `hexRadius` | `number` | `0.5` | Hexagon radius |
|
||||||
|
| `margin` | `number` | `0.05` | Spacing between hexagons |
|
||||||
|
| `maxHeight` | `number` | `3` | Maximum animation height |
|
||||||
|
| `animationInterval` | `number` | `1500` | Height target refresh interval in ms |
|
||||||
|
| `cameraSpeed` | `number` | `0.02` | Camera orbital speed |
|
||||||
|
| `cameraRadius` | `number` | `8` | Camera orbital radius |
|
||||||
|
| `fov` | `number` | `40` | Camera field of view |
|
||||||
|
|
||||||
|
## React Native Usage
|
||||||
|
|
||||||
|
The full-viewport `HexGridBackground` wrapper uses web-specific APIs. For React Native, use the lower-level `HexGrid` and `HexCamera` components directly within a native Canvas:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Canvas } from "@react-three/fiber/native";
|
||||||
|
import { HexGrid, HexCamera } from "@dangerousthings/hex-background/native";
|
||||||
|
|
||||||
|
function Background() {
|
||||||
|
return (
|
||||||
|
<Canvas camera={{ position: [5, 5, 5], fov: 40 }}>
|
||||||
|
<HexCamera />
|
||||||
|
<ambientLight intensity={20} />
|
||||||
|
<directionalLight position={[20, 40, 20]} intensity={1.5} />
|
||||||
|
<HexGrid rows={20} cols={20} />
|
||||||
|
</Canvas>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Exports
|
||||||
|
|
||||||
|
| Path | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `@dangerousthings/hex-background` | `HexGridBackground`, `HexGrid`, `HexCamera` (web) |
|
||||||
|
| `@dangerousthings/hex-background/native` | `HexGrid`, `HexCamera` (React Native) |
|
||||||
|
|
||||||
|
## Monorepo
|
||||||
|
|
||||||
|
Part of the [DT Design System](https://github.com/dangerous-tac0s/dt-design-system) monorepo.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
73
packages/hex-background/package.json
Normal file
73
packages/hex-background/package.json
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
{
|
||||||
|
"name": "@dangerousthings/hex-background",
|
||||||
|
"version": "0.2.1",
|
||||||
|
"description": "3D hexagon grid background for the Dangerous Things design system",
|
||||||
|
"license": "MIT",
|
||||||
|
"author": {
|
||||||
|
"name": "Dangerous Things",
|
||||||
|
"email": "info@dangerousthings.com"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/dangerous-tac0s/dt-design-system.git",
|
||||||
|
"directory": "packages/hex-background"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/dangerous-tac0s/dt-design-system#readme",
|
||||||
|
"keywords": [
|
||||||
|
"dangerousthings",
|
||||||
|
"design-system",
|
||||||
|
"three.js",
|
||||||
|
"react-three-fiber",
|
||||||
|
"3d",
|
||||||
|
"hexagon",
|
||||||
|
"background"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./native": {
|
||||||
|
"types": "./dist/native.d.ts",
|
||||||
|
"import": "./dist/native.js",
|
||||||
|
"default": "./dist/native.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"react-native": "dist/native.js",
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"@react-three/fiber": ">=8",
|
||||||
|
"three": ">=0.150"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"expo-gl": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@react-three/fiber": "^8.18.0",
|
||||||
|
"@types/react": "^18.2.0",
|
||||||
|
"@types/three": "^0.160.0",
|
||||||
|
"react": "^18.0.0",
|
||||||
|
"three": "^0.160.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
36
packages/hex-background/src/HexCamera.tsx
Normal file
36
packages/hex-background/src/HexCamera.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* HexCamera — Orbiting camera for hexagon grid
|
||||||
|
*
|
||||||
|
* Continuously rotates around the grid center at a fixed radius and height.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {useRef} from 'react';
|
||||||
|
import {useFrame, useThree} from '@react-three/fiber';
|
||||||
|
|
||||||
|
export interface HexCameraProps {
|
||||||
|
/** Orbital rotation speed in radians per second @default 0.02 */
|
||||||
|
speed?: number;
|
||||||
|
/** Orbital distance from center @default 8 */
|
||||||
|
radius?: number;
|
||||||
|
/** Camera Y height @default 5 */
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HexCamera({
|
||||||
|
speed = 0.02,
|
||||||
|
radius = 8,
|
||||||
|
height = 5,
|
||||||
|
}: HexCameraProps) {
|
||||||
|
const angleRef = useRef(0);
|
||||||
|
const {camera} = useThree();
|
||||||
|
|
||||||
|
useFrame((_state, delta) => {
|
||||||
|
angleRef.current = (angleRef.current + speed * delta) % (Math.PI * 2);
|
||||||
|
const x = Math.cos(angleRef.current) * radius;
|
||||||
|
const z = Math.sin(angleRef.current) * radius;
|
||||||
|
camera.position.set(x, height, z);
|
||||||
|
camera.lookAt(0, 0, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
171
packages/hex-background/src/HexGrid.tsx
Normal file
171
packages/hex-background/src/HexGrid.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* HexGrid — Core 3D hexagon grid renderer
|
||||||
|
*
|
||||||
|
* Renders an instanced mesh of animated hexagonal columns using Three.js.
|
||||||
|
* Source: dt-shopify-storefront HexagonGrid.tsx
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {useRef, useMemo, useEffect} from 'react';
|
||||||
|
import {useFrame} from '@react-three/fiber';
|
||||||
|
import {
|
||||||
|
InstancedMesh,
|
||||||
|
Object3D,
|
||||||
|
Shape,
|
||||||
|
ExtrudeGeometry,
|
||||||
|
MeshStandardMaterial,
|
||||||
|
} from 'three';
|
||||||
|
|
||||||
|
export interface HexGridProps {
|
||||||
|
rows: number;
|
||||||
|
cols: number;
|
||||||
|
/** Hexagon radius in world units @default 0.5 */
|
||||||
|
hexRadius?: number;
|
||||||
|
/** Extrude depth of hex geometry @default 1 */
|
||||||
|
extrudeHeight?: number;
|
||||||
|
/** Spacing between hexagons @default 0.05 */
|
||||||
|
margin?: number;
|
||||||
|
/** Maximum random height for animation @default 3 */
|
||||||
|
maxHeight?: number;
|
||||||
|
/** Interval (ms) between height target changes @default 1500 */
|
||||||
|
animationInterval?: number;
|
||||||
|
/** Duration (ms) for each hex to reach its target @default 1200 */
|
||||||
|
animationDuration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hermite smoothstep: 3t² - 2t³ */
|
||||||
|
function smoothstep(t: number): number {
|
||||||
|
return t * t * (3 - 2 * t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeHexPositions(
|
||||||
|
rows: number,
|
||||||
|
cols: number,
|
||||||
|
hexWidth: number,
|
||||||
|
hexHeight: number,
|
||||||
|
margin: number,
|
||||||
|
) {
|
||||||
|
const positions: {x: number; z: number}[] = [];
|
||||||
|
const horizontalStep = hexWidth * 0.75 + margin;
|
||||||
|
const verticalStep = hexHeight * 0.866 + margin;
|
||||||
|
|
||||||
|
for (let row = 0; row < rows; row++) {
|
||||||
|
for (let col = 0; col < cols; col++) {
|
||||||
|
const x = col * horizontalStep;
|
||||||
|
const z = row * verticalStep + (col % 2 ? verticalStep / 2 : 0);
|
||||||
|
const centerX = ((cols - 1) * horizontalStep) / 2;
|
||||||
|
const centerZ = ((rows - 1) * verticalStep) / 2;
|
||||||
|
positions.push({x: x - centerX, z: z - centerZ});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HexGrid({
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
hexRadius = 0.5,
|
||||||
|
extrudeHeight = 1,
|
||||||
|
margin = 0.05,
|
||||||
|
maxHeight = 3,
|
||||||
|
animationInterval = 1500,
|
||||||
|
animationDuration = 1200,
|
||||||
|
}: HexGridProps) {
|
||||||
|
const meshRef = useRef<InstancedMesh>(null);
|
||||||
|
|
||||||
|
const positions = useMemo(
|
||||||
|
() => computeHexPositions(rows, cols, hexRadius * 2, extrudeHeight, margin),
|
||||||
|
[rows, cols, hexRadius, extrudeHeight, margin],
|
||||||
|
);
|
||||||
|
|
||||||
|
const animStateRef = useRef(
|
||||||
|
positions.map(() => {
|
||||||
|
const h = 1 + Math.random() * maxHeight;
|
||||||
|
return {startHeight: h, targetHeight: h, startTime: 0};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reuse dummy object for matrix updates — 15-20% FPS improvement
|
||||||
|
const dummyRef = useRef(new Object3D());
|
||||||
|
|
||||||
|
const hexGeometry = useMemo(() => {
|
||||||
|
const shape = new Shape();
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const angle = (Math.PI / 3) * i;
|
||||||
|
const x = hexRadius * Math.cos(angle);
|
||||||
|
const z = hexRadius * Math.sin(angle);
|
||||||
|
i === 0 ? shape.moveTo(x, z) : shape.lineTo(x, z);
|
||||||
|
}
|
||||||
|
shape.closePath();
|
||||||
|
const geometry = new ExtrudeGeometry(shape, {
|
||||||
|
depth: 1,
|
||||||
|
bevelEnabled: false,
|
||||||
|
});
|
||||||
|
geometry.rotateX(Math.PI / 2);
|
||||||
|
geometry.computeVertexNormals();
|
||||||
|
return geometry;
|
||||||
|
}, [hexRadius]);
|
||||||
|
|
||||||
|
// Regenerate target heights at interval
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const now = performance.now();
|
||||||
|
animStateRef.current = animStateRef.current.map((state) => {
|
||||||
|
// Compute where the hex currently is (same easing as useFrame)
|
||||||
|
const elapsed = Math.min(
|
||||||
|
(now - state.startTime) / animationDuration,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const t = smoothstep(elapsed);
|
||||||
|
const currentHeight =
|
||||||
|
state.startHeight + (state.targetHeight - state.startHeight) * t;
|
||||||
|
return {
|
||||||
|
startHeight: currentHeight,
|
||||||
|
targetHeight: 0.5 + Math.random() * maxHeight,
|
||||||
|
startTime: now,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, animationInterval);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [positions.length, maxHeight, animationInterval, animationDuration]);
|
||||||
|
|
||||||
|
// Animate per frame: time-based smoothstep easing
|
||||||
|
useFrame(() => {
|
||||||
|
if (!meshRef.current) return;
|
||||||
|
const dummy = dummyRef.current;
|
||||||
|
const now = performance.now();
|
||||||
|
|
||||||
|
positions.forEach(({x, z}, i) => {
|
||||||
|
const state = animStateRef.current[i];
|
||||||
|
const elapsed = Math.min(
|
||||||
|
(now - state.startTime) / animationDuration,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const t = smoothstep(elapsed);
|
||||||
|
const height =
|
||||||
|
state.startHeight + (state.targetHeight - state.startHeight) * t;
|
||||||
|
|
||||||
|
dummy.position.set(x, height / 2, z);
|
||||||
|
dummy.scale.set(1, height, 1);
|
||||||
|
dummy.rotation.set(0, 0, 0);
|
||||||
|
dummy.updateMatrix();
|
||||||
|
meshRef.current!.setMatrixAt(i, dummy.matrix);
|
||||||
|
});
|
||||||
|
|
||||||
|
meshRef.current.instanceMatrix.needsUpdate = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<instancedMesh
|
||||||
|
ref={meshRef}
|
||||||
|
args={[
|
||||||
|
hexGeometry,
|
||||||
|
new MeshStandardMaterial({
|
||||||
|
color: 'black',
|
||||||
|
metalness: 0.3,
|
||||||
|
roughness: 0.7,
|
||||||
|
}),
|
||||||
|
positions.length,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
packages/hex-background/src/HexGridBackground.tsx
Normal file
145
packages/hex-background/src/HexGridBackground.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* HexGridBackground — Full-viewport hex grid background
|
||||||
|
*
|
||||||
|
* Drop-in component that renders a fixed, semi-transparent 3D hexagon grid
|
||||||
|
* behind page content. Automatically sizes grid to viewport and detects
|
||||||
|
* automated browsers to skip rendering.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {useState, useEffect, useRef, Suspense} from 'react';
|
||||||
|
import {Canvas} from '@react-three/fiber';
|
||||||
|
import {HexGrid} from './HexGrid';
|
||||||
|
import {HexCamera} from './HexCamera';
|
||||||
|
|
||||||
|
export interface HexGridBackgroundProps {
|
||||||
|
/** Canvas opacity @default 0.5 */
|
||||||
|
opacity?: number;
|
||||||
|
/** Hexagon radius @default 0.5 */
|
||||||
|
hexRadius?: number;
|
||||||
|
/** Spacing between hexagons @default 0.05 */
|
||||||
|
margin?: number;
|
||||||
|
/** Maximum animation height @default 3 */
|
||||||
|
maxHeight?: number;
|
||||||
|
/** Height target refresh interval in ms @default 1500 */
|
||||||
|
animationInterval?: number;
|
||||||
|
/** Duration (ms) for each hex to ease to target @default 1200 */
|
||||||
|
animationDuration?: number;
|
||||||
|
/** Camera orbital speed @default 0.02 */
|
||||||
|
cameraSpeed?: number;
|
||||||
|
/** Camera orbital radius @default 8 */
|
||||||
|
cameraRadius?: number;
|
||||||
|
/** Camera field of view @default 40 */
|
||||||
|
fov?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateGridDimensions(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
cameraDistance: number,
|
||||||
|
fov: number,
|
||||||
|
hexRadius: number,
|
||||||
|
margin: number,
|
||||||
|
): {rows: number; cols: number} {
|
||||||
|
const aspect = width / height;
|
||||||
|
const fovRadians = (fov * Math.PI) / 180;
|
||||||
|
const visibleHeight = 2 * cameraDistance * Math.tan(fovRadians / 2);
|
||||||
|
const visibleWidth = visibleHeight * aspect;
|
||||||
|
|
||||||
|
const hexWidth = hexRadius * 2;
|
||||||
|
const horizontalStep = hexWidth * 0.75 + margin;
|
||||||
|
const verticalStep = hexWidth * 0.866 + margin;
|
||||||
|
|
||||||
|
const cols = Math.ceil((visibleWidth / horizontalStep) * 1.5);
|
||||||
|
const rows = Math.ceil((visibleHeight / verticalStep) * 1.5);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: Math.min(Math.max(rows, 15), 35),
|
||||||
|
cols: Math.min(Math.max(cols, 15), 35),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HexGridBackground({
|
||||||
|
opacity = 0.5,
|
||||||
|
hexRadius = 0.5,
|
||||||
|
margin = 0.05,
|
||||||
|
maxHeight = 3,
|
||||||
|
animationInterval = 1500,
|
||||||
|
animationDuration = 1200,
|
||||||
|
cameraSpeed = 0.02,
|
||||||
|
cameraRadius = 8,
|
||||||
|
fov = 40,
|
||||||
|
}: HexGridBackgroundProps) {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [isAutomated, setIsAutomated] = useState(false);
|
||||||
|
const [gridDimensions, setGridDimensions] = useState({rows: 20, cols: 20});
|
||||||
|
const resizeTimeout = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
|
||||||
|
// Detect automated browsers (Selenium, Puppeteer, etc.)
|
||||||
|
if (typeof navigator !== 'undefined' && (navigator as any).webdriver) {
|
||||||
|
setIsAutomated(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateDimensions = () => {
|
||||||
|
const dims = calculateGridDimensions(
|
||||||
|
window.innerWidth,
|
||||||
|
window.innerHeight,
|
||||||
|
cameraRadius,
|
||||||
|
fov,
|
||||||
|
hexRadius,
|
||||||
|
margin,
|
||||||
|
);
|
||||||
|
setGridDimensions(dims);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateDimensions();
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
clearTimeout(resizeTimeout.current);
|
||||||
|
resizeTimeout.current = setTimeout(updateDimensions, 250);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
clearTimeout(resizeTimeout.current);
|
||||||
|
};
|
||||||
|
}, [cameraRadius, fov, hexRadius, margin]);
|
||||||
|
|
||||||
|
if (!mounted || isAutomated) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Canvas
|
||||||
|
camera={{position: [5, 5, 5], fov, near: 0.5, far: 50}}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100vw',
|
||||||
|
height: '100vh',
|
||||||
|
zIndex: -1,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
opacity,
|
||||||
|
margin: 0,
|
||||||
|
}}>
|
||||||
|
<HexCamera speed={cameraSpeed} radius={cameraRadius} />
|
||||||
|
<ambientLight intensity={20} />
|
||||||
|
<directionalLight position={[20, 40, 20]} intensity={1.5} />
|
||||||
|
<directionalLight position={[-10, 10, -10]} intensity={0.5} />
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<HexGrid
|
||||||
|
rows={gridDimensions.rows}
|
||||||
|
cols={gridDimensions.cols}
|
||||||
|
hexRadius={hexRadius}
|
||||||
|
margin={margin}
|
||||||
|
maxHeight={maxHeight}
|
||||||
|
animationInterval={animationInterval}
|
||||||
|
animationDuration={animationDuration}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</Canvas>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
packages/hex-background/src/index.ts
Normal file
23
packages/hex-background/src/index.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* @dangerousthings/hex-background
|
||||||
|
*
|
||||||
|
* 3D hexagon grid background for the Dangerous Things design system.
|
||||||
|
* Uses Three.js + React Three Fiber for instanced WebGL rendering.
|
||||||
|
*
|
||||||
|
* Web usage:
|
||||||
|
* import { HexGridBackground } from '@dangerousthings/hex-background';
|
||||||
|
* <HexGridBackground />
|
||||||
|
*
|
||||||
|
* React Native usage:
|
||||||
|
* import { HexGridBackground } from '@dangerousthings/hex-background/native';
|
||||||
|
* <HexGridBackground />
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { HexGridBackground } from './HexGridBackground';
|
||||||
|
export type { HexGridBackgroundProps } from './HexGridBackground';
|
||||||
|
|
||||||
|
export { HexGrid } from './HexGrid';
|
||||||
|
export type { HexGridProps } from './HexGrid';
|
||||||
|
|
||||||
|
export { HexCamera } from './HexCamera';
|
||||||
|
export type { HexCameraProps } from './HexCamera';
|
||||||
25
packages/hex-background/src/native.ts
Normal file
25
packages/hex-background/src/native.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* React Native entry point for @dangerousthings/hex-background
|
||||||
|
*
|
||||||
|
* Uses @react-three/fiber/native renderer with expo-gl.
|
||||||
|
* Consumers must install: expo-gl, @react-three/fiber, three
|
||||||
|
*
|
||||||
|
* Note: The HexGridBackground component uses web-specific APIs (window, CSS units).
|
||||||
|
* For React Native, use HexGrid and HexCamera directly within a native Canvas:
|
||||||
|
*
|
||||||
|
* import { Canvas } from '@react-three/fiber/native';
|
||||||
|
* import { HexGrid, HexCamera } from '@dangerousthings/hex-background/native';
|
||||||
|
*
|
||||||
|
* <Canvas camera={{ position: [5, 5, 5], fov: 40 }}>
|
||||||
|
* <HexCamera />
|
||||||
|
* <ambientLight intensity={20} />
|
||||||
|
* <directionalLight position={[20, 40, 20]} intensity={1.5} />
|
||||||
|
* <HexGrid rows={20} cols={20} />
|
||||||
|
* </Canvas>
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { HexGrid } from './HexGrid';
|
||||||
|
export type { HexGridProps } from './HexGrid';
|
||||||
|
|
||||||
|
export { HexCamera } from './HexCamera';
|
||||||
|
export type { HexCameraProps } from './HexCamera';
|
||||||
9
packages/hex-background/tsconfig.json
Normal file
9
packages/hex-background/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -1,5 +1,29 @@
|
|||||||
# @dangerousthings/react-native
|
# @dangerousthings/react-native
|
||||||
|
|
||||||
|
## 0.4.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Video letterboxing for DTMediaFrame, card badge styling with bevel-aware positioning, restored DTStaggerContainer.
|
||||||
|
|
||||||
|
## 0.4.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- Video letterboxing for DTMediaFrame, card badge styling with bevel-aware positioning, restored DTStaggerContainer in react-native, updated DTGallery/DTModal/DTMobileFilterOverlay components.
|
||||||
|
|
||||||
|
## 0.3.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- e74c285: Add storefront component features: DTCard selected state and progress bar, DTBadgeOverlay positioning wrapper, DTStaggerContainer with staggered scale-in animation, DTFeatureLegend product feature grid, DTMobileFilterOverlay slide-up filter panel, useScaleIn and usePulse animation hooks. Rewrite DTButton with interactive bevel pattern (outlined rectangle, bevels on hover/select).
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [e74c285]
|
||||||
|
- Updated dependencies [e74c285]
|
||||||
|
- @dangerousthings/tokens@0.3.0
|
||||||
|
|
||||||
## 0.2.1
|
## 0.2.1
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# @dangerousthings/react-native
|
# @dangerousthings/react-native
|
||||||
|
|
||||||
React Native components for the Dangerous Things design system — 18 themed components built on React Native Paper with cyberpunk beveled aesthetics.
|
React Native components for the Dangerous Things design system — 22 themed components built on React Native Paper with cyberpunk beveled aesthetics.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -74,6 +74,9 @@ Options:
|
|||||||
| `DTMediaFrame` | Diagonal beveled frame for images |
|
| `DTMediaFrame` | Diagonal beveled frame for images |
|
||||||
| `DTAccordion` | Collapsible sections with accent border |
|
| `DTAccordion` | Collapsible sections with accent border |
|
||||||
| `DTHexagon` | Decorative hexagon SVG shape |
|
| `DTHexagon` | Decorative hexagon SVG shape |
|
||||||
|
| `DTBadgeOverlay` | Absolute-positioned badge wrapper (top-left, top-right, bottom-left, bottom-right) |
|
||||||
|
| `DTStaggerContainer` | Staggered scale-in entrance animation for child elements |
|
||||||
|
| `DTFeatureLegend` | Product feature grid with icons and rotated labels |
|
||||||
|
|
||||||
### Interactive
|
### Interactive
|
||||||
|
|
||||||
@@ -83,6 +86,14 @@ Options:
|
|||||||
| `DTDrawer` | Side drawer with edge bevels |
|
| `DTDrawer` | Side drawer with edge bevels |
|
||||||
| `DTGallery` | Image gallery |
|
| `DTGallery` | Image gallery |
|
||||||
| `DTMenu` | Dropdown menu with item variants |
|
| `DTMenu` | Dropdown menu with item variants |
|
||||||
|
| `DTMobileFilterOverlay` | Full-screen slide-up filter overlay with backdrop |
|
||||||
|
|
||||||
|
### Animation Hooks
|
||||||
|
|
||||||
|
| Hook | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `useScaleIn` | Scale 0→1 entrance animation |
|
||||||
|
| `usePulse` | Looping opacity pulse animation |
|
||||||
|
|
||||||
## Usage Examples
|
## Usage Examples
|
||||||
|
|
||||||
@@ -99,6 +110,11 @@ import { DTButton, DTCard, DTTextInput } from "@dangerousthings/react-native";
|
|||||||
<Text>Chip detected</Text>
|
<Text>Chip detected</Text>
|
||||||
</DTCard>
|
</DTCard>
|
||||||
|
|
||||||
|
// Card with selected state and progress bar
|
||||||
|
<DTCard title="PRODUCT" mode="emphasis" selected progress={0.6}>
|
||||||
|
<Text>Selected with 60% progress</Text>
|
||||||
|
</DTCard>
|
||||||
|
|
||||||
// Text input with error state
|
// Text input with error state
|
||||||
<DTTextInput
|
<DTTextInput
|
||||||
variant="normal"
|
variant="normal"
|
||||||
|
|||||||
@@ -1,8 +1,29 @@
|
|||||||
{
|
{
|
||||||
"name": "@dangerousthings/react-native",
|
"name": "@dangerousthings/react-native",
|
||||||
"version": "0.2.1",
|
"version": "0.4.1",
|
||||||
"description": "React Native themed components for the Dangerous Things design system",
|
"description": "React Native themed components for the Dangerous Things design system",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"author": {
|
||||||
|
"name": "Dangerous Things",
|
||||||
|
"email": "info@dangerousthings.com"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/dangerous-tac0s/dt-design-system.git",
|
||||||
|
"directory": "packages/react-native"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/dangerous-tac0s/dt-design-system#readme",
|
||||||
|
"keywords": [
|
||||||
|
"dangerousthings",
|
||||||
|
"design-system",
|
||||||
|
"react-native",
|
||||||
|
"components",
|
||||||
|
"theming"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
Animated,
|
Animated,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import {Text} from 'react-native-paper';
|
import {Text} from 'react-native-paper';
|
||||||
import {DTColors} from '../theme/colors';
|
import {useDTTheme} from '../theme/DTThemeProvider';
|
||||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||||
|
|
||||||
export interface DTAccordionSection {
|
export interface DTAccordionSection {
|
||||||
@@ -93,6 +93,7 @@ export function DTAccordion({
|
|||||||
onSectionToggle,
|
onSectionToggle,
|
||||||
style,
|
style,
|
||||||
}: DTAccordionProps) {
|
}: DTAccordionProps) {
|
||||||
|
const theme = useDTTheme();
|
||||||
const [openKeys, setOpenKeys] = useState<Set<string>>(
|
const [openKeys, setOpenKeys] = useState<Set<string>>(
|
||||||
new Set(initialOpenKeys),
|
new Set(initialOpenKeys),
|
||||||
);
|
);
|
||||||
@@ -114,8 +115,8 @@ export function DTAccordion({
|
|||||||
[allowMultiple, onSectionToggle],
|
[allowMultiple, onSectionToggle],
|
||||||
);
|
);
|
||||||
|
|
||||||
const inactiveColor = getVariantColor(variant);
|
const inactiveColor = getVariantColor(theme, variant);
|
||||||
const activeColor = getVariantColor(activeVariant);
|
const activeColor = getVariantColor(theme, activeVariant);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, style]}>
|
<View style={[styles.container, style]}>
|
||||||
@@ -146,6 +147,7 @@ function AccordionSection({
|
|||||||
inactiveColor: string;
|
inactiveColor: string;
|
||||||
activeColor: string;
|
activeColor: string;
|
||||||
}) {
|
}) {
|
||||||
|
const theme = useDTTheme();
|
||||||
const heightAnim = useRef(new Animated.Value(isOpen ? 1 : 0)).current;
|
const heightAnim = useRef(new Animated.Value(isOpen ? 1 : 0)).current;
|
||||||
const chevronAnim = useRef(new Animated.Value(isOpen ? 1 : 0)).current;
|
const chevronAnim = useRef(new Animated.Value(isOpen ? 1 : 0)).current;
|
||||||
const [contentHeight, setContentHeight] = useState(0);
|
const [contentHeight, setContentHeight] = useState(0);
|
||||||
@@ -187,6 +189,7 @@ function AccordionSection({
|
|||||||
{
|
{
|
||||||
borderColor: sectionColor,
|
borderColor: sectionColor,
|
||||||
borderTopColor: sectionColor,
|
borderTopColor: sectionColor,
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
opacity: pressed ? 0.7 : 1,
|
opacity: pressed ? 0.7 : 1,
|
||||||
},
|
},
|
||||||
]}>
|
]}>
|
||||||
@@ -214,7 +217,7 @@ function AccordionSection({
|
|||||||
},
|
},
|
||||||
]}>
|
]}>
|
||||||
<View
|
<View
|
||||||
style={styles.content}
|
style={[styles.content, {backgroundColor: theme.colors.background}]}
|
||||||
onLayout={e => {
|
onLayout={e => {
|
||||||
const h = e.nativeEvent.layout.height;
|
const h = e.nativeEvent.layout.height;
|
||||||
if (h > 0 && !measured) {
|
if (h > 0 && !measured) {
|
||||||
@@ -240,7 +243,6 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
backgroundColor: DTColors.dark,
|
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderTopWidth: 5,
|
borderTopWidth: 5,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
@@ -249,14 +251,12 @@ const styles = StyleSheet.create({
|
|||||||
title: {
|
title: {
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
textTransform: 'uppercase',
|
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
},
|
},
|
||||||
contentWrapper: {},
|
contentWrapper: {},
|
||||||
content: {
|
content: {
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
backgroundColor: DTColors.dark,
|
|
||||||
},
|
},
|
||||||
chevron: {
|
chevron: {
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|||||||
45
packages/react-native/src/components/DTBadgeOverlay.tsx
Normal file
45
packages/react-native/src/components/DTBadgeOverlay.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* DT Badge Overlay
|
||||||
|
*
|
||||||
|
* Positioning wrapper for badges on cards and media frames.
|
||||||
|
* Places children absolutely at a specified corner with configurable offset.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {ReactNode} from 'react';
|
||||||
|
import {View, ViewStyle, StyleProp} from 'react-native';
|
||||||
|
|
||||||
|
type BadgePosition = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||||
|
|
||||||
|
interface DTBadgeOverlayProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/**
|
||||||
|
* Corner position for the badge
|
||||||
|
* @default 'bottom-right'
|
||||||
|
*/
|
||||||
|
position?: BadgePosition;
|
||||||
|
/**
|
||||||
|
* Offset from the corner in pixels
|
||||||
|
* @default 8
|
||||||
|
*/
|
||||||
|
offset?: number;
|
||||||
|
/**
|
||||||
|
* Additional styles
|
||||||
|
*/
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DTBadgeOverlay({
|
||||||
|
children,
|
||||||
|
position = 'bottom-right',
|
||||||
|
offset = 8,
|
||||||
|
style,
|
||||||
|
}: DTBadgeOverlayProps) {
|
||||||
|
const positionStyle: ViewStyle = {
|
||||||
|
position: 'absolute',
|
||||||
|
zIndex: 4,
|
||||||
|
...(position.includes('top') ? {top: offset} : {bottom: offset}),
|
||||||
|
...(position.includes('right') ? {right: offset} : {left: offset}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return <View style={[positionStyle, style]}>{children}</View>;
|
||||||
|
}
|
||||||
@@ -1,18 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* DT Button Component
|
* DT Button Component
|
||||||
*
|
*
|
||||||
* A themed button following the Dangerous Things design language
|
* Interactive bevel button matching the storefront .menu-item-clipped pattern:
|
||||||
* with SVG-based beveled corners.
|
* - Default: outlined rectangle, no bevel, colored border with thick top
|
||||||
|
* - Pressed/hover: bottom-right bevel appears, fills with mode color, text goes dark
|
||||||
|
* - Selected: bevel persists, fills with 70% opacity mode color
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {StyleSheet, View, ViewStyle, StyleProp, Pressable} from 'react-native';
|
import {useRef, useEffect} from 'react';
|
||||||
|
import {StyleSheet, View, ViewStyle, StyleProp, Pressable, Animated} from 'react-native';
|
||||||
import {Text} from 'react-native-paper';
|
import {Text} from 'react-native-paper';
|
||||||
import Svg, {Path} from 'react-native-svg';
|
import Svg, {Path, Rect} from 'react-native-svg';
|
||||||
import {DTColors} from '../theme/colors';
|
import {useDTTheme} from '../theme/DTThemeProvider';
|
||||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||||
import {buildButtonBevelPath} from '../utils/bevelPaths';
|
import {buildBeveledRectPath} from '../utils/bevelPaths';
|
||||||
import {useComponentLayout} from '../utils/useComponentLayout';
|
import {useComponentLayout} from '../utils/useComponentLayout';
|
||||||
import {useState} from 'react';
|
|
||||||
|
|
||||||
interface DTButtonProps {
|
interface DTButtonProps {
|
||||||
/**
|
/**
|
||||||
@@ -26,11 +28,16 @@ interface DTButtonProps {
|
|||||||
variant?: DTVariant;
|
variant?: DTVariant;
|
||||||
/**
|
/**
|
||||||
* Button display mode
|
* Button display mode
|
||||||
* - 'outlined': Border with transparent background (default)
|
* - 'outlined': Border with transparent background, bevel on press (default)
|
||||||
* - 'contained': Filled background
|
* - 'contained': Filled background with static bevel
|
||||||
* @default 'outlined'
|
* @default 'outlined'
|
||||||
*/
|
*/
|
||||||
mode?: 'outlined' | 'contained';
|
mode?: 'outlined' | 'contained';
|
||||||
|
/**
|
||||||
|
* Whether the button is in a persistent selected state
|
||||||
|
* (bevel visible, filled with 70% opacity mode color)
|
||||||
|
*/
|
||||||
|
selected?: boolean;
|
||||||
/**
|
/**
|
||||||
* Custom color (overrides variant)
|
* Custom color (overrides variant)
|
||||||
*/
|
*/
|
||||||
@@ -53,79 +60,169 @@ interface DTButtonProps {
|
|||||||
*/
|
*/
|
||||||
borderWidth?: number;
|
borderWidth?: number;
|
||||||
/**
|
/**
|
||||||
* Bevel size in pixels
|
* Bevel size in pixels (bottom-right corner)
|
||||||
* @default 8
|
* @default 16
|
||||||
*/
|
*/
|
||||||
bevelSize?: number;
|
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({
|
export function DTButton({
|
||||||
children,
|
children,
|
||||||
variant = 'normal',
|
variant = 'normal',
|
||||||
mode = 'outlined',
|
mode = 'outlined',
|
||||||
|
selected = false,
|
||||||
color,
|
color,
|
||||||
onPress,
|
onPress,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
style,
|
style,
|
||||||
borderWidth = 2,
|
borderWidth = 2,
|
||||||
bevelSize = 8,
|
bevelSize = 16,
|
||||||
}: DTButtonProps) {
|
}: DTButtonProps) {
|
||||||
|
const theme = useDTTheme();
|
||||||
const {dimensions, onLayout, hasDimensions} = useComponentLayout();
|
const {dimensions, onLayout, hasDimensions} = useComponentLayout();
|
||||||
const [pressed, setPressed] = useState(false);
|
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||||
|
const pressed = useRef(false);
|
||||||
|
|
||||||
const accentColor = getVariantColor(variant, color);
|
const accentColor = getVariantColor(theme, variant, color);
|
||||||
|
const selectedColor = accentColor + 'B3'; // ~70% opacity
|
||||||
const isContained = mode === 'contained';
|
const isContained = mode === 'contained';
|
||||||
const bgColor = isContained ? accentColor : 'transparent';
|
const useBevels = theme.custom.bevelMd > 0;
|
||||||
const textColor = isContained ? DTColors.dark : accentColor;
|
|
||||||
|
|
||||||
const {width, height} = dimensions;
|
const {width, height} = dimensions;
|
||||||
const opacity = disabled ? 0.5 : pressed ? 0.7 : 1;
|
|
||||||
|
// Build SVG paths: rectangle (default) and beveled (active)
|
||||||
|
const rectPath = hasDimensions
|
||||||
|
? buildBeveledRectPath(width, height, {corners: {}, strokeWidth: borderWidth})
|
||||||
|
: '';
|
||||||
|
const bevelPath = useBevels && hasDimensions
|
||||||
|
? buildBeveledRectPath(width, height, {
|
||||||
|
corners: {bottomRight: bevelSize},
|
||||||
|
strokeWidth: borderWidth,
|
||||||
|
})
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Pulse animation for pressed/selected hover
|
||||||
|
useEffect(() => {
|
||||||
|
if (selected) {
|
||||||
|
// No pulse when just selected (matches storefront: animation: none on .selected)
|
||||||
|
pulseAnim.setValue(1);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
pulseAnim.stopAnimation();
|
||||||
|
};
|
||||||
|
}, [selected, pulseAnim]);
|
||||||
|
|
||||||
|
const startPulse = () => {
|
||||||
|
Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(pulseAnim, {
|
||||||
|
toValue: 0.5,
|
||||||
|
duration: 1000,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(pulseAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 1000,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopPulse = () => {
|
||||||
|
pulseAnim.stopAnimation();
|
||||||
|
pulseAnim.setValue(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePressIn = () => {
|
||||||
|
pressed.current = true;
|
||||||
|
startPulse();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePressOut = () => {
|
||||||
|
pressed.current = false;
|
||||||
|
if (!selected) {
|
||||||
|
stopPulse();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine visual state
|
||||||
|
const showBevel = isContained || selected;
|
||||||
|
|
||||||
|
const getBgColor = (isPressed: boolean) => {
|
||||||
|
if (isContained) return accentColor;
|
||||||
|
if (selected) return selectedColor;
|
||||||
|
if (isPressed) return accentColor;
|
||||||
|
return 'transparent';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTextColor = (isPressed: boolean) => {
|
||||||
|
if (isContained || selected || isPressed) return theme.colors.onPrimary;
|
||||||
|
return accentColor;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onPressIn={() => setPressed(true)}
|
onPressIn={handlePressIn}
|
||||||
onPressOut={() => setPressed(false)}
|
onPressOut={handlePressOut}
|
||||||
style={[{opacity}, style]}>
|
style={[{opacity: disabled ? 0.5 : 1}, style]}>
|
||||||
<View style={styles.container} onLayout={onLayout}>
|
{({pressed: isPressedState}) => (
|
||||||
{hasDimensions && (
|
<Animated.View
|
||||||
|
style={[styles.container, {opacity: pulseAnim}]}
|
||||||
|
onLayout={onLayout}>
|
||||||
|
{/* Non-beveled mode (classic brand) */}
|
||||||
|
{!useBevels && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
StyleSheet.absoluteFill,
|
||||||
|
{
|
||||||
|
borderWidth,
|
||||||
|
borderColor: accentColor,
|
||||||
|
borderTopWidth: 5,
|
||||||
|
borderRadius: theme.custom.radiusSm,
|
||||||
|
backgroundColor: getBgColor(isPressedState),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Beveled mode (DT brand) */}
|
||||||
|
{useBevels && hasDimensions && (
|
||||||
<Svg
|
<Svg
|
||||||
style={StyleSheet.absoluteFill}
|
style={StyleSheet.absoluteFill}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
viewBox={`0 0 ${width} ${height}`}>
|
viewBox={`0 0 ${width} ${height}`}>
|
||||||
<Path
|
<Path
|
||||||
d={buildButtonBevelPath(width, height, bevelSize, borderWidth)}
|
d={(isPressedState || showBevel) ? bevelPath : rectPath}
|
||||||
fill={bgColor}
|
fill={getBgColor(isPressedState)}
|
||||||
stroke={accentColor}
|
stroke={accentColor}
|
||||||
strokeWidth={borderWidth}
|
strokeWidth={borderWidth}
|
||||||
/>
|
/>
|
||||||
|
{/* Thick top accent — rendered inside SVG for exact alignment */}
|
||||||
|
<Rect
|
||||||
|
x={borderWidth / 2}
|
||||||
|
y={0}
|
||||||
|
width={width - borderWidth}
|
||||||
|
height={5}
|
||||||
|
fill={accentColor}
|
||||||
|
/>
|
||||||
</Svg>
|
</Svg>
|
||||||
)}
|
)}
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
{typeof children === 'string' ? (
|
{typeof children === 'string' ? (
|
||||||
<Text variant="labelLarge" style={[styles.label, {color: textColor}]}>
|
<Text
|
||||||
|
variant="labelLarge"
|
||||||
|
style={[styles.label, {color: getTextColor(isPressedState)}]}>
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
children
|
children
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</Animated.View>
|
||||||
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -146,6 +243,5 @@ const styles = StyleSheet.create({
|
|||||||
label: {
|
label: {
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
letterSpacing: 1,
|
letterSpacing: 1,
|
||||||
textTransform: 'uppercase',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,13 +8,17 @@
|
|||||||
* - Bottom-right bevel: 2em (~32px)
|
* - Bottom-right bevel: 2em (~32px)
|
||||||
* - Bottom-left bevel: 1em (~16px)
|
* - Bottom-left bevel: 1em (~16px)
|
||||||
* - Border width: 0.2em (~3px)
|
* - Border width: 0.2em (~3px)
|
||||||
|
*
|
||||||
|
* The progress bar is a structural element on the left edge (0 to bevelSizeSmall).
|
||||||
|
* It is always present. At 0 progress it shows surface color (empty bar).
|
||||||
|
* As progress increases, accent color fills from the bottom up.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {ReactNode} from 'react';
|
import {ReactNode} from 'react';
|
||||||
import {StyleSheet, View, ViewStyle, StyleProp, Pressable} from 'react-native';
|
import {StyleSheet, View, ViewStyle, StyleProp, Pressable} from 'react-native';
|
||||||
import {Text} from 'react-native-paper';
|
import {Text} from 'react-native-paper';
|
||||||
import Svg, {Path} from 'react-native-svg';
|
import Svg, {Path} from 'react-native-svg';
|
||||||
import {DTColors} from '../theme/colors';
|
import {useDTTheme} from '../theme/DTThemeProvider';
|
||||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||||
import {buildCardBevelPath} from '../utils/bevelPaths';
|
import {buildCardBevelPath} from '../utils/bevelPaths';
|
||||||
import {useComponentLayout} from '../utils/useComponentLayout';
|
import {useComponentLayout} from '../utils/useComponentLayout';
|
||||||
@@ -39,6 +43,17 @@ interface DTCardProps {
|
|||||||
* @default true when title is provided
|
* @default true when title is provided
|
||||||
*/
|
*/
|
||||||
showHeader?: boolean;
|
showHeader?: boolean;
|
||||||
|
/**
|
||||||
|
* Progress value (0–1) for left-edge vertical progress bar.
|
||||||
|
* Always present. At 0 the bar shows surface color (empty).
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
|
progress?: number;
|
||||||
|
/**
|
||||||
|
* Which area expands when the card grows to fill space
|
||||||
|
* @default 'body'
|
||||||
|
*/
|
||||||
|
growArea?: 'body' | 'title';
|
||||||
/**
|
/**
|
||||||
* Additional styles for the card container
|
* Additional styles for the card container
|
||||||
*/
|
*/
|
||||||
@@ -68,16 +83,127 @@ interface DTCardProps {
|
|||||||
*/
|
*/
|
||||||
bevelSizeSmall?: number;
|
bevelSizeSmall?: number;
|
||||||
/**
|
/**
|
||||||
* Background color
|
* Background color (defaults to theme background)
|
||||||
* @default '#000000'
|
|
||||||
*/
|
*/
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
|
/**
|
||||||
|
* Element rendered at the right edge of the header
|
||||||
|
*/
|
||||||
|
headerRight?: ReactNode;
|
||||||
/**
|
/**
|
||||||
* Press handler
|
* Press handler
|
||||||
*/
|
*/
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build inner card path with left edge at bevelSizeSmall (for progress bar zone).
|
||||||
|
* 5-point polygon — no bottom-left bevel, straight vertical left edge.
|
||||||
|
*/
|
||||||
|
function buildInnerCardPath(
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
bevelBR: number,
|
||||||
|
bw: number,
|
||||||
|
bevelSizeSmall: number,
|
||||||
|
): string {
|
||||||
|
const right = w - bw;
|
||||||
|
const top = bw;
|
||||||
|
const bottom = h - bw;
|
||||||
|
const br = Math.min(
|
||||||
|
bevelBR - bw,
|
||||||
|
(right - bevelSizeSmall) / 3,
|
||||||
|
(bottom - top) / 3,
|
||||||
|
);
|
||||||
|
if (right <= bevelSizeSmall || bottom <= top) return '';
|
||||||
|
return [
|
||||||
|
`M ${bevelSizeSmall} ${top}`,
|
||||||
|
`L ${right} ${top}`,
|
||||||
|
`L ${right} ${bottom - br}`,
|
||||||
|
`L ${right - br} ${bottom}`,
|
||||||
|
`L ${bevelSizeSmall} ${bottom}`,
|
||||||
|
'Z',
|
||||||
|
].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the full progress bar fill area path (inset 3px from frame on all sides).
|
||||||
|
* This is the region where the surface/accent gradient fills.
|
||||||
|
*/
|
||||||
|
function buildProgressAreaPath(
|
||||||
|
cardH: number,
|
||||||
|
bw: number,
|
||||||
|
bevelSizeSmall: number,
|
||||||
|
): string {
|
||||||
|
const left = bw;
|
||||||
|
const right = bevelSizeSmall - bw;
|
||||||
|
const top = bw;
|
||||||
|
const bevelStartY = cardH - bevelSizeSmall;
|
||||||
|
const bottomRight = cardH - bw * 2; // y at bottom-right of fill area (on bevel diagonal)
|
||||||
|
|
||||||
|
if (right <= left || bevelStartY <= top) return '';
|
||||||
|
|
||||||
|
return [
|
||||||
|
`M ${left} ${top}`,
|
||||||
|
`L ${right} ${top}`,
|
||||||
|
`L ${right} ${bottomRight}`,
|
||||||
|
`L ${left} ${bevelStartY}`,
|
||||||
|
'Z',
|
||||||
|
].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the accent-colored portion of the progress bar (fills from bottom).
|
||||||
|
* Returns empty string when progress is 0.
|
||||||
|
*/
|
||||||
|
function buildProgressFillPath(
|
||||||
|
cardH: number,
|
||||||
|
bw: number,
|
||||||
|
bevelSizeSmall: number,
|
||||||
|
progress: number,
|
||||||
|
): string {
|
||||||
|
const p = Math.max(0, Math.min(1, progress));
|
||||||
|
if (p === 0) return '';
|
||||||
|
|
||||||
|
const left = bw;
|
||||||
|
const right = bevelSizeSmall - bw;
|
||||||
|
const top = bw;
|
||||||
|
const bevelStartY = cardH - bevelSizeSmall;
|
||||||
|
const bottomRight = cardH - bw * 2;
|
||||||
|
|
||||||
|
if (right <= left || bevelStartY <= top) return '';
|
||||||
|
|
||||||
|
// Total fill area height (along the left edge, from top to bevelStartY)
|
||||||
|
const areaH = bevelStartY - top;
|
||||||
|
// How much of the area is filled from the bottom
|
||||||
|
const fillTop = top + areaH * (1 - p);
|
||||||
|
|
||||||
|
if (fillTop >= bevelStartY) {
|
||||||
|
// Fill is entirely within the bevel zone
|
||||||
|
// The bevel diagonal goes from (left, bevelStartY) to (right, bottomRight)
|
||||||
|
// At y=fillTop, x on the diagonal: x = left + (fillTop - bevelStartY) * (right - left) / (bottomRight - bevelStartY)
|
||||||
|
const bevelH = bottomRight - bevelStartY;
|
||||||
|
if (bevelH <= 0) return '';
|
||||||
|
const xAtFillTop = left + ((fillTop - bevelStartY) / bevelH) * (right - left);
|
||||||
|
if (xAtFillTop >= right) return '';
|
||||||
|
return [
|
||||||
|
`M ${xAtFillTop} ${fillTop}`,
|
||||||
|
`L ${right} ${fillTop}`,
|
||||||
|
`L ${right} ${bottomRight}`,
|
||||||
|
'Z',
|
||||||
|
].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill extends above the bevel zone — rectangle + bevel triangle
|
||||||
|
return [
|
||||||
|
`M ${left} ${fillTop}`,
|
||||||
|
`L ${right} ${fillTop}`,
|
||||||
|
`L ${right} ${bottomRight}`,
|
||||||
|
`L ${left} ${bevelStartY}`,
|
||||||
|
'Z',
|
||||||
|
].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DT-styled Card component with SVG beveled corners
|
* DT-styled Card component with SVG beveled corners
|
||||||
*
|
*
|
||||||
@@ -97,58 +223,139 @@ export function DTCard({
|
|||||||
borderColor,
|
borderColor,
|
||||||
title,
|
title,
|
||||||
showHeader,
|
showHeader,
|
||||||
|
progress = 0,
|
||||||
|
growArea = 'body',
|
||||||
style,
|
style,
|
||||||
contentStyle,
|
contentStyle,
|
||||||
padding = 16,
|
padding = 16,
|
||||||
borderWidth = 3,
|
borderWidth = 3,
|
||||||
bevelSize = 32,
|
bevelSize = 32,
|
||||||
bevelSizeSmall = 16,
|
bevelSizeSmall = 16,
|
||||||
backgroundColor = '#000000',
|
backgroundColor,
|
||||||
|
headerRight,
|
||||||
onPress,
|
onPress,
|
||||||
}: DTCardProps) {
|
}: DTCardProps) {
|
||||||
|
const theme = useDTTheme();
|
||||||
const {dimensions, onLayout, hasDimensions} = useComponentLayout();
|
const {dimensions, onLayout, hasDimensions} = useComponentLayout();
|
||||||
const accentColor = getVariantColor(mode, borderColor);
|
const accentColor = getVariantColor(theme, mode, borderColor);
|
||||||
const shouldShowHeader = showHeader ?? !!title;
|
const shouldShowHeader = showHeader ?? !!title;
|
||||||
|
const bgColor = backgroundColor ?? theme.colors.background;
|
||||||
|
|
||||||
const {width, height} = dimensions;
|
const {width, height} = dimensions;
|
||||||
|
const useBevels = theme.custom.bevelMd > 0;
|
||||||
|
|
||||||
// Outer bevel path (full card shape)
|
// Outer bevel path (full card shape)
|
||||||
const outerPath = hasDimensions
|
const outerPath = useBevels && hasDimensions
|
||||||
? buildCardBevelPath(width, height, bevelSize, bevelSizeSmall, 0)
|
? buildCardBevelPath(width, height, bevelSize, bevelSizeSmall, 0)
|
||||||
: '';
|
: '';
|
||||||
// Inner bevel path at border inset (for background + frame cutout)
|
// Inner bevel path — left edge at bevelSizeSmall (progress bar zone)
|
||||||
const innerPath = hasDimensions
|
const innerPath = useBevels && hasDimensions
|
||||||
? buildCardBevelPath(width, height, bevelSize - borderWidth, bevelSizeSmall - borderWidth, borderWidth)
|
? buildInnerCardPath(width, height, bevelSize, borderWidth, bevelSizeSmall)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Progress bar paths — computed once, reused in progress SVG and frame overlay
|
||||||
|
const progressAreaPath = useBevels && hasDimensions
|
||||||
|
? buildProgressAreaPath(height, borderWidth, bevelSizeSmall)
|
||||||
|
: '';
|
||||||
|
const progressFillPath = useBevels && hasDimensions && progress > 0
|
||||||
|
? buildProgressFillPath(height, borderWidth, bevelSizeSmall, progress)
|
||||||
|
: '';
|
||||||
|
// Frame overlay: outer + inner + progressArea hole (evenodd punches window for progress bar)
|
||||||
|
const framePath = useBevels && hasDimensions
|
||||||
|
? outerPath + ' ' + innerPath + (progressAreaPath ? ' ' + progressAreaPath : '')
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<View style={[styles.container, style]} onLayout={onLayout}>
|
<View
|
||||||
{/* Background fill (behind content) */}
|
style={[
|
||||||
{hasDimensions && (
|
styles.container,
|
||||||
|
!useBevels && {
|
||||||
|
borderWidth,
|
||||||
|
borderColor: accentColor,
|
||||||
|
borderRadius: theme.custom.radius,
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
onLayout={onLayout}>
|
||||||
|
{/* Background fill (behind content) — beveled mode only */}
|
||||||
|
{useBevels && hasDimensions && (
|
||||||
<Svg
|
<Svg
|
||||||
style={StyleSheet.absoluteFill}
|
style={StyleSheet.absoluteFill}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
viewBox={`0 0 ${width} ${height}`}>
|
viewBox={`0 0 ${width} ${height}`}>
|
||||||
<Path d={innerPath} fill={backgroundColor} />
|
<Path d={innerPath} fill={bgColor} />
|
||||||
</Svg>
|
</Svg>
|
||||||
)}
|
)}
|
||||||
<View style={styles.innerContainer}>
|
{/* Progress bar fill area — beveled mode */}
|
||||||
|
{useBevels && hasDimensions && (
|
||||||
|
<Svg
|
||||||
|
style={[StyleSheet.absoluteFill, {zIndex: 1}]}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
pointerEvents="none">
|
||||||
|
{/* Accent base — visible through the semi-transparent surface above.
|
||||||
|
Matches web where ::after sits over the accent-colored card bg. */}
|
||||||
|
<Path d={progressAreaPath} fill={accentColor} />
|
||||||
|
{/* Surface fill over accent base — 0.6 opacity lets accent bleed through.
|
||||||
|
Matches web --dt-progress-empty-opacity default. */}
|
||||||
|
<Path
|
||||||
|
d={progressAreaPath}
|
||||||
|
fill={bgColor}
|
||||||
|
opacity={0.6}
|
||||||
|
/>
|
||||||
|
{/* Accent fill from bottom (progressed portion) */}
|
||||||
|
{progressFillPath !== '' && (
|
||||||
|
<Path
|
||||||
|
d={progressFillPath}
|
||||||
|
fill={accentColor}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Svg>
|
||||||
|
)}
|
||||||
|
{/* Progress bar — non-beveled (classic) mode */}
|
||||||
|
{!useBevels && (
|
||||||
|
<View style={[styles.progressBar, {
|
||||||
|
width: bevelSizeSmall,
|
||||||
|
left: borderWidth,
|
||||||
|
top: borderWidth,
|
||||||
|
bottom: borderWidth,
|
||||||
|
borderBottomLeftRadius: theme.custom.radius > 0 ? Math.max(0, theme.custom.radius - borderWidth) : 0,
|
||||||
|
}]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.progressFill,
|
||||||
|
{
|
||||||
|
backgroundColor: accentColor,
|
||||||
|
height: `${Math.round(Math.max(0, Math.min(1, progress)) * 100)}%`,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={[styles.innerContainer, useBevels && {paddingLeft: bevelSizeSmall - borderWidth}]}>
|
||||||
{shouldShowHeader && (
|
{shouldShowHeader && (
|
||||||
<View style={[styles.header, {backgroundColor: accentColor}]}>
|
<View style={[styles.header, {backgroundColor: accentColor}, growArea === 'title' && {flex: 1}, !!(title && headerRight) && styles.headerRow]}>
|
||||||
{title && (
|
{title && (
|
||||||
<Text
|
<Text
|
||||||
variant="titleMedium"
|
variant="titleMedium"
|
||||||
style={[styles.headerText, {color: DTColors.dark}]}>
|
style={[styles.headerText, {color: theme.colors.onPrimary}]}>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
{headerRight}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<View style={[styles.content, {padding}, contentStyle]}>{children}</View>
|
<View style={[styles.content, {padding}, growArea === 'body' && {flex: 1}, contentStyle]}>{children}</View>
|
||||||
</View>
|
</View>
|
||||||
{/* Frame overlay (above content) — clips content at beveled border */}
|
{/* Frame overlay (above content) — beveled mode only.
|
||||||
{hasDimensions && (
|
Uses evenodd with 3 sub-paths: outer + inner + progressArea.
|
||||||
|
The progress area path punches a hole in the frame so the
|
||||||
|
progress bar SVG underneath (zIndex:1) shows through. */}
|
||||||
|
{useBevels && hasDimensions && (
|
||||||
<Svg
|
<Svg
|
||||||
style={[StyleSheet.absoluteFill, styles.frameOverlay]}
|
style={[StyleSheet.absoluteFill, styles.frameOverlay]}
|
||||||
width={width}
|
width={width}
|
||||||
@@ -156,7 +363,7 @@ export function DTCard({
|
|||||||
viewBox={`0 0 ${width} ${height}`}
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
pointerEvents="none">
|
pointerEvents="none">
|
||||||
<Path
|
<Path
|
||||||
d={outerPath + ' ' + innerPath}
|
d={framePath}
|
||||||
fillRule="evenodd"
|
fillRule="evenodd"
|
||||||
fill={accentColor}
|
fill={accentColor}
|
||||||
/>
|
/>
|
||||||
@@ -205,11 +412,18 @@ const styles = StyleSheet.create({
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
flex: 1,
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
paddingHorizontal: 16,
|
paddingLeft: 8,
|
||||||
|
paddingRight: 16,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
},
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
headerText: {
|
headerText: {
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
@@ -218,4 +432,17 @@ const styles = StyleSheet.create({
|
|||||||
frameOverlay: {
|
frameOverlay: {
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
},
|
},
|
||||||
|
progressBar: {
|
||||||
|
position: 'absolute' as const,
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
zIndex: 3,
|
||||||
|
justifyContent: 'flex-end' as const,
|
||||||
|
overflow: 'hidden' as const,
|
||||||
|
},
|
||||||
|
progressFill: {
|
||||||
|
width: '100%' as const,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,10 +8,10 @@
|
|||||||
* Uses diagonal-symmetry bevels (top-left + bottom-right) on the indicator.
|
* Uses diagonal-symmetry bevels (top-left + bottom-right) on the indicator.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {StyleSheet, ViewStyle, TextStyle, StyleProp, Pressable} from 'react-native';
|
import {StyleSheet, View, ViewStyle, TextStyle, StyleProp, Pressable} from 'react-native';
|
||||||
import {Text} from 'react-native-paper';
|
import {Text} from 'react-native-paper';
|
||||||
import Svg, {Path} from 'react-native-svg';
|
import Svg, {Path} from 'react-native-svg';
|
||||||
import {DTColors} from '../theme/colors';
|
import {useDTTheme} from '../theme/DTThemeProvider';
|
||||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||||
import {buildBeveledRectPath} from '../utils/bevelPaths';
|
import {buildBeveledRectPath} from '../utils/bevelPaths';
|
||||||
|
|
||||||
@@ -76,16 +76,20 @@ export function DTCheckbox({
|
|||||||
style,
|
style,
|
||||||
labelStyle,
|
labelStyle,
|
||||||
}: DTCheckboxProps) {
|
}: DTCheckboxProps) {
|
||||||
const accentColor = getVariantColor(variant, color);
|
const theme = useDTTheme();
|
||||||
|
const accentColor = getVariantColor(theme, variant, color);
|
||||||
const opacity = disabled ? 0.5 : 1;
|
const opacity = disabled ? 0.5 : 1;
|
||||||
const borderWidth = 2;
|
const borderWidth = 2;
|
||||||
|
const useBevels = theme.custom.bevelMd > 0;
|
||||||
const bevelSize = Math.round(size * 0.3);
|
const bevelSize = Math.round(size * 0.3);
|
||||||
|
|
||||||
// Outer beveled path (border)
|
// Outer beveled path (border)
|
||||||
const outerPath = buildBeveledRectPath(size, size, {
|
const outerPath = useBevels
|
||||||
|
? buildBeveledRectPath(size, size, {
|
||||||
corners: {topLeft: bevelSize, bottomRight: bevelSize},
|
corners: {topLeft: bevelSize, bottomRight: bevelSize},
|
||||||
strokeWidth: borderWidth,
|
strokeWidth: borderWidth,
|
||||||
});
|
})
|
||||||
|
: '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -96,28 +100,44 @@ export function DTCheckbox({
|
|||||||
{opacity: pressed ? 0.7 : opacity},
|
{opacity: pressed ? 0.7 : opacity},
|
||||||
style,
|
style,
|
||||||
]}>
|
]}>
|
||||||
|
{useBevels ? (
|
||||||
<Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
<Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||||
{/* Beveled border */}
|
|
||||||
<Path
|
<Path
|
||||||
d={outerPath}
|
d={outerPath}
|
||||||
fill={checked ? accentColor : 'transparent'}
|
fill={checked ? accentColor : 'transparent'}
|
||||||
stroke={accentColor}
|
stroke={accentColor}
|
||||||
strokeWidth={borderWidth}
|
strokeWidth={borderWidth}
|
||||||
/>
|
/>
|
||||||
{/* Checkmark path (only when checked) */}
|
|
||||||
{checked && (
|
{checked && (
|
||||||
<Path
|
<Path
|
||||||
d={`M ${size * 0.2} ${size * 0.5} L ${size * 0.4} ${size * 0.7} L ${size * 0.8} ${size * 0.25}`}
|
d={`M ${size * 0.2} ${size * 0.5} L ${size * 0.4} ${size * 0.7} L ${size * 0.8} ${size * 0.25}`}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={DTColors.dark}
|
stroke={theme.colors.onPrimary}
|
||||||
strokeWidth={borderWidth + 0.5}
|
strokeWidth={borderWidth + 0.5}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Svg>
|
</Svg>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderWidth,
|
||||||
|
borderColor: accentColor,
|
||||||
|
borderRadius: theme.custom.radiusSm,
|
||||||
|
backgroundColor: checked ? accentColor : 'transparent',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{checked && (
|
||||||
|
<Text style={{color: theme.colors.onPrimary, fontSize: size * 0.6, lineHeight: size * 0.7}}>✓</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
{label && (
|
{label && (
|
||||||
<Text style={[styles.label, {color: DTColors.light}, labelStyle]}>
|
<Text style={[styles.label, {color: theme.colors.onSurface}, labelStyle]}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,16 +8,15 @@
|
|||||||
// React import not needed with new JSX transform
|
// React import not needed with new JSX transform
|
||||||
import { StyleSheet, ViewStyle, StyleProp } from 'react-native';
|
import { StyleSheet, ViewStyle, StyleProp } from 'react-native';
|
||||||
import { Chip, ChipProps } from 'react-native-paper';
|
import { Chip, ChipProps } from 'react-native-paper';
|
||||||
import { DTColors } from '../theme/colors';
|
import { useDTTheme } from '../theme/DTThemeProvider';
|
||||||
|
import { type DTVariant, getVariantColor } from '../utils/variantColors';
|
||||||
type DTChipVariant = 'normal' | 'emphasis' | 'warning' | 'success' | 'other';
|
|
||||||
|
|
||||||
interface DTChipProps extends Omit<ChipProps, 'mode'> {
|
interface DTChipProps extends Omit<ChipProps, 'mode'> {
|
||||||
/**
|
/**
|
||||||
* Visual variant of the chip
|
* Visual variant of the chip
|
||||||
* @default 'normal'
|
* @default 'normal'
|
||||||
*/
|
*/
|
||||||
variant?: DTChipVariant;
|
variant?: DTVariant;
|
||||||
/**
|
/**
|
||||||
* Whether the chip is in selected state
|
* Whether the chip is in selected state
|
||||||
* @default false
|
* @default false
|
||||||
@@ -29,14 +28,6 @@ interface DTChipProps extends Omit<ChipProps, 'mode'> {
|
|||||||
style?: StyleProp<ViewStyle>;
|
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
|
* DT-styled Chip component
|
||||||
*
|
*
|
||||||
@@ -56,7 +47,8 @@ export function DTChip({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: DTChipProps) {
|
}: DTChipProps) {
|
||||||
const color = variantColors[variant];
|
const theme = useDTTheme();
|
||||||
|
const color = getVariantColor(theme, variant);
|
||||||
|
|
||||||
const chipStyle: ViewStyle = {
|
const chipStyle: ViewStyle = {
|
||||||
backgroundColor: selected ? color : 'transparent',
|
backgroundColor: selected ? color : 'transparent',
|
||||||
@@ -69,9 +61,10 @@ export function DTChip({
|
|||||||
<Chip
|
<Chip
|
||||||
{...props}
|
{...props}
|
||||||
mode="outlined"
|
mode="outlined"
|
||||||
|
showSelectedCheck={false}
|
||||||
textStyle={[
|
textStyle={[
|
||||||
styles.text,
|
styles.text,
|
||||||
{ color: selected ? DTColors.dark : color },
|
{ color: selected ? theme.colors.onPrimary : color },
|
||||||
]}
|
]}
|
||||||
style={[styles.chip, chipStyle, style]}
|
style={[styles.chip, chipStyle, style]}
|
||||||
selectedColor={color}
|
selectedColor={color}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
import {Portal, Text} from 'react-native-paper';
|
import {Portal, Text} from 'react-native-paper';
|
||||||
import Svg, {Path} from 'react-native-svg';
|
import Svg, {Path} from 'react-native-svg';
|
||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||||
import {DTColors} from '../theme/colors';
|
import {useDTTheme} from '../theme/DTThemeProvider';
|
||||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||||
import {buildDrawerBevelPath} from '../utils/bevelPaths';
|
import {buildDrawerBevelPath} from '../utils/bevelPaths';
|
||||||
|
|
||||||
@@ -108,9 +108,11 @@ export function DTDrawer({
|
|||||||
children,
|
children,
|
||||||
style,
|
style,
|
||||||
}: DTDrawerProps) {
|
}: DTDrawerProps) {
|
||||||
|
const theme = useDTTheme();
|
||||||
|
const useBevels = theme.custom.bevelMd > 0;
|
||||||
const slideAnim = useRef(new Animated.Value(0)).current;
|
const slideAnim = useRef(new Animated.Value(0)).current;
|
||||||
const overlayAnim = useRef(new Animated.Value(0)).current;
|
const overlayAnim = useRef(new Animated.Value(0)).current;
|
||||||
const headerColor = getVariantColor(headingVariant);
|
const headerColor = getVariantColor(theme, headingVariant);
|
||||||
const {width: screenWidth, height: screenHeight} = useWindowDimensions();
|
const {width: screenWidth, height: screenHeight} = useWindowDimensions();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const effectiveWidth = Math.min(drawerWidth, screenWidth);
|
const effectiveWidth = Math.min(drawerWidth, screenWidth);
|
||||||
@@ -172,7 +174,7 @@ export function DTDrawer({
|
|||||||
{/* Overlay backdrop */}
|
{/* Overlay backdrop */}
|
||||||
<Animated.View style={[StyleSheet.absoluteFill, {opacity: overlayAnim}]}>
|
<Animated.View style={[StyleSheet.absoluteFill, {opacity: overlayAnim}]}>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={[StyleSheet.absoluteFill, styles.overlay]}
|
style={[StyleSheet.absoluteFill, {backgroundColor: theme.colors.backdrop}]}
|
||||||
onPress={onDismiss}
|
onPress={onDismiss}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -183,12 +185,19 @@ export function DTDrawer({
|
|||||||
styles.panel,
|
styles.panel,
|
||||||
position === 'right' ? styles.panelRight : styles.panelLeft,
|
position === 'right' ? styles.panelRight : styles.panelLeft,
|
||||||
{width: effectiveWidth, transform: [{translateX}]},
|
{width: effectiveWidth, transform: [{translateX}]},
|
||||||
|
!useBevels && {
|
||||||
|
borderWidth,
|
||||||
|
borderColor: headerColor,
|
||||||
|
borderRadius: theme.custom.radius,
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
style,
|
style,
|
||||||
]}>
|
]}>
|
||||||
{/* Dual-SVG-path technique: outer border + inner dark fill */}
|
{/* Dual-SVG-path technique: outer border + inner dark fill — beveled mode only */}
|
||||||
|
{useBevels && (
|
||||||
<View style={StyleSheet.absoluteFill}>
|
<View style={StyleSheet.absoluteFill}>
|
||||||
<Svg width={effectiveWidth} height={screenHeight}>
|
<Svg width={effectiveWidth} height={screenHeight}>
|
||||||
{/* Outer path: accent border color */}
|
|
||||||
<Path
|
<Path
|
||||||
d={buildDrawerBevelPath(
|
d={buildDrawerBevelPath(
|
||||||
effectiveWidth,
|
effectiveWidth,
|
||||||
@@ -198,7 +207,6 @@ export function DTDrawer({
|
|||||||
)}
|
)}
|
||||||
fill={headerColor}
|
fill={headerColor}
|
||||||
/>
|
/>
|
||||||
{/* Inner path: dark background (inset by borderWidth) */}
|
|
||||||
<Path
|
<Path
|
||||||
d={buildDrawerBevelPath(
|
d={buildDrawerBevelPath(
|
||||||
effectiveWidth - borderWidth * 2,
|
effectiveWidth - borderWidth * 2,
|
||||||
@@ -206,19 +214,20 @@ export function DTDrawer({
|
|||||||
bevelSize - borderWidth,
|
bevelSize - borderWidth,
|
||||||
position,
|
position,
|
||||||
)}
|
)}
|
||||||
fill={DTColors.dark}
|
fill={theme.colors.background}
|
||||||
transform={`translate(${borderWidth}, ${borderWidth})`}
|
transform={`translate(${borderWidth}, ${borderWidth})`}
|
||||||
/>
|
/>
|
||||||
</Svg>
|
</Svg>
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Header bar */}
|
{/* Header bar */}
|
||||||
<View style={[styles.header, {backgroundColor: headerColor, paddingTop: insets.top + 16}]}>
|
<View style={[styles.header, {backgroundColor: headerColor, paddingTop: insets.top + 16}]}>
|
||||||
<Text style={styles.headerText}>{heading}</Text>
|
<Text style={[styles.headerText, {color: theme.colors.onPrimary}]}>{heading}</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onDismiss}
|
onPress={onDismiss}
|
||||||
style={({pressed}) => ({opacity: pressed ? 0.7 : 1})}>
|
style={({pressed}) => ({opacity: pressed ? 0.7 : 1})}>
|
||||||
<Text style={styles.closeButton}>✕</Text>
|
<Text style={[styles.closeButton, {color: theme.colors.onPrimary}]}>✕</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -235,9 +244,6 @@ export function DTDrawer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
overlay: {
|
|
||||||
backgroundColor: DTColors.overlay,
|
|
||||||
},
|
|
||||||
panel: {
|
panel: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
@@ -258,13 +264,11 @@ const styles = StyleSheet.create({
|
|||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
},
|
},
|
||||||
headerText: {
|
headerText: {
|
||||||
color: DTColors.dark,
|
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
},
|
},
|
||||||
closeButton: {
|
closeButton: {
|
||||||
color: DTColors.dark,
|
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 8,
|
||||||
|
|||||||
135
packages/react-native/src/components/DTFeatureLegend.tsx
Normal file
135
packages/react-native/src/components/DTFeatureLegend.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* DT Feature Legend
|
||||||
|
*
|
||||||
|
* Displays product features in a grid with icons and rotated labels.
|
||||||
|
* Source: dt-shopify-storefront UseCaseLegend component.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {ReactNode} from 'react';
|
||||||
|
import {StyleSheet, View, ViewStyle, StyleProp} from 'react-native';
|
||||||
|
import {Text} from 'react-native-paper';
|
||||||
|
import {useDTTheme} from '../theme/DTThemeProvider';
|
||||||
|
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||||
|
|
||||||
|
export interface DTFeatureItem {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
state: 'supported' | 'disabled' | 'unsupported';
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateToVariant: Record<DTFeatureItem['state'], DTVariant> = {
|
||||||
|
supported: 'normal',
|
||||||
|
disabled: 'emphasis',
|
||||||
|
unsupported: 'warning',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DTFeatureLegendProps {
|
||||||
|
features: DTFeatureItem[];
|
||||||
|
/**
|
||||||
|
* Header title text
|
||||||
|
*/
|
||||||
|
title?: string;
|
||||||
|
/**
|
||||||
|
* Color variant for the header bar
|
||||||
|
* @default 'normal'
|
||||||
|
*/
|
||||||
|
variant?: DTVariant;
|
||||||
|
/**
|
||||||
|
* Number of columns in the grid
|
||||||
|
* @default 5
|
||||||
|
*/
|
||||||
|
columns?: number;
|
||||||
|
/**
|
||||||
|
* Icon size in pixels
|
||||||
|
* @default 42
|
||||||
|
*/
|
||||||
|
iconSize?: number;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DTFeatureLegend({
|
||||||
|
features,
|
||||||
|
title,
|
||||||
|
variant = 'normal',
|
||||||
|
columns = 5,
|
||||||
|
iconSize = 42,
|
||||||
|
style,
|
||||||
|
}: DTFeatureLegendProps) {
|
||||||
|
const theme = useDTTheme();
|
||||||
|
const headerColor = getVariantColor(theme, variant);
|
||||||
|
const itemWidth = `${100 / columns}%` as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, style]}>
|
||||||
|
{title && (
|
||||||
|
<View style={[styles.header, {backgroundColor: headerColor}]}>
|
||||||
|
<Text
|
||||||
|
variant="labelLarge"
|
||||||
|
style={[styles.headerText, {color: theme.colors.onPrimary}]}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={styles.grid}>
|
||||||
|
{features.map(feature => {
|
||||||
|
const featureColor = getVariantColor(
|
||||||
|
theme,
|
||||||
|
stateToVariant[feature.state],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={feature.key}
|
||||||
|
style={[styles.item, {width: itemWidth as unknown as number}]}>
|
||||||
|
<View style={[styles.iconContainer, {width: iconSize, height: iconSize}]}>
|
||||||
|
{feature.icon}
|
||||||
|
</View>
|
||||||
|
<View style={styles.labelContainer}>
|
||||||
|
<Text
|
||||||
|
style={[styles.label, {color: featureColor}]}>
|
||||||
|
{feature.name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
headerText: {
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
},
|
||||||
|
item: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
labelContainer: {
|
||||||
|
paddingTop: 4,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '600',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
Platform,
|
Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import Svg, {Path} from 'react-native-svg';
|
import Svg, {Path} from 'react-native-svg';
|
||||||
import {DTColors} from '../theme/colors';
|
import {useDTTheme} from '../theme/DTThemeProvider';
|
||||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||||
import {buildButtonBevelPath, buildMediaFrameBevelPath} from '../utils/bevelPaths';
|
import {buildButtonBevelPath, buildMediaFrameBevelPath} from '../utils/bevelPaths';
|
||||||
import {useComponentLayout} from '../utils/useComponentLayout';
|
import {useComponentLayout} from '../utils/useComponentLayout';
|
||||||
@@ -79,6 +79,11 @@ interface DTGalleryProps {
|
|||||||
* @default 3
|
* @default 3
|
||||||
*/
|
*/
|
||||||
borderWidth?: number;
|
borderWidth?: number;
|
||||||
|
/**
|
||||||
|
* Aspect ratio (width / height) for the display area
|
||||||
|
* @default 1
|
||||||
|
*/
|
||||||
|
aspectRatio?: number;
|
||||||
/**
|
/**
|
||||||
* Additional styles
|
* Additional styles
|
||||||
*/
|
*/
|
||||||
@@ -108,11 +113,13 @@ export function DTGallery({
|
|||||||
thumbnailSize = 64,
|
thumbnailSize = 64,
|
||||||
bevelSize = 24,
|
bevelSize = 24,
|
||||||
borderWidth = 3,
|
borderWidth = 3,
|
||||||
|
aspectRatio = 1,
|
||||||
style,
|
style,
|
||||||
}: DTGalleryProps) {
|
}: DTGalleryProps) {
|
||||||
|
const theme = useDTTheme();
|
||||||
const flatListRef = useRef<FlatList>(null);
|
const flatListRef = useRef<FlatList>(null);
|
||||||
const {dimensions, onLayout, hasDimensions} = useComponentLayout();
|
const {dimensions, onLayout, hasDimensions} = useComponentLayout();
|
||||||
const accentColor = getVariantColor(variant);
|
const accentColor = getVariantColor(theme, variant);
|
||||||
const atStart = activeIndex <= 0;
|
const atStart = activeIndex <= 0;
|
||||||
const atEnd = activeIndex >= items.length - 1;
|
const atEnd = activeIndex >= items.length - 1;
|
||||||
|
|
||||||
@@ -171,6 +178,7 @@ export function DTGallery({
|
|||||||
style={({pressed}) => ({
|
style={({pressed}) => ({
|
||||||
opacity: isDisabled ? 0.3 : pressed ? 0.7 : 1,
|
opacity: isDisabled ? 0.3 : pressed ? 0.7 : 1,
|
||||||
})}>
|
})}>
|
||||||
|
{useBevels ? (
|
||||||
<Svg width={ARROW_SIZE} height={ARROW_SIZE} viewBox={`0 0 ${ARROW_SIZE} ${ARROW_SIZE}`}>
|
<Svg width={ARROW_SIZE} height={ARROW_SIZE} viewBox={`0 0 ${ARROW_SIZE} ${ARROW_SIZE}`}>
|
||||||
<Path
|
<Path
|
||||||
d={buildButtonBevelPath(ARROW_SIZE, ARROW_SIZE, ARROW_BEVEL, ARROW_BORDER)}
|
d={buildButtonBevelPath(ARROW_SIZE, ARROW_SIZE, ARROW_BEVEL, ARROW_BORDER)}
|
||||||
@@ -187,6 +195,28 @@ export function DTGallery({
|
|||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
</Svg>
|
</Svg>
|
||||||
|
) : (
|
||||||
|
<View style={{
|
||||||
|
width: ARROW_SIZE,
|
||||||
|
height: ARROW_SIZE,
|
||||||
|
borderWidth: ARROW_BORDER,
|
||||||
|
borderColor: accentColor,
|
||||||
|
borderRadius: theme.custom.radiusSm,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<Svg width={ARROW_SIZE * 0.6} height={ARROW_SIZE * 0.6} viewBox={`${ARROW_SIZE * 0.2} ${ARROW_SIZE * 0.2} ${ARROW_SIZE * 0.6} ${ARROW_SIZE * 0.6}`}>
|
||||||
|
<Path
|
||||||
|
d={iconPath}
|
||||||
|
fill="none"
|
||||||
|
stroke={accentColor}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -204,6 +234,7 @@ export function DTGallery({
|
|||||||
width: thumbnailSize,
|
width: thumbnailSize,
|
||||||
height: thumbnailSize,
|
height: thumbnailSize,
|
||||||
borderColor: isActive ? accentColor : 'transparent',
|
borderColor: isActive ? accentColor : 'transparent',
|
||||||
|
borderRadius: useBevels ? 0 : theme.custom.radiusSm,
|
||||||
},
|
},
|
||||||
isActive && styles.thumbnailActive,
|
isActive && styles.thumbnailActive,
|
||||||
]}>
|
]}>
|
||||||
@@ -235,30 +266,42 @@ export function DTGallery({
|
|||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
const {width, height} = dimensions;
|
const {width, height} = dimensions;
|
||||||
|
const useBevels = theme.custom.bevelMd > 0;
|
||||||
|
|
||||||
// Outer and inner bevel paths for frame overlay
|
// Outer and inner bevel paths for frame overlay
|
||||||
const outerPath = hasDimensions
|
const outerPath = useBevels && hasDimensions
|
||||||
? buildMediaFrameBevelPath(width, height, bevelSize)
|
? buildMediaFrameBevelPath(width, height, bevelSize)
|
||||||
: '';
|
: '';
|
||||||
const innerPath = hasDimensions
|
const innerPath = useBevels && hasDimensions
|
||||||
? buildMediaFrameBevelPath(width, height, bevelSize - borderWidth, borderWidth)
|
? buildMediaFrameBevelPath(width, height, bevelSize - borderWidth, borderWidth)
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, style]}>
|
<View style={[styles.container, style]}>
|
||||||
{/* Main image with beveled frame */}
|
{/* Main image with beveled frame */}
|
||||||
<View style={styles.mainImageContainer} onLayout={onLayout}>
|
<View
|
||||||
{/* Background fill */}
|
style={[
|
||||||
{hasDimensions && (
|
styles.mainImageContainer,
|
||||||
|
{ aspectRatio },
|
||||||
|
!useBevels && {
|
||||||
|
borderWidth,
|
||||||
|
borderColor: accentColor,
|
||||||
|
borderRadius: theme.custom.radius,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onLayout={onLayout}>
|
||||||
|
{/* Background fill — beveled mode only */}
|
||||||
|
{useBevels && hasDimensions && (
|
||||||
<Svg
|
<Svg
|
||||||
style={StyleSheet.absoluteFill}
|
style={StyleSheet.absoluteFill}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
viewBox={`0 0 ${width} ${height}`}>
|
viewBox={`0 0 ${width} ${height}`}>
|
||||||
<Path d={innerPath} fill={DTColors.surfaceVariant} />
|
<Path d={innerPath} fill={theme.colors.surfaceVariant} />
|
||||||
</Svg>
|
</Svg>
|
||||||
)}
|
)}
|
||||||
<View style={[styles.mainImageContent, {padding: borderWidth}]}>
|
<View style={[styles.mainImageContent, {padding: useBevels ? borderWidth : 0}]}>
|
||||||
<Image
|
<Image
|
||||||
source={{uri: items[activeIndex]?.uri}}
|
source={{uri: items[activeIndex]?.uri}}
|
||||||
style={styles.mainImage}
|
style={styles.mainImage}
|
||||||
@@ -266,21 +309,19 @@ export function DTGallery({
|
|||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{/* Frame overlay (above content) — clips image at beveled border */}
|
{/* Frame overlay — beveled mode only */}
|
||||||
{hasDimensions && (
|
{useBevels && hasDimensions && (
|
||||||
<Svg
|
<Svg
|
||||||
style={[StyleSheet.absoluteFill, styles.frameOverlay]}
|
style={[StyleSheet.absoluteFill, styles.frameOverlay]}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
viewBox={`0 0 ${width} ${height}`}
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
pointerEvents="none">
|
pointerEvents="none">
|
||||||
{/* Corner mask: fill area outside outer bevel with dark to hide overflow */}
|
|
||||||
<Path
|
<Path
|
||||||
d={`M 0 0 L ${width} 0 L ${width} ${height} L 0 ${height} Z ` + outerPath}
|
d={`M 0 0 L ${width} 0 L ${width} ${height} L 0 ${height} Z ` + outerPath}
|
||||||
fillRule="evenodd"
|
fillRule="evenodd"
|
||||||
fill={DTColors.dark}
|
fill={theme.colors.background}
|
||||||
/>
|
/>
|
||||||
{/* Colored border between outer and inner bevel paths */}
|
|
||||||
<Path
|
<Path
|
||||||
d={outerPath + ' ' + innerPath}
|
d={outerPath + ' ' + innerPath}
|
||||||
fillRule="evenodd"
|
fillRule="evenodd"
|
||||||
@@ -321,7 +362,6 @@ const styles = StyleSheet.create({
|
|||||||
gap: 12,
|
gap: 12,
|
||||||
},
|
},
|
||||||
mainImageContainer: {
|
mainImageContainer: {
|
||||||
aspectRatio: 1,
|
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
},
|
},
|
||||||
mainImageContent: {
|
mainImageContent: {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
import {useRef, useEffect} from 'react';
|
import {useRef, useEffect} from 'react';
|
||||||
import {StyleSheet, View, ViewStyle, StyleProp, Animated} from 'react-native';
|
import {StyleSheet, View, ViewStyle, StyleProp, Animated} from 'react-native';
|
||||||
import Svg, {Polygon} from 'react-native-svg';
|
import Svg, {Polygon} from 'react-native-svg';
|
||||||
|
import {useDTTheme} from '../theme/DTThemeProvider';
|
||||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||||
|
|
||||||
interface DTHexagonProps {
|
interface DTHexagonProps {
|
||||||
@@ -112,7 +113,8 @@ export function DTHexagon({
|
|||||||
style,
|
style,
|
||||||
children,
|
children,
|
||||||
}: DTHexagonProps) {
|
}: DTHexagonProps) {
|
||||||
const accentColor = getVariantColor(variant, color);
|
const theme = useDTTheme();
|
||||||
|
const accentColor = getVariantColor(theme, variant, color);
|
||||||
const rotateAnim = useRef(new Animated.Value(0)).current;
|
const rotateAnim = useRef(new Animated.Value(0)).current;
|
||||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {ReactNode, useEffect, useRef} from 'react';
|
|||||||
import {StyleSheet, View, ViewStyle, StyleProp, Animated} from 'react-native';
|
import {StyleSheet, View, ViewStyle, StyleProp, Animated} from 'react-native';
|
||||||
import {Text} from 'react-native-paper';
|
import {Text} from 'react-native-paper';
|
||||||
import Svg, {Path} from 'react-native-svg';
|
import Svg, {Path} from 'react-native-svg';
|
||||||
import {DTColors} from '../theme/colors';
|
import {useDTTheme} from '../theme/DTThemeProvider';
|
||||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||||
import {buildLabelBevelPath} from '../utils/bevelPaths';
|
import {buildLabelBevelPath} from '../utils/bevelPaths';
|
||||||
import {useComponentLayout} from '../utils/useComponentLayout';
|
import {useComponentLayout} from '../utils/useComponentLayout';
|
||||||
@@ -150,12 +150,14 @@ export function DTLabel({
|
|||||||
bevelSize,
|
bevelSize,
|
||||||
style,
|
style,
|
||||||
}: DTLabelProps) {
|
}: DTLabelProps) {
|
||||||
|
const theme = useDTTheme();
|
||||||
const {dimensions, onLayout, hasDimensions} = useComponentLayout();
|
const {dimensions, onLayout, hasDimensions} = useComponentLayout();
|
||||||
const scaleAnim = useRef(new Animated.Value(animated ? 0 : 1)).current;
|
const scaleAnim = useRef(new Animated.Value(animated ? 0 : 1)).current;
|
||||||
const pingAnim = useRef(new Animated.Value(1)).current;
|
const pingAnim = useRef(new Animated.Value(1)).current;
|
||||||
const backgroundColor = getVariantColor(mode);
|
const backgroundColor = getVariantColor(theme, mode);
|
||||||
const sizeConfig = sizeConfigs[size];
|
const sizeConfig = sizeConfigs[size];
|
||||||
const effectiveBevelSize = bevelSize ?? sizeConfig.bevel;
|
const effectiveBevelSize = bevelSize ?? sizeConfig.bevel;
|
||||||
|
const onPrimaryColor = theme.colors.onPrimary;
|
||||||
|
|
||||||
// Mount animation
|
// Mount animation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -196,6 +198,7 @@ export function DTLabel({
|
|||||||
}, [showIndicator, pingAnim]);
|
}, [showIndicator, pingAnim]);
|
||||||
|
|
||||||
const {width, height} = dimensions;
|
const {width, height} = dimensions;
|
||||||
|
const useBevels = theme.custom.bevelMd > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
@@ -203,10 +206,14 @@ export function DTLabel({
|
|||||||
styles.container,
|
styles.container,
|
||||||
fullWidth && styles.fullWidth,
|
fullWidth && styles.fullWidth,
|
||||||
{transform: [{scale: scaleAnim}]},
|
{transform: [{scale: scaleAnim}]},
|
||||||
|
!useBevels && {
|
||||||
|
backgroundColor,
|
||||||
|
borderRadius: theme.custom.radiusSm,
|
||||||
|
},
|
||||||
style,
|
style,
|
||||||
]}
|
]}
|
||||||
onLayout={onLayout}>
|
onLayout={onLayout}>
|
||||||
{hasDimensions && (
|
{useBevels && hasDimensions && (
|
||||||
<Svg
|
<Svg
|
||||||
style={StyleSheet.absoluteFill}
|
style={StyleSheet.absoluteFill}
|
||||||
width={width}
|
width={width}
|
||||||
@@ -228,7 +235,7 @@ export function DTLabel({
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.primaryText,
|
styles.primaryText,
|
||||||
{fontSize: sizeConfig.fontSize},
|
{fontSize: sizeConfig.fontSize, color: onPrimaryColor},
|
||||||
]}>
|
]}>
|
||||||
{primaryText}
|
{primaryText}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -236,7 +243,7 @@ export function DTLabel({
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.secondaryText,
|
styles.secondaryText,
|
||||||
{fontSize: sizeConfig.fontSizeSecondary},
|
{fontSize: sizeConfig.fontSizeSecondary, color: onPrimaryColor},
|
||||||
]}>
|
]}>
|
||||||
{secondaryText}
|
{secondaryText}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -251,7 +258,7 @@ export function DTLabel({
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.indicatorLine,
|
styles.indicatorLine,
|
||||||
{borderColor: DTColors.dark, width: sizeConfig.indicatorWidth},
|
{borderColor: onPrimaryColor, width: sizeConfig.indicatorWidth},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -299,11 +306,9 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
primaryText: {
|
primaryText: {
|
||||||
color: DTColors.dark,
|
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
secondaryText: {
|
secondaryText: {
|
||||||
color: DTColors.dark,
|
|
||||||
fontWeight: '800',
|
fontWeight: '800',
|
||||||
},
|
},
|
||||||
indicator: {},
|
indicator: {},
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
import {ReactNode, useRef, useEffect} from 'react';
|
import {ReactNode, useRef, useEffect} from 'react';
|
||||||
import {StyleSheet, View, ViewStyle, StyleProp, Animated} from 'react-native';
|
import {StyleSheet, View, ViewStyle, StyleProp, Animated} from 'react-native';
|
||||||
import Svg, {Path, Defs, ClipPath} from 'react-native-svg';
|
import Svg, {Path, Defs, ClipPath} from 'react-native-svg';
|
||||||
import {DTColors} from '../theme/colors';
|
import {useDTTheme} from '../theme/DTThemeProvider';
|
||||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||||
import {buildMediaFrameBevelPath} from '../utils/bevelPaths';
|
import {buildMediaFrameBevelPath} from '../utils/bevelPaths';
|
||||||
import {useComponentLayout} from '../utils/useComponentLayout';
|
import {useComponentLayout} from '../utils/useComponentLayout';
|
||||||
@@ -91,9 +91,10 @@ export function DTMediaFrame({
|
|||||||
style,
|
style,
|
||||||
contentStyle,
|
contentStyle,
|
||||||
}: DTMediaFrameProps) {
|
}: DTMediaFrameProps) {
|
||||||
|
const theme = useDTTheme();
|
||||||
const {dimensions, onLayout, hasDimensions} = useComponentLayout();
|
const {dimensions, onLayout, hasDimensions} = useComponentLayout();
|
||||||
const scaleAnim = useRef(new Animated.Value(animated ? 0 : 1)).current;
|
const scaleAnim = useRef(new Animated.Value(animated ? 0 : 1)).current;
|
||||||
const accentColor = getVariantColor(variant, color);
|
const accentColor = getVariantColor(theme, variant, color);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (animated) {
|
if (animated) {
|
||||||
@@ -107,13 +108,14 @@ export function DTMediaFrame({
|
|||||||
}, [animated, animationDelay, scaleAnim]);
|
}, [animated, animationDelay, scaleAnim]);
|
||||||
|
|
||||||
const {width, height} = dimensions;
|
const {width, height} = dimensions;
|
||||||
|
const useBevels = theme.custom.bevelMd > 0;
|
||||||
|
|
||||||
// Outer bevel path (full frame shape)
|
// Outer bevel path (full frame shape)
|
||||||
const outerPath = hasDimensions
|
const outerPath = useBevels && hasDimensions
|
||||||
? buildMediaFrameBevelPath(width, height, bevelSize)
|
? buildMediaFrameBevelPath(width, height, bevelSize)
|
||||||
: '';
|
: '';
|
||||||
// Inner bevel path at border inset
|
// Inner bevel path at border inset
|
||||||
const innerPath = hasDimensions
|
const innerPath = useBevels && hasDimensions
|
||||||
? buildMediaFrameBevelPath(width, height, bevelSize - borderWidth, borderWidth)
|
? buildMediaFrameBevelPath(width, height, bevelSize - borderWidth, borderWidth)
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
@@ -123,21 +125,27 @@ export function DTMediaFrame({
|
|||||||
styles.container,
|
styles.container,
|
||||||
aspectRatio ? {aspectRatio} : undefined,
|
aspectRatio ? {aspectRatio} : undefined,
|
||||||
{transform: [{scale: scaleAnim}]},
|
{transform: [{scale: scaleAnim}]},
|
||||||
|
!useBevels && {
|
||||||
|
borderWidth,
|
||||||
|
borderColor: accentColor,
|
||||||
|
borderRadius: theme.custom.radius,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
style,
|
style,
|
||||||
]}
|
]}
|
||||||
onLayout={onLayout}>
|
onLayout={onLayout}>
|
||||||
{/* Background fill (behind content) */}
|
{/* Background fill (behind content) — beveled mode only */}
|
||||||
{hasDimensions && (
|
{useBevels && hasDimensions && (
|
||||||
<Svg
|
<Svg
|
||||||
style={StyleSheet.absoluteFill}
|
style={StyleSheet.absoluteFill}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
viewBox={`0 0 ${width} ${height}`}>
|
viewBox={`0 0 ${width} ${height}`}>
|
||||||
<Path d={innerPath} fill={DTColors.dark} />
|
<Path d={innerPath} fill={theme.colors.background} />
|
||||||
</Svg>
|
</Svg>
|
||||||
)}
|
)}
|
||||||
{/* Content clipped to the inner bevel shape */}
|
{/* Content */}
|
||||||
{hasDimensions ? (
|
{useBevels && hasDimensions ? (
|
||||||
<View style={[styles.content, contentStyle]}>
|
<View style={[styles.content, contentStyle]}>
|
||||||
<Svg
|
<Svg
|
||||||
width={width}
|
width={width}
|
||||||
@@ -155,7 +163,6 @@ export function DTMediaFrame({
|
|||||||
StyleSheet.absoluteFill,
|
StyleSheet.absoluteFill,
|
||||||
{
|
{
|
||||||
margin: borderWidth,
|
margin: borderWidth,
|
||||||
// Use borderRadius 0 to keep angular, overflow hidden clips rectangular part
|
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
]}>
|
]}>
|
||||||
@@ -163,30 +170,27 @@ export function DTMediaFrame({
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={[styles.content, {padding: borderWidth}, contentStyle]}>
|
<View style={[styles.content, !useBevels ? undefined : {padding: borderWidth}, contentStyle]}>
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{/* Frame overlay (above content) — masks content at beveled corners */}
|
{/* Frame overlay (above content) — beveled mode only */}
|
||||||
{hasDimensions && (
|
{useBevels && hasDimensions && (
|
||||||
<Svg
|
<Svg
|
||||||
style={[StyleSheet.absoluteFill, styles.frameOverlay]}
|
style={[StyleSheet.absoluteFill, styles.frameOverlay]}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
viewBox={`0 0 ${width} ${height}`}
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
pointerEvents="none">
|
pointerEvents="none">
|
||||||
{/* Outer frame border */}
|
|
||||||
<Path
|
<Path
|
||||||
d={outerPath + ' ' + innerPath}
|
d={outerPath + ' ' + innerPath}
|
||||||
fillRule="evenodd"
|
fillRule="evenodd"
|
||||||
fill={accentColor}
|
fill={accentColor}
|
||||||
/>
|
/>
|
||||||
{/* Corner masks: fill the rectangular area outside the bevel shape with black
|
|
||||||
to hide content that overflows past the beveled corners */}
|
|
||||||
<Path
|
<Path
|
||||||
d={`M 0 0 L ${width} 0 L ${width} ${height} L 0 ${height} Z ` + outerPath}
|
d={`M 0 0 L ${width} 0 L ${width} ${height} L 0 ${height} Z ` + outerPath}
|
||||||
fillRule="evenodd"
|
fillRule="evenodd"
|
||||||
fill={DTColors.dark}
|
fill={theme.colors.background}
|
||||||
/>
|
/>
|
||||||
</Svg>
|
</Svg>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
Animated,
|
Animated,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import {Menu, Text} from 'react-native-paper';
|
import {Menu, Text} from 'react-native-paper';
|
||||||
import {DTColors} from '../theme/colors';
|
import {useDTTheme} from '../theme/DTThemeProvider';
|
||||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||||
|
|
||||||
export interface DTMenuItem {
|
export interface DTMenuItem {
|
||||||
@@ -163,7 +163,8 @@ export function DTMenuDropdown({
|
|||||||
onDismiss,
|
onDismiss,
|
||||||
style,
|
style,
|
||||||
}: DTMenuDropdownProps) {
|
}: DTMenuDropdownProps) {
|
||||||
const accentColor = getVariantColor(variant);
|
const theme = useDTTheme();
|
||||||
|
const accentColor = getVariantColor(theme, variant);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu
|
<Menu
|
||||||
@@ -173,12 +174,12 @@ export function DTMenuDropdown({
|
|||||||
anchorPosition="bottom"
|
anchorPosition="bottom"
|
||||||
contentStyle={[
|
contentStyle={[
|
||||||
styles.dropdownContent,
|
styles.dropdownContent,
|
||||||
{borderColor: accentColor},
|
{borderColor: accentColor, backgroundColor: theme.colors.background},
|
||||||
style,
|
style,
|
||||||
]}
|
]}
|
||||||
theme={{
|
theme={{
|
||||||
colors: {
|
colors: {
|
||||||
elevation: {level2: DTColors.dark},
|
elevation: {level2: theme.colors.background},
|
||||||
},
|
},
|
||||||
}}>
|
}}>
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
@@ -189,7 +190,7 @@ export function DTMenuDropdown({
|
|||||||
item.onPress?.();
|
item.onPress?.();
|
||||||
onDismiss();
|
onDismiss();
|
||||||
}}
|
}}
|
||||||
titleStyle={[styles.dropdownItemTitle, {color: DTColors.light}]}
|
titleStyle={[styles.dropdownItemTitle, {color: theme.colors.onSurface}]}
|
||||||
style={styles.dropdownItem}
|
style={styles.dropdownItem}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -210,10 +211,11 @@ function DTMenuItemRow({
|
|||||||
activeVariant: DTVariant;
|
activeVariant: DTVariant;
|
||||||
level: number;
|
level: number;
|
||||||
}) {
|
}) {
|
||||||
|
const theme = useDTTheme();
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const hasChildren = item.items && item.items.length > 0;
|
const hasChildren = item.items && item.items.length > 0;
|
||||||
const isActive = item.isActive || expanded;
|
const isActive = item.isActive || expanded;
|
||||||
const itemColor = getVariantColor(isActive ? activeVariant : variant);
|
const itemColor = getVariantColor(theme, isActive ? activeVariant : variant);
|
||||||
|
|
||||||
const heightAnim = useRef(new Animated.Value(0)).current;
|
const heightAnim = useRef(new Animated.Value(0)).current;
|
||||||
const chevronAnim = useRef(new Animated.Value(0)).current;
|
const chevronAnim = useRef(new Animated.Value(0)).current;
|
||||||
@@ -265,7 +267,7 @@ function DTMenuItemRow({
|
|||||||
borderTopColor: itemColor,
|
borderTopColor: itemColor,
|
||||||
backgroundColor: isActive
|
backgroundColor: isActive
|
||||||
? `${itemColor}20`
|
? `${itemColor}20`
|
||||||
: DTColors.dark,
|
: theme.colors.background,
|
||||||
paddingLeft: 16 + level * 16,
|
paddingLeft: 16 + level * 16,
|
||||||
opacity: pressed ? 0.7 : 1,
|
opacity: pressed ? 0.7 : 1,
|
||||||
},
|
},
|
||||||
@@ -339,7 +341,6 @@ const styles = StyleSheet.create({
|
|||||||
menuItemTitle: {
|
menuItemTitle: {
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
textTransform: 'uppercase',
|
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
},
|
},
|
||||||
chevron: {
|
chevron: {
|
||||||
@@ -360,7 +361,6 @@ const styles = StyleSheet.create({
|
|||||||
dropdownContent: {
|
dropdownContent: {
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
borderRadius: 0,
|
borderRadius: 0,
|
||||||
backgroundColor: DTColors.dark,
|
|
||||||
},
|
},
|
||||||
dropdownItem: {
|
dropdownItem: {
|
||||||
borderRadius: 0,
|
borderRadius: 0,
|
||||||
|
|||||||
230
packages/react-native/src/components/DTMobileFilterOverlay.tsx
Normal file
230
packages/react-native/src/components/DTMobileFilterOverlay.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
/**
|
||||||
|
* DT Mobile Filter Overlay
|
||||||
|
*
|
||||||
|
* Full-screen slide-up overlay for mobile filter menus.
|
||||||
|
* Source: dt-shopify-storefront MobileFilterMenu component.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {ReactNode, useRef, useEffect} from 'react';
|
||||||
|
import {
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
ViewStyle,
|
||||||
|
StyleProp,
|
||||||
|
Pressable,
|
||||||
|
Animated,
|
||||||
|
Dimensions,
|
||||||
|
BackHandler,
|
||||||
|
} from 'react-native';
|
||||||
|
import {Text, Portal} from 'react-native-paper';
|
||||||
|
import {useDTTheme} from '../theme/DTThemeProvider';
|
||||||
|
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||||
|
|
||||||
|
interface DTMobileFilterOverlayProps {
|
||||||
|
visible: boolean;
|
||||||
|
onDismiss: () => void;
|
||||||
|
/**
|
||||||
|
* Header title
|
||||||
|
*/
|
||||||
|
heading?: string;
|
||||||
|
/**
|
||||||
|
* Number of active filters (shown as badge in header)
|
||||||
|
*/
|
||||||
|
activeFilterCount?: number;
|
||||||
|
/**
|
||||||
|
* Callback to clear all filters
|
||||||
|
*/
|
||||||
|
onClearAll?: () => void;
|
||||||
|
/**
|
||||||
|
* Color variant for the header
|
||||||
|
* @default 'normal'
|
||||||
|
*/
|
||||||
|
variant?: DTVariant;
|
||||||
|
children: ReactNode;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DTMobileFilterOverlay({
|
||||||
|
visible,
|
||||||
|
onDismiss,
|
||||||
|
heading = 'Filters',
|
||||||
|
activeFilterCount,
|
||||||
|
onClearAll,
|
||||||
|
variant = 'normal',
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
}: DTMobileFilterOverlayProps) {
|
||||||
|
const theme = useDTTheme();
|
||||||
|
const accentColor = getVariantColor(theme, variant);
|
||||||
|
const slideAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
const backdropAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
const screenHeight = Dimensions.get('window').height;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(backdropAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 200,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(slideAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
} else {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(backdropAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 200,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(slideAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 200,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
}
|
||||||
|
}, [visible, backdropAnim, slideAnim]);
|
||||||
|
|
||||||
|
// Android back button
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
const handler = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||||
|
onDismiss();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return () => handler.remove();
|
||||||
|
}, [visible, onDismiss]);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
const translateY = slideAnim.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [screenHeight, 0],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Portal>
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<Animated.View style={[styles.backdrop, {opacity: backdropAnim}]}>
|
||||||
|
<Pressable style={StyleSheet.absoluteFill} onPress={onDismiss} />
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.content,
|
||||||
|
{
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
transform: [{translateY}],
|
||||||
|
maxHeight: screenHeight * 0.85,
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={[styles.header, {backgroundColor: accentColor}]}>
|
||||||
|
<View style={styles.headerLeft}>
|
||||||
|
<Text
|
||||||
|
variant="titleMedium"
|
||||||
|
style={[styles.headerText, {color: theme.colors.onPrimary}]}>
|
||||||
|
{heading}
|
||||||
|
</Text>
|
||||||
|
{activeFilterCount !== undefined && activeFilterCount > 0 && (
|
||||||
|
<View style={[styles.countBadge, {backgroundColor: theme.colors.onPrimary}]}>
|
||||||
|
<Text style={[styles.countText, {color: accentColor}]}>
|
||||||
|
{activeFilterCount}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Pressable onPress={onDismiss} hitSlop={8}>
|
||||||
|
<Text style={[styles.closeText, {color: theme.colors.onPrimary}]}>
|
||||||
|
CLOSE
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Clear all button */}
|
||||||
|
{onClearAll && activeFilterCount !== undefined && activeFilterCount > 0 && (
|
||||||
|
<Pressable
|
||||||
|
onPress={onClearAll}
|
||||||
|
style={[styles.clearAll, {borderBottomColor: accentColor}]}>
|
||||||
|
<Text style={[styles.clearAllText, {color: accentColor}]}>
|
||||||
|
Clear All Filters
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filter content */}
|
||||||
|
<View style={styles.body}>{children}</View>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
</Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
zIndex: 1000,
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
|
backdrop: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
borderTopLeftRadius: 0,
|
||||||
|
borderTopRightRadius: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
headerLeft: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
headerText: {
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
countBadge: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 10,
|
||||||
|
minWidth: 24,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
countText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
closeText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
letterSpacing: 1,
|
||||||
|
},
|
||||||
|
clearAll: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
},
|
||||||
|
clearAllText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -9,9 +9,9 @@
|
|||||||
* - Content in beveled card container
|
* - Content in beveled card container
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {StyleSheet, ViewStyle, StyleProp, KeyboardAvoidingView, Platform} from 'react-native';
|
import {StyleSheet, ViewStyle, StyleProp, KeyboardAvoidingView, Platform, Pressable} from 'react-native';
|
||||||
import {Portal, Modal} from 'react-native-paper';
|
import {Portal, Modal, Text} from 'react-native-paper';
|
||||||
import {DTColors} from '../theme/colors';
|
import {useDTTheme} from '../theme/DTThemeProvider';
|
||||||
import {type DTVariant} from '../utils/variantColors';
|
import {type DTVariant} from '../utils/variantColors';
|
||||||
import {DTCard} from './DTCard';
|
import {DTCard} from './DTCard';
|
||||||
|
|
||||||
@@ -75,6 +75,8 @@ export function DTModal({
|
|||||||
contentStyle,
|
contentStyle,
|
||||||
style,
|
style,
|
||||||
}: DTModalProps) {
|
}: DTModalProps) {
|
||||||
|
const theme = useDTTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Portal>
|
<Portal>
|
||||||
<Modal
|
<Modal
|
||||||
@@ -85,7 +87,7 @@ export function DTModal({
|
|||||||
style={styles.modal}
|
style={styles.modal}
|
||||||
theme={{
|
theme={{
|
||||||
colors: {
|
colors: {
|
||||||
backdrop: DTColors.overlay,
|
backdrop: theme.colors.backdrop,
|
||||||
},
|
},
|
||||||
}}>
|
}}>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
@@ -95,6 +97,17 @@ export function DTModal({
|
|||||||
mode={variant}
|
mode={variant}
|
||||||
title={title}
|
title={title}
|
||||||
showHeader={!!title}
|
showHeader={!!title}
|
||||||
|
headerRight={dismissable ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={onDismiss}
|
||||||
|
hitSlop={8}
|
||||||
|
accessibilityLabel="Close"
|
||||||
|
accessibilityRole="button">
|
||||||
|
<Text style={{ color: theme.colors.onPrimary, fontSize: 18, lineHeight: 18 }}>
|
||||||
|
{'\u2715'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : undefined}
|
||||||
contentStyle={contentStyle}>
|
contentStyle={contentStyle}>
|
||||||
{children}
|
{children}
|
||||||
</DTCard>
|
</DTCard>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
import {StyleSheet, View, ViewStyle, StyleProp} from 'react-native';
|
import {StyleSheet, View, ViewStyle, StyleProp} from 'react-native';
|
||||||
import {ProgressBar, Text} from 'react-native-paper';
|
import {ProgressBar, Text} from 'react-native-paper';
|
||||||
import {DTColors} from '../theme/colors';
|
import {useDTTheme} from '../theme/DTThemeProvider';
|
||||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||||
import {useComponentLayout} from '../utils/useComponentLayout';
|
import {useComponentLayout} from '../utils/useComponentLayout';
|
||||||
|
|
||||||
@@ -77,7 +77,8 @@ export function DTProgressBar({
|
|||||||
color,
|
color,
|
||||||
style,
|
style,
|
||||||
}: DTProgressBarProps) {
|
}: DTProgressBarProps) {
|
||||||
const accentColor = getVariantColor(variant, color);
|
const theme = useDTTheme();
|
||||||
|
const accentColor = getVariantColor(theme, variant, color);
|
||||||
const clampedValue = Math.max(0, Math.min(1, value));
|
const clampedValue = Math.max(0, Math.min(1, value));
|
||||||
|
|
||||||
if (orientation === 'vertical') {
|
if (orientation === 'vertical') {
|
||||||
@@ -85,6 +86,7 @@ export function DTProgressBar({
|
|||||||
<VerticalProgressBar
|
<VerticalProgressBar
|
||||||
value={clampedValue}
|
value={clampedValue}
|
||||||
color={accentColor}
|
color={accentColor}
|
||||||
|
trackColor={theme.colors.surfaceDisabled}
|
||||||
width={height}
|
width={height}
|
||||||
showLabel={showLabel}
|
showLabel={showLabel}
|
||||||
style={style}
|
style={style}
|
||||||
@@ -100,7 +102,7 @@ export function DTProgressBar({
|
|||||||
style={[styles.bar, {height}]}
|
style={[styles.bar, {height}]}
|
||||||
theme={{
|
theme={{
|
||||||
colors: {
|
colors: {
|
||||||
surfaceVariant: DTColors.disabledBackground,
|
surfaceVariant: theme.colors.surfaceDisabled,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -120,12 +122,14 @@ export function DTProgressBar({
|
|||||||
function VerticalProgressBar({
|
function VerticalProgressBar({
|
||||||
value,
|
value,
|
||||||
color,
|
color,
|
||||||
|
trackColor,
|
||||||
width,
|
width,
|
||||||
showLabel,
|
showLabel,
|
||||||
style,
|
style,
|
||||||
}: {
|
}: {
|
||||||
value: number;
|
value: number;
|
||||||
color: string;
|
color: string;
|
||||||
|
trackColor: string;
|
||||||
width: number;
|
width: number;
|
||||||
showLabel: boolean;
|
showLabel: boolean;
|
||||||
style?: StyleProp<ViewStyle>;
|
style?: StyleProp<ViewStyle>;
|
||||||
@@ -135,7 +139,7 @@ function VerticalProgressBar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.verticalContainer, {width}, style]}>
|
<View style={[styles.verticalContainer, {width}, style]}>
|
||||||
<View style={styles.verticalTrack} onLayout={onLayout}>
|
<View style={[styles.verticalTrack, {backgroundColor: trackColor}]} onLayout={onLayout}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.verticalFill,
|
styles.verticalFill,
|
||||||
@@ -169,7 +173,6 @@ const styles = StyleSheet.create({
|
|||||||
verticalTrack: {
|
verticalTrack: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
backgroundColor: DTColors.disabledBackground,
|
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
},
|
},
|
||||||
verticalFill: {
|
verticalFill: {
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
* - Quantity display between buttons
|
* - Quantity display between buttons
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {StyleSheet, View, ViewStyle, StyleProp, Pressable} from 'react-native';
|
import {StyleSheet, View, ViewStyle, StyleProp, Pressable, Text as RNText} from 'react-native';
|
||||||
import {Text} from 'react-native-paper';
|
import {Text} from 'react-native-paper';
|
||||||
import Svg, {Path} from 'react-native-svg';
|
import Svg, {Path} from 'react-native-svg';
|
||||||
import {DTColors} from '../theme/colors';
|
import {useDTTheme} from '../theme/DTThemeProvider';
|
||||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||||
import {buildButtonBevelPath} from '../utils/bevelPaths';
|
import {buildButtonBevelPath} from '../utils/bevelPaths';
|
||||||
|
|
||||||
@@ -124,7 +124,9 @@ export function DTQuantityStepper({
|
|||||||
color,
|
color,
|
||||||
style,
|
style,
|
||||||
}: DTQuantityStepperProps) {
|
}: DTQuantityStepperProps) {
|
||||||
const accentColor = getVariantColor(variant, color);
|
const theme = useDTTheme();
|
||||||
|
const useBevels = theme.custom.bevelMd > 0;
|
||||||
|
const accentColor = getVariantColor(theme, variant, color);
|
||||||
const config = sizeConfigs[size];
|
const config = sizeConfigs[size];
|
||||||
const atMin = value <= min;
|
const atMin = value <= min;
|
||||||
const atMax = value >= max;
|
const atMax = value >= max;
|
||||||
@@ -168,6 +170,7 @@ export function DTQuantityStepper({
|
|||||||
style={({pressed}) => ({
|
style={({pressed}) => ({
|
||||||
opacity: disabled || isDisabled ? 0.3 : pressed ? 0.7 : 1,
|
opacity: disabled || isDisabled ? 0.3 : pressed ? 0.7 : 1,
|
||||||
})}>
|
})}>
|
||||||
|
{useBevels ? (
|
||||||
<Svg width={s} height={s} viewBox={`0 0 ${s} ${s}`}>
|
<Svg width={s} height={s} viewBox={`0 0 ${s} ${s}`}>
|
||||||
<Path
|
<Path
|
||||||
d={buildButtonBevelPath(s, s, config.bevelSize, borderWidth)}
|
d={buildButtonBevelPath(s, s, config.bevelSize, borderWidth)}
|
||||||
@@ -183,6 +186,21 @@ export function DTQuantityStepper({
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
/>
|
/>
|
||||||
</Svg>
|
</Svg>
|
||||||
|
) : (
|
||||||
|
<View style={{
|
||||||
|
width: s,
|
||||||
|
height: s,
|
||||||
|
borderWidth,
|
||||||
|
borderColor: accentColor,
|
||||||
|
borderRadius: theme.custom.radiusSm,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<RNText style={{color: accentColor, fontSize: s * 0.5, lineHeight: s * 0.6}}>
|
||||||
|
{type === 'minus' ? '−' : '+'}
|
||||||
|
</RNText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -190,7 +208,7 @@ export function DTQuantityStepper({
|
|||||||
return (
|
return (
|
||||||
<View style={[styles.wrapper, style]}>
|
<View style={[styles.wrapper, style]}>
|
||||||
{label && (
|
{label && (
|
||||||
<Text style={[styles.label, {color: DTColors.light}]}>{label}</Text>
|
<Text style={[styles.label, {color: theme.colors.onSurface}]}>{label}</Text>
|
||||||
)}
|
)}
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{renderStepButton('minus', handleDecrement, atMin)}
|
{renderStepButton('minus', handleDecrement, atMin)}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {createContext, useContext} from 'react';
|
|||||||
import {StyleSheet, View, ViewStyle, StyleProp, Pressable} from 'react-native';
|
import {StyleSheet, View, ViewStyle, StyleProp, Pressable} from 'react-native';
|
||||||
import {Text} from 'react-native-paper';
|
import {Text} from 'react-native-paper';
|
||||||
import Svg, {Polygon} from 'react-native-svg';
|
import Svg, {Polygon} from 'react-native-svg';
|
||||||
import {DTColors, getColor} from '../theme/colors';
|
import {useDTTheme} from '../theme/DTThemeProvider';
|
||||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||||
|
|
||||||
// --- Context ---
|
// --- Context ---
|
||||||
@@ -115,6 +115,15 @@ function hexPoints(cx: number, cy: number, r: number): string {
|
|||||||
return pts.join(' ');
|
return pts.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Convert hex color to rgba with given alpha */
|
||||||
|
function hexToRgba(hex: string, alpha: number): string {
|
||||||
|
const clean = hex.replace('#', '');
|
||||||
|
const r = parseInt(clean.substring(0, 2), 16);
|
||||||
|
const g = parseInt(clean.substring(2, 4), 16);
|
||||||
|
const b = parseInt(clean.substring(4, 6), 16);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DT-styled RadioOption with hexagonal indicator
|
* DT-styled RadioOption with hexagonal indicator
|
||||||
*
|
*
|
||||||
@@ -130,6 +139,7 @@ export function DTRadioOption({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
style,
|
style,
|
||||||
}: DTRadioOptionProps) {
|
}: DTRadioOptionProps) {
|
||||||
|
const theme = useDTTheme();
|
||||||
const context = useContext(RadioContext);
|
const context = useContext(RadioContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error('DTRadioOption must be used inside DTRadioGroup');
|
throw new Error('DTRadioOption must be used inside DTRadioGroup');
|
||||||
@@ -137,14 +147,9 @@ export function DTRadioOption({
|
|||||||
|
|
||||||
const {value: selectedValue, onValueChange, variant} = context;
|
const {value: selectedValue, onValueChange, variant} = context;
|
||||||
const isSelected = selectedValue === value;
|
const isSelected = selectedValue === value;
|
||||||
const accentColor = getVariantColor(variant);
|
const accentColor = getVariantColor(theme, variant);
|
||||||
const selectedBgColor = getColor(
|
const selectedBgColor = hexToRgba(accentColor, 0.7);
|
||||||
variant === 'normal'
|
const unselectedBorderColor = hexToRgba(theme.custom.modeNormal, 0.3);
|
||||||
? 'modeNormalSelected'
|
|
||||||
: variant === 'emphasis'
|
|
||||||
? 'modeEmphasisSelected'
|
|
||||||
: 'modeNormal',
|
|
||||||
);
|
|
||||||
const opacity = disabled ? 0.5 : 1;
|
const opacity = disabled ? 0.5 : 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -153,7 +158,7 @@ export function DTRadioOption({
|
|||||||
style={({pressed}) => [
|
style={({pressed}) => [
|
||||||
styles.option,
|
styles.option,
|
||||||
{
|
{
|
||||||
borderColor: isSelected ? accentColor : getColor('modeNormal', 0.3),
|
borderColor: isSelected ? accentColor : unselectedBorderColor,
|
||||||
backgroundColor: isSelected ? selectedBgColor : 'transparent',
|
backgroundColor: isSelected ? selectedBgColor : 'transparent',
|
||||||
opacity: pressed ? 0.7 : opacity,
|
opacity: pressed ? 0.7 : opacity,
|
||||||
},
|
},
|
||||||
@@ -183,7 +188,7 @@ export function DTRadioOption({
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.optionLabel,
|
styles.optionLabel,
|
||||||
{color: isSelected ? DTColors.dark : DTColors.light},
|
{color: isSelected ? theme.colors.onPrimary : theme.colors.onSurface},
|
||||||
]}>
|
]}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -192,7 +197,7 @@ export function DTRadioOption({
|
|||||||
variant="bodySmall"
|
variant="bodySmall"
|
||||||
style={[
|
style={[
|
||||||
styles.optionDescription,
|
styles.optionDescription,
|
||||||
{color: isSelected ? DTColors.dark : DTColors.disabled},
|
{color: isSelected ? theme.colors.onPrimary : theme.colors.onSurfaceDisabled},
|
||||||
]}>
|
]}>
|
||||||
{description}
|
{description}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import {useState, useRef, useEffect} from 'react';
|
import {useState, useRef, useEffect} from 'react';
|
||||||
import {StyleSheet, View, ViewStyle, TextStyle, StyleProp, Animated} from 'react-native';
|
import {StyleSheet, View, ViewStyle, TextStyle, StyleProp, Animated} from 'react-native';
|
||||||
import {Searchbar, ActivityIndicator} from 'react-native-paper';
|
import {Searchbar, ActivityIndicator} from 'react-native-paper';
|
||||||
import {DTColors} from '../theme/colors';
|
import {useDTTheme} from '../theme/DTThemeProvider';
|
||||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||||
|
|
||||||
interface DTSearchInputProps {
|
interface DTSearchInputProps {
|
||||||
@@ -86,7 +86,8 @@ export function DTSearchInput({
|
|||||||
style,
|
style,
|
||||||
inputStyle,
|
inputStyle,
|
||||||
}: DTSearchInputProps) {
|
}: DTSearchInputProps) {
|
||||||
const accentColor = getVariantColor(variant, color);
|
const theme = useDTTheme();
|
||||||
|
const accentColor = getVariantColor(theme, variant, color);
|
||||||
const [focused, setFocused] = useState(false);
|
const [focused, setFocused] = useState(false);
|
||||||
const focusAnim = useRef(new Animated.Value(0)).current;
|
const focusAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
@@ -110,7 +111,7 @@ export function DTSearchInput({
|
|||||||
onBlur={() => setFocused(false)}
|
onBlur={() => setFocused(false)}
|
||||||
returnKeyType="search"
|
returnKeyType="search"
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
placeholderTextColor={DTColors.disabled}
|
placeholderTextColor={theme.colors.onSurfaceDisabled}
|
||||||
editable={!disabled}
|
editable={!disabled}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
icon={
|
icon={
|
||||||
@@ -119,13 +120,13 @@ export function DTSearchInput({
|
|||||||
: 'magnify'
|
: 'magnify'
|
||||||
}
|
}
|
||||||
iconColor={accentColor}
|
iconColor={accentColor}
|
||||||
inputStyle={[styles.input, {color: DTColors.light}, inputStyle]}
|
inputStyle={[styles.input, {color: theme.colors.onSurface}, inputStyle]}
|
||||||
style={[styles.searchbar, {borderColor: accentColor}]}
|
style={[styles.searchbar, {borderColor: accentColor, backgroundColor: theme.colors.background}]}
|
||||||
rippleColor={accentColor}
|
rippleColor={accentColor}
|
||||||
theme={{
|
theme={{
|
||||||
colors: {
|
colors: {
|
||||||
elevation: {level3: DTColors.dark},
|
elevation: {level3: theme.colors.background},
|
||||||
onSurface: DTColors.light,
|
onSurface: theme.colors.onSurface,
|
||||||
onSurfaceVariant: accentColor,
|
onSurfaceVariant: accentColor,
|
||||||
},
|
},
|
||||||
roundness: 0,
|
roundness: 0,
|
||||||
@@ -149,7 +150,6 @@ const styles = StyleSheet.create({
|
|||||||
container: {},
|
container: {},
|
||||||
searchbar: {
|
searchbar: {
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
backgroundColor: DTColors.dark,
|
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
|
|||||||
66
packages/react-native/src/components/DTStaggerContainer.tsx
Normal file
66
packages/react-native/src/components/DTStaggerContainer.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* DT Stagger Container
|
||||||
|
*
|
||||||
|
* Animates children with staggered scale-in entrances.
|
||||||
|
* Source: dt-shopify-storefront framer-motion staggered card entrance pattern.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {ReactNode, Children, useRef, useEffect} from 'react';
|
||||||
|
import {Animated, ViewStyle, StyleProp} from 'react-native';
|
||||||
|
|
||||||
|
interface DTStaggerContainerProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/**
|
||||||
|
* Duration of each child's scale-in animation in ms
|
||||||
|
* @default 330
|
||||||
|
*/
|
||||||
|
duration?: number;
|
||||||
|
/**
|
||||||
|
* Delay between each child's animation start in ms
|
||||||
|
* @default 75
|
||||||
|
*/
|
||||||
|
interval?: number;
|
||||||
|
/**
|
||||||
|
* Additional styles for the container
|
||||||
|
*/
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DTStaggerContainer({
|
||||||
|
children,
|
||||||
|
duration = 330,
|
||||||
|
interval = 75,
|
||||||
|
style,
|
||||||
|
}: DTStaggerContainerProps) {
|
||||||
|
const childArray = Children.toArray(children);
|
||||||
|
const animations = useRef(childArray.map(() => new Animated.Value(0))).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Reset animations if child count changes
|
||||||
|
while (animations.length < childArray.length) {
|
||||||
|
animations.push(new Animated.Value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
const staggered = childArray.map((_, i) =>
|
||||||
|
Animated.timing(animations[i], {
|
||||||
|
toValue: 1,
|
||||||
|
duration,
|
||||||
|
delay: i * interval,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
Animated.parallel(staggered).start();
|
||||||
|
}, [childArray.length, duration, interval]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={style}>
|
||||||
|
{childArray.map((child, i) => (
|
||||||
|
<Animated.View
|
||||||
|
key={i}
|
||||||
|
style={{transform: [{scale: animations[i] ?? new Animated.Value(1)}]}}>
|
||||||
|
{child}
|
||||||
|
</Animated.View>
|
||||||
|
))}
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
Animated,
|
Animated,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import {Text} from 'react-native-paper';
|
import {Text} from 'react-native-paper';
|
||||||
import {DTColors} from '../theme/colors';
|
import {useDTTheme} from '../theme/DTThemeProvider';
|
||||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||||
|
|
||||||
const TRACK_WIDTH = 48;
|
const TRACK_WIDTH = 48;
|
||||||
@@ -73,7 +73,8 @@ export function DTSwitch({
|
|||||||
color,
|
color,
|
||||||
style,
|
style,
|
||||||
}: DTSwitchProps) {
|
}: DTSwitchProps) {
|
||||||
const accentColor = getVariantColor(variant, color);
|
const theme = useDTTheme();
|
||||||
|
const accentColor = getVariantColor(theme, variant, color);
|
||||||
const thumbAnim = useRef(new Animated.Value(value ? 1 : 0)).current;
|
const thumbAnim = useRef(new Animated.Value(value ? 1 : 0)).current;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -91,10 +92,9 @@ export function DTSwitch({
|
|||||||
|
|
||||||
const trackColor = thumbAnim.interpolate({
|
const trackColor = thumbAnim.interpolate({
|
||||||
inputRange: [0, 1],
|
inputRange: [0, 1],
|
||||||
outputRange: [DTColors.disabledBackground, accentColor],
|
outputRange: [theme.colors.surfaceDisabled, accentColor],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => !disabled && onValueChange(!value)}
|
onPress={() => !disabled && onValueChange(!value)}
|
||||||
@@ -104,7 +104,7 @@ export function DTSwitch({
|
|||||||
style,
|
style,
|
||||||
]}>
|
]}>
|
||||||
{label && (
|
{label && (
|
||||||
<Text style={[styles.label, {color: DTColors.light}]}>{label}</Text>
|
<Text style={[styles.label, {color: theme.colors.onSurface}]}>{label}</Text>
|
||||||
)}
|
)}
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
@@ -118,7 +118,7 @@ export function DTSwitch({
|
|||||||
style={[
|
style={[
|
||||||
styles.thumb,
|
styles.thumb,
|
||||||
{
|
{
|
||||||
backgroundColor: value ? DTColors.dark : DTColors.light,
|
backgroundColor: value ? theme.colors.onPrimary : theme.colors.onSurface,
|
||||||
transform: [{translateX: thumbTranslateX}],
|
transform: [{translateX: thumbTranslateX}],
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
Animated,
|
Animated,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import {TextInput, TextInputProps} from 'react-native-paper';
|
import {TextInput, TextInputProps} from 'react-native-paper';
|
||||||
import {DTColors} from '../theme/colors';
|
import {useDTTheme} from '../theme/DTThemeProvider';
|
||||||
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
import {type DTVariant, getVariantColor} from '../utils/variantColors';
|
||||||
|
|
||||||
interface DTTextInputProps
|
interface DTTextInputProps
|
||||||
@@ -79,12 +79,13 @@ export function DTTextInput({
|
|||||||
style,
|
style,
|
||||||
...props
|
...props
|
||||||
}: DTTextInputProps) {
|
}: DTTextInputProps) {
|
||||||
|
const theme = useDTTheme();
|
||||||
const [focused, setFocused] = useState(false);
|
const [focused, setFocused] = useState(false);
|
||||||
const focusAnim = useRef(new Animated.Value(0)).current;
|
const focusAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
const accentColor = error
|
const accentColor = error
|
||||||
? DTColors.modeWarning
|
? theme.custom.modeWarning
|
||||||
: getVariantColor(variant, color);
|
: getVariantColor(theme, variant, color);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const anim = Animated.timing(focusAnim, {
|
const anim = Animated.timing(focusAnim, {
|
||||||
@@ -101,7 +102,7 @@ export function DTTextInput({
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.inputWrapper,
|
styles.inputWrapper,
|
||||||
{borderColor: accentColor, borderWidth},
|
{borderColor: accentColor, borderWidth, backgroundColor: theme.colors.background},
|
||||||
]}>
|
]}>
|
||||||
<TextInput
|
<TextInput
|
||||||
{...props}
|
{...props}
|
||||||
@@ -116,8 +117,8 @@ export function DTTextInput({
|
|||||||
onBlur?.(e);
|
onBlur?.(e);
|
||||||
}}
|
}}
|
||||||
style={[styles.input, inputStyle, style]}
|
style={[styles.input, inputStyle, style]}
|
||||||
textColor={DTColors.light}
|
textColor={theme.colors.onSurface}
|
||||||
placeholderTextColor={DTColors.disabled}
|
placeholderTextColor={theme.colors.onSurfaceDisabled}
|
||||||
activeUnderlineColor="transparent"
|
activeUnderlineColor="transparent"
|
||||||
underlineColor="transparent"
|
underlineColor="transparent"
|
||||||
theme={{
|
theme={{
|
||||||
@@ -141,7 +142,7 @@ export function DTTextInput({
|
|||||||
/>
|
/>
|
||||||
{error && errorMessage && (
|
{error && errorMessage && (
|
||||||
<View style={styles.errorContainer}>
|
<View style={styles.errorContainer}>
|
||||||
<Animated.Text style={[styles.errorText, {color: DTColors.modeWarning}]}>
|
<Animated.Text style={[styles.errorText, {color: theme.custom.modeWarning}]}>
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -151,9 +152,7 @@ export function DTTextInput({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
inputWrapper: {
|
inputWrapper: {},
|
||||||
backgroundColor: DTColors.dark,
|
|
||||||
},
|
|
||||||
input: {
|
input: {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,9 +27,10 @@ export { DTThemeProvider, useDTTheme } from './theme/DTThemeProvider';
|
|||||||
|
|
||||||
// Shared utilities
|
// Shared utilities
|
||||||
export type { DTVariant } from './utils/variantColors';
|
export type { DTVariant } from './utils/variantColors';
|
||||||
export { variantColorMap, getVariantColor } from './utils/variantColors';
|
export { getVariantColor } from './utils/variantColors';
|
||||||
export { buildBeveledRectPath } from './utils/bevelPaths';
|
export { buildBeveledRectPath } from './utils/bevelPaths';
|
||||||
export { useComponentLayout } from './utils/useComponentLayout';
|
export { useComponentLayout } from './utils/useComponentLayout';
|
||||||
|
export { useScaleIn, usePulse } from './utils/animations';
|
||||||
|
|
||||||
// Component exports — existing
|
// Component exports — existing
|
||||||
export { DTCard, DTCardClipPath } from './components/DTCard';
|
export { DTCard, DTCardClipPath } from './components/DTCard';
|
||||||
@@ -50,6 +51,10 @@ export { DTMediaFrame } from './components/DTMediaFrame';
|
|||||||
export { DTAccordion } from './components/DTAccordion';
|
export { DTAccordion } from './components/DTAccordion';
|
||||||
export type { DTAccordionSection } from './components/DTAccordion';
|
export type { DTAccordionSection } from './components/DTAccordion';
|
||||||
|
|
||||||
|
// Component exports — positioning & animation containers
|
||||||
|
export { DTBadgeOverlay } from './components/DTBadgeOverlay';
|
||||||
|
export { DTStaggerContainer } from './components/DTStaggerContainer';
|
||||||
|
|
||||||
// Component exports — complex interactive
|
// Component exports — complex interactive
|
||||||
export { DTModal } from './components/DTModal';
|
export { DTModal } from './components/DTModal';
|
||||||
export { DTDrawer } from './components/DTDrawer';
|
export { DTDrawer } from './components/DTDrawer';
|
||||||
@@ -57,6 +62,11 @@ export { DTGallery } from './components/DTGallery';
|
|||||||
export type { DTGalleryItem } from './components/DTGallery';
|
export type { DTGalleryItem } from './components/DTGallery';
|
||||||
export { DTSearchInput } from './components/DTSearchInput';
|
export { DTSearchInput } from './components/DTSearchInput';
|
||||||
|
|
||||||
|
// Component exports — filter & feature
|
||||||
|
export { DTFeatureLegend } from './components/DTFeatureLegend';
|
||||||
|
export type { DTFeatureItem } from './components/DTFeatureLegend';
|
||||||
|
export { DTMobileFilterOverlay } from './components/DTMobileFilterOverlay';
|
||||||
|
|
||||||
// Component exports — navigation & decorative
|
// Component exports — navigation & decorative
|
||||||
export { DTMenu, DTMenuDropdown } from './components/DTMenu';
|
export { DTMenu, DTMenuDropdown } from './components/DTMenu';
|
||||||
export type { DTMenuItem } from './components/DTMenu';
|
export type { DTMenuItem } from './components/DTMenu';
|
||||||
|
|||||||
@@ -124,6 +124,14 @@ export interface DTExtendedTheme extends MD3Theme {
|
|||||||
modeOther: string;
|
modeOther: string;
|
||||||
border: string;
|
border: string;
|
||||||
borderEmphasis: string;
|
borderEmphasis: string;
|
||||||
|
/** Bevel sizes in pixels (0 = no bevels, use borderRadius instead) */
|
||||||
|
bevelSm: number;
|
||||||
|
bevelMd: number;
|
||||||
|
bevelLg: number;
|
||||||
|
/** Border radius in pixels (0 = angular/beveled) */
|
||||||
|
radiusSm: number;
|
||||||
|
radius: number;
|
||||||
|
radiusLg: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +147,12 @@ export const DTExtendedDarkTheme: DTExtendedTheme = {
|
|||||||
modeOther: DTColors.modeOther,
|
modeOther: DTColors.modeOther,
|
||||||
border: DTColors.border,
|
border: DTColors.border,
|
||||||
borderEmphasis: DTColors.borderEmphasis,
|
borderEmphasis: DTColors.borderEmphasis,
|
||||||
|
bevelSm: 16,
|
||||||
|
bevelMd: 32,
|
||||||
|
bevelLg: 40,
|
||||||
|
radiusSm: 0,
|
||||||
|
radius: 0,
|
||||||
|
radiusLg: 0,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
73
packages/react-native/src/utils/animations.ts
Normal file
73
packages/react-native/src/utils/animations.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Reusable animation hooks for DT components
|
||||||
|
*
|
||||||
|
* Provides common animation patterns (scale-in, pulse) as hooks
|
||||||
|
* that consumers and new components can use consistently.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {useRef, useEffect} from 'react';
|
||||||
|
import {Animated} from 'react-native';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scale-in entrance animation.
|
||||||
|
* Returns an Animated.Value (0→1) to use as a scale transform.
|
||||||
|
*/
|
||||||
|
export function useScaleIn(options?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
duration?: number;
|
||||||
|
delay?: number;
|
||||||
|
}): Animated.Value {
|
||||||
|
const {enabled = true, duration = 330, delay = 0} = options ?? {};
|
||||||
|
const anim = useRef(new Animated.Value(enabled ? 0 : 1)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled) {
|
||||||
|
anim.setValue(0);
|
||||||
|
Animated.timing(anim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration,
|
||||||
|
delay,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
} else {
|
||||||
|
anim.setValue(1);
|
||||||
|
}
|
||||||
|
}, [enabled, duration, delay, anim]);
|
||||||
|
|
||||||
|
return anim;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulse (ping) opacity animation loop.
|
||||||
|
* Returns an Animated.Value that oscillates between 1 and 0.3.
|
||||||
|
*/
|
||||||
|
export function usePulse(enabled = true): Animated.Value {
|
||||||
|
const anim = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled) {
|
||||||
|
const loop = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(anim, {
|
||||||
|
toValue: 0.3,
|
||||||
|
duration: 1000,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(anim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 1000,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
loop.start();
|
||||||
|
return () => {
|
||||||
|
loop.stop();
|
||||||
|
anim.setValue(1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
anim.setValue(1);
|
||||||
|
}, [enabled, anim]);
|
||||||
|
|
||||||
|
return anim;
|
||||||
|
}
|
||||||
@@ -2,26 +2,33 @@
|
|||||||
* Centralized variant type and color mapping
|
* Centralized variant type and color mapping
|
||||||
*
|
*
|
||||||
* Used by all DT components for consistent mode/variant color resolution.
|
* Used by all DT components for consistent mode/variant color resolution.
|
||||||
|
* Reads colors from the active DTExtendedTheme rather than static defaults.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {DTColors} from '../theme/colors';
|
import type {DTExtendedTheme} from '../theme/paperTheme';
|
||||||
|
import type {DTVariant} from '@dangerousthings/tokens';
|
||||||
|
|
||||||
export type DTVariant = 'normal' | 'emphasis' | 'warning' | 'success' | 'other';
|
// Re-export DTVariant from the canonical source (tokens package)
|
||||||
|
export type {DTVariant} from '@dangerousthings/tokens';
|
||||||
|
|
||||||
export const variantColorMap: Record<DTVariant, string> = {
|
/** String-typed keys in DTExtendedTheme['custom'] that map to mode colors */
|
||||||
normal: DTColors.modeNormal,
|
type ModeColorKey = 'modeNormal' | 'modeEmphasis' | 'modeWarning' | 'modeSuccess' | 'modeOther';
|
||||||
emphasis: DTColors.modeEmphasis,
|
|
||||||
warning: DTColors.modeWarning,
|
const variantToThemeKey: Record<DTVariant, ModeColorKey> = {
|
||||||
success: DTColors.modeSuccess,
|
normal: 'modeNormal',
|
||||||
other: DTColors.modeOther,
|
emphasis: 'modeEmphasis',
|
||||||
|
warning: 'modeWarning',
|
||||||
|
success: 'modeSuccess',
|
||||||
|
other: 'modeOther',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve variant to color, with optional custom color override.
|
* Resolve variant to color from the active theme, with optional custom color override.
|
||||||
*/
|
*/
|
||||||
export function getVariantColor(
|
export function getVariantColor(
|
||||||
|
theme: DTExtendedTheme,
|
||||||
variant: DTVariant,
|
variant: DTVariant,
|
||||||
customColor?: string,
|
customColor?: string,
|
||||||
): string {
|
): string {
|
||||||
return customColor ?? variantColorMap[variant];
|
return customColor ?? theme.custom[variantToThemeKey[variant]];
|
||||||
}
|
}
|
||||||
|
|||||||
34
packages/react/CHANGELOG.md
Normal file
34
packages/react/CHANGELOG.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# @dangerousthings/react
|
||||||
|
|
||||||
|
## 2.0.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Video letterboxing for DTMediaFrame, card badge styling with bevel-aware positioning, restored DTStaggerContainer.
|
||||||
|
- Updated dependencies
|
||||||
|
- @dangerousthings/web@0.4.1
|
||||||
|
|
||||||
|
## 2.0.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- Video letterboxing for DTMediaFrame, card badge styling with bevel-aware positioning, restored DTStaggerContainer in react-native, updated DTGallery/DTModal/DTMobileFilterOverlay components.
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies
|
||||||
|
- @dangerousthings/web@0.4.0
|
||||||
|
|
||||||
|
## 1.0.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- e74c285: New package: @dangerousthings/react — React web components wrapping @dangerousthings/web CSS. 22 components matching the React Native package API (DTCard, DTButton, DTLabel, DTChip, DTModal, DTDrawer, DTAccordion, DTMenu, DTStaggerContainer, DTFeatureLegend, DTMobileFilterOverlay, DTGallery, DTHexagon, form primitives). Includes DTWebThemeProvider, animation hooks (useScaleIn, usePulse), and variant utility functions. DTVariant type moved to @dangerousthings/tokens as shared canonical source.
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [e74c285]
|
||||||
|
- Updated dependencies [e74c285]
|
||||||
|
- Updated dependencies [e74c285]
|
||||||
|
- @dangerousthings/tokens@0.3.0
|
||||||
|
- @dangerousthings/web@0.3.0
|
||||||
21
packages/react/LICENSE
Normal file
21
packages/react/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Dangerous Things
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
131
packages/react/README.md
Normal file
131
packages/react/README.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# @dangerousthings/react
|
||||||
|
|
||||||
|
React web components for the Dangerous Things design system — 20 themed components wrapping `@dangerousthings/web` CSS with React APIs matching `@dangerousthings/react-native` for cross-platform parity.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @dangerousthings/react
|
||||||
|
```
|
||||||
|
|
||||||
|
### Peer Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install react react-dom @dangerousthings/tokens @dangerousthings/web
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Wrap your app with `DTWebThemeProvider` and import the web CSS bundle:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import "@dangerousthings/web";
|
||||||
|
import { DTWebThemeProvider } from "@dangerousthings/react";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<DTWebThemeProvider brand="dt" theme="dark">
|
||||||
|
{/* your app */}
|
||||||
|
</DTWebThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The provider sets `data-brand` and `data-theme` attributes on a wrapper div and exposes brand/theme via React context.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Bevel & Layout
|
||||||
|
|
||||||
|
| Component | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `DTCard` | Beveled card with header, progress bar, and mode colors |
|
||||||
|
| `DTButton` | Beveled button with variant colors |
|
||||||
|
| `DTLabel` | Top-right beveled label with status colors |
|
||||||
|
| `DTChip` | Compact labeled element |
|
||||||
|
| `DTMediaFrame` | Diagonal beveled frame for images |
|
||||||
|
| `DTModal` | Modal dialog with beveled corners |
|
||||||
|
| `DTDrawer` | Side drawer with edge bevels |
|
||||||
|
| `DTHexagon` | Decorative hexagon SVG shape |
|
||||||
|
|
||||||
|
### Forms
|
||||||
|
|
||||||
|
| Component | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `DTTextInput` | Sharp-cornered input with focus glow |
|
||||||
|
| `DTCheckbox` | Beveled checkbox with opposing corner cuts |
|
||||||
|
| `DTSwitch` | Angular toggle switch |
|
||||||
|
| `DTRadioGroup` | Hexagonal radio buttons |
|
||||||
|
| `DTQuantityStepper` | Beveled +/- increment buttons |
|
||||||
|
| `DTSearchInput` | Search-styled text input |
|
||||||
|
|
||||||
|
### Layout & Animation
|
||||||
|
|
||||||
|
| Component | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `DTProgressBar` | Angular progress bar with optional label |
|
||||||
|
| `DTAccordion` | Collapsible sections with accent border |
|
||||||
|
| `DTStaggerContainer` | Staggered scale-in entrance animation for children |
|
||||||
|
| `DTBadgeOverlay` | Absolute-positioned badge wrapper |
|
||||||
|
|
||||||
|
### Filter & Feature
|
||||||
|
|
||||||
|
| Component | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `DTMenu` | Dropdown menu with item variants |
|
||||||
|
| `DTFeatureLegend` | Product feature grid with icons and rotated labels |
|
||||||
|
| `DTMobileFilterOverlay` | Full-screen slide-up filter overlay with backdrop |
|
||||||
|
| `DTGallery` | Image gallery |
|
||||||
|
|
||||||
|
### Animation Hooks
|
||||||
|
|
||||||
|
| Hook | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `useScaleIn` | Scale 0 to 1 entrance animation |
|
||||||
|
| `usePulse` | Looping opacity pulse animation |
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { DTButton, DTCard, DTTextInput } from "@dangerousthings/react";
|
||||||
|
|
||||||
|
// Button with variant
|
||||||
|
<DTButton variant="normal" onClick={handleClick}>
|
||||||
|
Scan NFC
|
||||||
|
</DTButton>
|
||||||
|
|
||||||
|
// Card with title and progress bar
|
||||||
|
<DTCard title="PRODUCT" variant="emphasis" progress={60}>
|
||||||
|
<p>60% progress</p>
|
||||||
|
</DTCard>
|
||||||
|
|
||||||
|
// Text input
|
||||||
|
<DTTextInput variant="normal" label="Username" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Theme Utilities
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useDTWebTheme } from "@dangerousthings/react";
|
||||||
|
|
||||||
|
const { brand, theme } = useDTWebTheme();
|
||||||
|
// brand: "dt" | "classic"
|
||||||
|
// theme: "dark" | "light"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Variant Utilities
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { getVariantClass, variantToClassName } from "@dangerousthings/react";
|
||||||
|
|
||||||
|
getVariantClass("emphasis"); // mode class name for CSS
|
||||||
|
variantToClassName("warning"); // token-level variant mapping
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monorepo
|
||||||
|
|
||||||
|
Part of the [DT Design System](https://github.com/dangerous-tac0s/dt-design-system) monorepo.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
62
packages/react/package.json
Normal file
62
packages/react/package.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"name": "@dangerousthings/react",
|
||||||
|
"version": "2.1.0",
|
||||||
|
"description": "React web components for the Dangerous Things design system",
|
||||||
|
"license": "MIT",
|
||||||
|
"author": {
|
||||||
|
"name": "Dangerous Things",
|
||||||
|
"email": "info@dangerousthings.com"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/dangerous-tac0s/dt-design-system.git",
|
||||||
|
"directory": "packages/react"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/dangerous-tac0s/dt-design-system#readme",
|
||||||
|
"keywords": [
|
||||||
|
"dangerousthings",
|
||||||
|
"design-system",
|
||||||
|
"react",
|
||||||
|
"components",
|
||||||
|
"theming"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"default": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18",
|
||||||
|
"@dangerousthings/tokens": "*",
|
||||||
|
"@dangerousthings/web": "*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@dangerousthings/tokens": "*",
|
||||||
|
"@dangerousthings/web": "*",
|
||||||
|
"@types/react": "^18.2.0",
|
||||||
|
"@types/react-dom": "^18.2.0",
|
||||||
|
"react": "^18.0.0",
|
||||||
|
"react-dom": "^18.0.0",
|
||||||
|
"typescript": "^5.8.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
83
packages/react/src/components/DTAccordion.tsx
Normal file
83
packages/react/src/components/DTAccordion.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* DTAccordion — Expandable sections with animated height and chevron.
|
||||||
|
*
|
||||||
|
* CSS reference: forms-dt.css .dt-accordion, .dt-accordion-header,
|
||||||
|
* .dt-accordion-chevron, .dt-accordion-content
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, type ReactNode, type CSSProperties } from 'react';
|
||||||
|
import type { DTVariant } from '@dangerousthings/tokens';
|
||||||
|
import { cx } from '../utils/cx';
|
||||||
|
|
||||||
|
export interface DTAccordionSection {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DTAccordionProps {
|
||||||
|
sections: DTAccordionSection[];
|
||||||
|
/** Header variant when closed @default 'normal' */
|
||||||
|
variant?: DTVariant;
|
||||||
|
/** Header variant when open @default 'emphasis' */
|
||||||
|
activeVariant?: DTVariant;
|
||||||
|
/** Allow multiple sections open @default false */
|
||||||
|
allowMultiple?: boolean;
|
||||||
|
initialOpenKeys?: string[];
|
||||||
|
onSectionToggle?: (key: string, isOpen: boolean) => void;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DTAccordion({
|
||||||
|
sections,
|
||||||
|
allowMultiple = false,
|
||||||
|
initialOpenKeys,
|
||||||
|
onSectionToggle,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: DTAccordionProps) {
|
||||||
|
const [openKeys, setOpenKeys] = useState<Set<string>>(
|
||||||
|
new Set(initialOpenKeys),
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggle = 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],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cx('dt-accordion', className)} style={style}>
|
||||||
|
{sections.map(section => {
|
||||||
|
const isOpen = openKeys.has(section.key);
|
||||||
|
return (
|
||||||
|
<div key={section.key}>
|
||||||
|
<button
|
||||||
|
className="dt-accordion-header"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
onClick={() => toggle(section.key)}
|
||||||
|
type="button"
|
||||||
|
style={{ width: '100%' }}>
|
||||||
|
<span>{section.title}</span>
|
||||||
|
<span className="dt-accordion-chevron" />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className="dt-accordion-content"
|
||||||
|
data-open={isOpen}>
|
||||||
|
{section.children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
packages/react/src/components/DTBadgeOverlay.tsx
Normal file
50
packages/react/src/components/DTBadgeOverlay.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* DTBadgeOverlay — Absolute-positioned badge container.
|
||||||
|
*
|
||||||
|
* CSS reference: bevels.css .dt-badge-bottom-right, .dt-badge-top-right, etc.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode, CSSProperties } from 'react';
|
||||||
|
import { cx } from '../utils/cx';
|
||||||
|
|
||||||
|
type BadgePosition = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||||
|
|
||||||
|
interface DTBadgeOverlayProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Badge position @default 'bottom-right' */
|
||||||
|
position?: BadgePosition;
|
||||||
|
/** Offset from corner in px */
|
||||||
|
offset?: number;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const positionClassMap: Record<BadgePosition, string> = {
|
||||||
|
'top-left': 'dt-badge-top-left',
|
||||||
|
'top-right': 'dt-badge-top-right',
|
||||||
|
'bottom-left': 'dt-badge-overlay',
|
||||||
|
'bottom-right': 'dt-badge-bottom-right',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DTBadgeOverlay({
|
||||||
|
children,
|
||||||
|
position = 'bottom-right',
|
||||||
|
offset,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: DTBadgeOverlayProps) {
|
||||||
|
const offsetStyle: CSSProperties = offset
|
||||||
|
? {
|
||||||
|
...(position.includes('top') ? { top: `${offset}px` } : { bottom: `${offset}px` }),
|
||||||
|
...(position.includes('left') ? { left: `${offset}px` } : { right: `${offset}px` }),
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(positionClassMap[position], className)}
|
||||||
|
style={{ ...offsetStyle, ...style }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
packages/react/src/components/DTButton.tsx
Normal file
66
packages/react/src/components/DTButton.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* DTButton — Interactive bevel button with mode colors.
|
||||||
|
*
|
||||||
|
* CSS reference: bevels.css .dt-btn, .btn-primary, .mode-*, .selected
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode, CSSProperties, MouseEvent } from 'react';
|
||||||
|
import type { DTVariant } from '@dangerousthings/tokens';
|
||||||
|
import { cx } from '../utils/cx';
|
||||||
|
import { getVariantClass } from '../utils/variantClasses';
|
||||||
|
|
||||||
|
interface DTButtonProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Color variant @default 'normal' */
|
||||||
|
variant?: DTVariant;
|
||||||
|
/** Display mode @default 'outlined' */
|
||||||
|
mode?: 'outlined' | 'contained';
|
||||||
|
/** Persistent selected state (bevel + 70% fill) */
|
||||||
|
selected?: boolean;
|
||||||
|
onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
type?: 'button' | 'submit' | 'reset';
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DTButton({
|
||||||
|
children,
|
||||||
|
variant = 'normal',
|
||||||
|
mode = 'outlined',
|
||||||
|
selected = false,
|
||||||
|
onClick,
|
||||||
|
disabled = false,
|
||||||
|
type = 'button',
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: DTButtonProps) {
|
||||||
|
if (mode === 'contained') {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cx('btn-primary', getVariantClass(variant), className)}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
type={type}
|
||||||
|
style={style}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cx(
|
||||||
|
'dt-btn',
|
||||||
|
getVariantClass(variant),
|
||||||
|
selected && 'selected',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
type={type}
|
||||||
|
style={style}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
packages/react/src/components/DTCard.tsx
Normal file
99
packages/react/src/components/DTCard.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* DTCard — Beveled card with header, progress bar, and mode colors.
|
||||||
|
*
|
||||||
|
* CSS reference: bevels.css .card / .dt-bevel-card, .card-title,
|
||||||
|
* ::after progress bar, .mode-*
|
||||||
|
*
|
||||||
|
* The progress bar is a structural element on the left edge (0 to bevel-sm).
|
||||||
|
* It is always present. At 0 progress it shows surface color (empty bar).
|
||||||
|
* As progress increases, accent color fills from top to bottom.
|
||||||
|
* When progress is omitted, the bar stays empty (full card, no fill).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode, CSSProperties } from 'react';
|
||||||
|
import type { DTVariant } from '@dangerousthings/tokens';
|
||||||
|
import { cx } from '../utils/cx';
|
||||||
|
import { getVariantClass } from '../utils/variantClasses';
|
||||||
|
|
||||||
|
interface DTCardProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Color variant @default 'normal' */
|
||||||
|
variant?: DTVariant;
|
||||||
|
/** Card title (displayed in header bar) */
|
||||||
|
title?: string;
|
||||||
|
/** Whether to show the accent header bar @default true when title is provided */
|
||||||
|
showHeader?: boolean;
|
||||||
|
/** Dismiss callback — renders a close button in the header bar */
|
||||||
|
onDismiss?: () => void;
|
||||||
|
/** Progress value (0–100) for left-edge vertical progress bar. Omit for empty bar. */
|
||||||
|
progress?: number;
|
||||||
|
/** Grow to fill available space in flex/grid containers @default false */
|
||||||
|
grow?: boolean;
|
||||||
|
/** Which area expands when grow is true @default 'body' */
|
||||||
|
growArea?: 'body' | 'title';
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
/** Badge content rendered at bottom-right, clipped by card bevel */
|
||||||
|
badge?: ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DTCard({
|
||||||
|
children,
|
||||||
|
variant = 'normal',
|
||||||
|
title,
|
||||||
|
showHeader,
|
||||||
|
onDismiss,
|
||||||
|
progress,
|
||||||
|
grow = false,
|
||||||
|
growArea = 'body',
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
onClick,
|
||||||
|
badge,
|
||||||
|
}: DTCardProps) {
|
||||||
|
const shouldShowHeader = showHeader ?? !!(title || onDismiss);
|
||||||
|
const Tag = onClick ? 'button' : 'div';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
className={cx(
|
||||||
|
'card',
|
||||||
|
getVariantClass(variant),
|
||||||
|
grow && 'dt-card-grow',
|
||||||
|
grow && growArea === 'title' && 'dt-card-grow-title',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
...(progress != null && { '--dt-card-progress': progress }),
|
||||||
|
} as CSSProperties}
|
||||||
|
onClick={onClick}
|
||||||
|
type={onClick ? 'button' : undefined}>
|
||||||
|
{shouldShowHeader && (
|
||||||
|
<div className="card-title" style={onDismiss ? { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } : undefined}>
|
||||||
|
<span>{title}</span>
|
||||||
|
{onDismiss && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onDismiss(); }}
|
||||||
|
aria-label="Close"
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: 'inherit',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
fontSize: '1.25rem',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
{badge && <div className="dt-card-badge">{badge}</div>}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
packages/react/src/components/DTCheckbox.tsx
Normal file
46
packages/react/src/components/DTCheckbox.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* DTCheckbox — Beveled diamond checkbox.
|
||||||
|
*
|
||||||
|
* CSS reference: forms-dt.css input[type="checkbox"]
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
import { cx } from '../utils/cx';
|
||||||
|
|
||||||
|
interface DTCheckboxProps {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
label?: string;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DTCheckbox({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
label,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: DTCheckboxProps) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className={cx('dt-checkbox-wrapper', className)}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
...style,
|
||||||
|
}}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={e => onChange(e.target.checked)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
{label && <span>{label}</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
packages/react/src/components/DTChip.tsx
Normal file
47
packages/react/src/components/DTChip.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* DTChip — Small badge/chip with variant-based status colors.
|
||||||
|
*
|
||||||
|
* CSS reference: bevels.css .badge, .badge-success, .badge-error, etc.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode, CSSProperties } from 'react';
|
||||||
|
import type { DTVariant } from '@dangerousthings/tokens';
|
||||||
|
import { cx } from '../utils/cx';
|
||||||
|
import { getVariantClass, variantToBadgeClass } from '../utils/variantClasses';
|
||||||
|
|
||||||
|
interface DTChipProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Color variant @default 'normal' */
|
||||||
|
variant?: DTVariant;
|
||||||
|
/** Use mode-colored fill instead of status border color */
|
||||||
|
filled?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DTChip({
|
||||||
|
children,
|
||||||
|
variant = 'normal',
|
||||||
|
filled = false,
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: DTChipProps) {
|
||||||
|
const Tag = onClick ? 'button' : 'span';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
className={cx(
|
||||||
|
'badge',
|
||||||
|
filled ? 'badge-mode' : variantToBadgeClass(variant),
|
||||||
|
getVariantClass(variant),
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
style={style}
|
||||||
|
type={onClick ? 'button' : undefined}>
|
||||||
|
{children}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
202
packages/react/src/components/DTDrawer.tsx
Normal file
202
packages/react/src/components/DTDrawer.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* DTDrawer — Sliding side panel with beveled edges.
|
||||||
|
*
|
||||||
|
* Matches the dt-shopify-storefront Aside pattern:
|
||||||
|
* - Outer colored shell (mode color) with beveled clip-path
|
||||||
|
* - Inner dark surface with inset bevel clip-path (creates colored border)
|
||||||
|
* - Colored header bar with bold heading + close button
|
||||||
|
* - Scrollable content area
|
||||||
|
* - Slides in and out from the correct edge
|
||||||
|
*
|
||||||
|
* CSS reference: bevels.css .dt-bevel-drawer-right, .dt-bevel-drawer-left
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback, useRef, type ReactNode, type CSSProperties } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import type { DTVariant } from '@dangerousthings/tokens';
|
||||||
|
import { cx } from '../utils/cx';
|
||||||
|
import { getVariantClass } from '../utils/variantClasses';
|
||||||
|
|
||||||
|
const DURATION = 200;
|
||||||
|
|
||||||
|
interface DTDrawerProps {
|
||||||
|
visible: boolean;
|
||||||
|
onDismiss: () => void;
|
||||||
|
heading: string;
|
||||||
|
/** Color variant for heading bar @default 'emphasis' */
|
||||||
|
headingVariant?: DTVariant;
|
||||||
|
/** Slide direction @default 'right' */
|
||||||
|
position?: 'right' | 'left';
|
||||||
|
/** Panel width @default '400px' */
|
||||||
|
width?: string | number;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DTDrawer({
|
||||||
|
visible,
|
||||||
|
onDismiss,
|
||||||
|
heading,
|
||||||
|
headingVariant = 'emphasis',
|
||||||
|
position = 'right',
|
||||||
|
width = 400,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: DTDrawerProps) {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [closing, setClosing] = useState(false);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
// Open: mount immediately
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setClosing(false);
|
||||||
|
setMounted(true);
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
// Close: play exit animation, then unmount
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible && mounted && !closing) {
|
||||||
|
setClosing(true);
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
setMounted(false);
|
||||||
|
setClosing(false);
|
||||||
|
}, DURATION);
|
||||||
|
}
|
||||||
|
return () => clearTimeout(timerRef.current);
|
||||||
|
}, [visible, mounted, closing]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onDismiss();
|
||||||
|
},
|
||||||
|
[onDismiss],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mounted && !closing) {
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}
|
||||||
|
}, [mounted, closing, handleKeyDown]);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
const bevelClass = position === 'right' ? 'dt-bevel-drawer-right' : 'dt-bevel-drawer-left';
|
||||||
|
const innerBevelClass = position === 'right' ? 'dt-bevel-drawer-right-inner' : 'dt-bevel-drawer-left-inner';
|
||||||
|
const widthValue = typeof width === 'number' ? `${width}px` : width;
|
||||||
|
const varCSSVar = getVariantCSSVar(headingVariant);
|
||||||
|
|
||||||
|
// Slide direction for exit: reverse of entry
|
||||||
|
const slideOut = position === 'right' ? 'translateX(100%)' : 'translateX(-100%)';
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
pointerEvents: closing ? 'none' : undefined,
|
||||||
|
}}>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(0, 0, 0, 0.2)',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
animation: closing ? undefined : `dt-fade-in 0.4s ease-in-out both`,
|
||||||
|
opacity: closing ? 0 : undefined,
|
||||||
|
transition: closing ? `opacity ${DURATION}ms ease-in-out` : undefined,
|
||||||
|
}}
|
||||||
|
onClick={closing ? undefined : onDismiss}
|
||||||
|
/>
|
||||||
|
{/* Outer shell */}
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
bevelClass,
|
||||||
|
getVariantClass(headingVariant),
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
[position]: 0,
|
||||||
|
height: '99vh',
|
||||||
|
width: widthValue,
|
||||||
|
maxWidth: '100vw',
|
||||||
|
background: `var(${varCSSVar}, var(--color-primary))`,
|
||||||
|
padding: '3px',
|
||||||
|
paddingRight: position === 'right' ? 0 : '3px',
|
||||||
|
paddingLeft: position === 'left' ? 0 : '3px',
|
||||||
|
boxShadow: '0 0 50px rgba(0, 0, 0, 0.3)',
|
||||||
|
animation: closing ? undefined : `dt-slide-${position} ${DURATION}ms ease-in-out both`,
|
||||||
|
transform: closing ? slideOut : undefined,
|
||||||
|
transition: closing ? `transform ${DURATION}ms ease-in-out` : undefined,
|
||||||
|
...style,
|
||||||
|
}}>
|
||||||
|
{/* Inner surface */}
|
||||||
|
<div
|
||||||
|
className={innerBevelClass}
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
background: 'var(--color-bg)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 64,
|
||||||
|
padding: '0 1em',
|
||||||
|
background: `var(${varCSSVar}, var(--color-primary))`,
|
||||||
|
color: 'var(--color-bg)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
fontWeight: 900,
|
||||||
|
fontSize: '2em',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
borderBottom: '1px solid var(--color-bg)',
|
||||||
|
}}>
|
||||||
|
<span>{heading}</span>
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: 'inherit',
|
||||||
|
fontSize: '0.6em',
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '0 8px',
|
||||||
|
opacity: 0.8,
|
||||||
|
}}
|
||||||
|
type="button">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Content */}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', padding: '16px' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVariantCSSVar(variant: DTVariant): string {
|
||||||
|
const map: Record<DTVariant, string> = {
|
||||||
|
normal: '--mode-normal',
|
||||||
|
emphasis: '--mode-emphasis',
|
||||||
|
warning: '--mode-warning',
|
||||||
|
success: '--mode-success',
|
||||||
|
other: '--mode-other',
|
||||||
|
};
|
||||||
|
return map[variant];
|
||||||
|
}
|
||||||
142
packages/react/src/components/DTFeatureLegend.tsx
Normal file
142
packages/react/src/components/DTFeatureLegend.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* DTFeatureLegend — Interactive grid of product feature icons with rotated labels.
|
||||||
|
*
|
||||||
|
* Matches dt-shopify-storefront UseCaseLegend: header bar with hover details,
|
||||||
|
* rotated vertical labels (above for row 0, below for row 1), ? toggle,
|
||||||
|
* bordered grid with dark cell backgrounds, and pulse-on-hover icons.
|
||||||
|
*
|
||||||
|
* CSS reference: feature-legend.css
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, type ReactNode, type CSSProperties } from 'react';
|
||||||
|
import type { DTVariant } from '@dangerousthings/tokens';
|
||||||
|
import { cx } from '../utils/cx';
|
||||||
|
import { getVariantClass } from '../utils/variantClasses';
|
||||||
|
|
||||||
|
export interface DTFeatureItem {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
state: 'supported' | 'disabled' | 'unsupported';
|
||||||
|
/** Optional detail text shown in the header on hover */
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateClassMap: Record<DTFeatureItem['state'], string> = {
|
||||||
|
supported: 'dt-feature-supported',
|
||||||
|
disabled: 'dt-feature-disabled',
|
||||||
|
unsupported: 'dt-feature-unsupported',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DTFeatureLegendProps {
|
||||||
|
features: DTFeatureItem[];
|
||||||
|
title?: string;
|
||||||
|
/** Header variant @default 'normal' */
|
||||||
|
variant?: DTVariant;
|
||||||
|
/** Grid columns @default 5 */
|
||||||
|
columns?: number;
|
||||||
|
/** Icon size in px @default 42 */
|
||||||
|
iconSize?: number;
|
||||||
|
/** Show rotated labels initially @default true */
|
||||||
|
showLabels?: boolean;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DTFeatureLegend({
|
||||||
|
features,
|
||||||
|
title,
|
||||||
|
variant = 'normal',
|
||||||
|
columns = 5,
|
||||||
|
iconSize = 42,
|
||||||
|
showLabels: initialShowLabels = true,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: DTFeatureLegendProps) {
|
||||||
|
const [showLabels, setShowLabels] = useState(initialShowLabels);
|
||||||
|
const [hoveredFeature, setHoveredFeature] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const hoveredItem = hoveredFeature
|
||||||
|
? features.find(f => f.key === hoveredFeature)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const rowCount = Math.ceil(features.length / columns);
|
||||||
|
const hasBottomRow = rowCount >= 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx('dt-feature-legend', getVariantClass(variant), className)}
|
||||||
|
style={style}>
|
||||||
|
|
||||||
|
{/* Header bar — title + hovered feature detail + ? toggle */}
|
||||||
|
{title && (
|
||||||
|
<div className="dt-feature-legend-header">
|
||||||
|
<div className="dt-feature-legend-header-content">
|
||||||
|
<div className="dt-feature-legend-title">{title}</div>
|
||||||
|
<div className="dt-feature-legend-detail">
|
||||||
|
{hoveredItem ? (
|
||||||
|
<span className={stateClassMap[hoveredItem.state]}>
|
||||||
|
{hoveredItem.name}{hoveredItem.detail ? ': ' : ''}
|
||||||
|
{hoveredItem.detail && <strong>{hoveredItem.detail}</strong>}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="dt-feature-legend-hint">
|
||||||
|
Hover a feature for details
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="dt-feature-legend-toggle"
|
||||||
|
onClick={() => setShowLabels(v => !v)}
|
||||||
|
aria-label={showLabels ? 'Hide feature labels' : 'Show feature labels'}
|
||||||
|
title={showLabels ? 'Hide labels' : 'Show labels'}
|
||||||
|
type="button">
|
||||||
|
?
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Icon grid with border */}
|
||||||
|
<div className="dt-feature-legend-body">
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'dt-feature-legend-grid',
|
||||||
|
showLabels && 'with-labels',
|
||||||
|
showLabels && hasBottomRow && 'with-labels-bottom',
|
||||||
|
)}
|
||||||
|
style={{ '--dt-feature-columns': columns } as CSSProperties}>
|
||||||
|
{features.map((feature, index) => {
|
||||||
|
const row = Math.floor(index / columns);
|
||||||
|
const labelPosition = rowCount >= 2 ? (row === 0 ? 'above' : 'below') : 'above';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={feature.key}
|
||||||
|
className={cx('dt-feature-legend-item', stateClassMap[feature.state])}
|
||||||
|
style={{ width: `${100 / columns}%` }}
|
||||||
|
onMouseEnter={() => setHoveredFeature(feature.key)}
|
||||||
|
onMouseLeave={() => setHoveredFeature(null)}>
|
||||||
|
|
||||||
|
{/* Rotated vertical label — always in DOM, visibility via CSS */}
|
||||||
|
<div className={cx(
|
||||||
|
'dt-feature-legend-rotated-label',
|
||||||
|
labelPosition === 'below' ? 'label-below' : 'label-above',
|
||||||
|
showLabels && 'label-visible',
|
||||||
|
)}>
|
||||||
|
<span>{feature.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="dt-feature-legend-icon"
|
||||||
|
style={{ width: `${iconSize}px`, height: `${iconSize}px` }}>
|
||||||
|
{feature.icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
242
packages/react/src/components/DTGallery.tsx
Normal file
242
packages/react/src/components/DTGallery.tsx
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
/**
|
||||||
|
* DTGallery — Media gallery with beveled frame and thumbnails.
|
||||||
|
* Supports images, 3D models (via <model-viewer>), videos, and external videos.
|
||||||
|
*
|
||||||
|
* CSS reference: bevels.css .dt-bevel-media
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, type CSSProperties } from 'react';
|
||||||
|
import type { DTVariant } from '@dangerousthings/tokens';
|
||||||
|
import { cx } from '../utils/cx';
|
||||||
|
import { getVariantClass } from '../utils/variantClasses';
|
||||||
|
|
||||||
|
/* ── TypeScript declaration for <model-viewer> web component ── */
|
||||||
|
declare global {
|
||||||
|
namespace JSX {
|
||||||
|
interface IntrinsicElements {
|
||||||
|
'model-viewer': React.DetailedHTMLProps<
|
||||||
|
React.HTMLAttributes<HTMLElement>,
|
||||||
|
HTMLElement
|
||||||
|
> & {
|
||||||
|
src?: string;
|
||||||
|
alt?: string;
|
||||||
|
poster?: string;
|
||||||
|
'camera-controls'?: boolean | string;
|
||||||
|
'auto-rotate'?: boolean | string;
|
||||||
|
'interaction-prompt'?: string;
|
||||||
|
ar?: boolean | string;
|
||||||
|
loading?: 'auto' | 'lazy' | 'eager';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Gallery item types ── */
|
||||||
|
|
||||||
|
export type DTGalleryItem =
|
||||||
|
| { type?: 'image'; key: string; src: string; alt?: string; thumbnail?: string }
|
||||||
|
| { type: 'model3d'; key: string; src: string; alt?: string; thumbnail?: string; poster?: string }
|
||||||
|
| { type: 'video'; key: string; src: string; alt?: string; thumbnail?: string; poster?: string }
|
||||||
|
| { type: 'external-video'; key: string; src: string; alt?: string; thumbnail?: string };
|
||||||
|
|
||||||
|
interface DTGalleryProps {
|
||||||
|
items: DTGalleryItem[];
|
||||||
|
/** Externally-controlled active index. When this value changes, the gallery
|
||||||
|
* jumps to the corresponding item. Thumbnail clicks still work normally. */
|
||||||
|
activeIndex?: number;
|
||||||
|
/** Color variant @default 'normal' */
|
||||||
|
variant?: DTVariant;
|
||||||
|
/** Aspect ratio (width / height) for the display area @default 1 */
|
||||||
|
aspectRatio?: number;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Model-viewer script loader ── */
|
||||||
|
|
||||||
|
const MODEL_VIEWER_SRC =
|
||||||
|
'https://unpkg.com/@google/model-viewer@v1.12.1/dist/model-viewer.min.js';
|
||||||
|
|
||||||
|
let modelViewerLoaded = false;
|
||||||
|
|
||||||
|
function useModelViewerScript(needed: boolean) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!needed || modelViewerLoaded || typeof document === 'undefined') return;
|
||||||
|
if (customElements.get('model-viewer')) {
|
||||||
|
modelViewerLoaded = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.type = 'module';
|
||||||
|
script.src = MODEL_VIEWER_SRC;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
modelViewerLoaded = true;
|
||||||
|
}, [needed]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Shared styles ── */
|
||||||
|
|
||||||
|
const fillStyle: CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'block',
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Component ── */
|
||||||
|
|
||||||
|
export function DTGallery({
|
||||||
|
items,
|
||||||
|
activeIndex: activeIndexProp,
|
||||||
|
variant = 'normal',
|
||||||
|
aspectRatio = 1,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: DTGalleryProps) {
|
||||||
|
const [activeIndex, setActiveIndex] = useState(activeIndexProp ?? 0);
|
||||||
|
|
||||||
|
// Sync external activeIndex prop to internal state when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeIndexProp != null && activeIndexProp >= 0 && activeIndexProp < items.length) {
|
||||||
|
setActiveIndex(activeIndexProp);
|
||||||
|
}
|
||||||
|
}, [activeIndexProp, items.length]);
|
||||||
|
|
||||||
|
const hasModel = items.some((i) => i.type === 'model3d');
|
||||||
|
useModelViewerScript(hasModel);
|
||||||
|
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
const current = items[activeIndex];
|
||||||
|
const currentType = current.type || 'image';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cx(getVariantClass(variant), className)} style={style}>
|
||||||
|
{/* Main display */}
|
||||||
|
<div
|
||||||
|
className="dt-bevel-media"
|
||||||
|
style={{ overflow: 'hidden', aspectRatio, position: 'relative' }}
|
||||||
|
>
|
||||||
|
{currentType === 'image' && (
|
||||||
|
<img
|
||||||
|
src={current.src}
|
||||||
|
alt={current.alt || ''}
|
||||||
|
style={{
|
||||||
|
...fillStyle,
|
||||||
|
objectFit: 'contain',
|
||||||
|
transition: 'opacity 300ms ease-in-out',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentType === 'model3d' && (
|
||||||
|
<model-viewer
|
||||||
|
src={current.src}
|
||||||
|
alt={current.alt || ''}
|
||||||
|
poster={'poster' in current ? current.poster : undefined}
|
||||||
|
camera-controls=""
|
||||||
|
auto-rotate=""
|
||||||
|
interaction-prompt="auto"
|
||||||
|
loading="lazy"
|
||||||
|
style={{
|
||||||
|
...fillStyle,
|
||||||
|
backgroundColor: 'var(--color-bg, #000)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentType === 'video' && (
|
||||||
|
<video
|
||||||
|
src={current.src}
|
||||||
|
poster={'poster' in current ? current.poster : undefined}
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
style={{
|
||||||
|
...fillStyle,
|
||||||
|
objectFit: 'contain',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentType === 'external-video' && (
|
||||||
|
<iframe
|
||||||
|
src={current.src}
|
||||||
|
title={current.alt || 'Video'}
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowFullScreen
|
||||||
|
style={{
|
||||||
|
...fillStyle,
|
||||||
|
border: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thumbnails */}
|
||||||
|
{items.length > 1 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
marginTop: '8px',
|
||||||
|
overflowX: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((item, i) => {
|
||||||
|
const itemType = item.type || 'image';
|
||||||
|
const thumbSrc = item.thumbnail || ('poster' in item ? item.poster : undefined) || item.src;
|
||||||
|
const isMedia = itemType === 'video' || itemType === 'external-video';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
onClick={() => setActiveIndex(i)}
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
width: '64px',
|
||||||
|
height: '64px',
|
||||||
|
padding: 0,
|
||||||
|
border:
|
||||||
|
i === activeIndex
|
||||||
|
? '2px solid var(--dt-card-color, var(--color-primary))'
|
||||||
|
: '2px solid transparent',
|
||||||
|
background: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={thumbSrc}
|
||||||
|
alt={item.alt || ''}
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
{/* Play icon overlay for video thumbnails */}
|
||||||
|
{isMedia && (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="white"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
margin: 'auto',
|
||||||
|
width: '24px',
|
||||||
|
height: '24px',
|
||||||
|
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.6))',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
packages/react/src/components/DTHexagon.tsx
Normal file
109
packages/react/src/components/DTHexagon.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* DTHexagon — Decorative SVG hexagon with optional animations.
|
||||||
|
*
|
||||||
|
* Uses inline SVG (not CSS classes) — same approach as the RN version.
|
||||||
|
* Animation classes from animations.css applied to wrapper div.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode, CSSProperties } from 'react';
|
||||||
|
import type { DTVariant } from '@dangerousthings/tokens';
|
||||||
|
import { cx } from '../utils/cx';
|
||||||
|
import { getVariantClass } from '../utils/variantClasses';
|
||||||
|
|
||||||
|
interface DTHexagonProps {
|
||||||
|
/** Diameter in pixels */
|
||||||
|
size: number;
|
||||||
|
/** Color variant @default 'normal' */
|
||||||
|
variant?: DTVariant;
|
||||||
|
/** Filled or outline @default true */
|
||||||
|
filled?: boolean;
|
||||||
|
/** Enable animation @default false */
|
||||||
|
animated?: boolean;
|
||||||
|
/** Animation type @default 'rotate' */
|
||||||
|
animationType?: 'rotate' | 'pulse' | 'none';
|
||||||
|
/** Animation duration in ms @default 2000 */
|
||||||
|
animationDuration?: number;
|
||||||
|
/** Outline stroke width @default 2 */
|
||||||
|
borderWidth?: number;
|
||||||
|
/** Custom color override */
|
||||||
|
color?: string;
|
||||||
|
/** Opacity @default 1 */
|
||||||
|
opacity?: number;
|
||||||
|
/** Centered content inside hexagon */
|
||||||
|
children?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flat-top hexagon points for given size */
|
||||||
|
function hexPoints(size: number): string {
|
||||||
|
const r = size / 2;
|
||||||
|
const cx = r;
|
||||||
|
const cy = r;
|
||||||
|
const points: [number, number][] = [];
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const angle = (Math.PI / 3) * i - Math.PI / 6;
|
||||||
|
points.push([cx + r * Math.cos(angle), cy + r * Math.sin(angle)]);
|
||||||
|
}
|
||||||
|
return points.map(([x, y]) => `${x},${y}`).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DTHexagon({
|
||||||
|
size,
|
||||||
|
variant = 'normal',
|
||||||
|
filled = true,
|
||||||
|
animated = false,
|
||||||
|
animationType = 'rotate',
|
||||||
|
animationDuration = 2000,
|
||||||
|
borderWidth = 2,
|
||||||
|
color,
|
||||||
|
opacity = 1,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: DTHexagonProps) {
|
||||||
|
const animClass = animated
|
||||||
|
? animationType === 'rotate'
|
||||||
|
? 'dt-animate-spin'
|
||||||
|
: animationType === 'pulse'
|
||||||
|
? 'dt-animate-pulse'
|
||||||
|
: undefined
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(getVariantClass(variant), animClass, className)}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
position: 'relative',
|
||||||
|
opacity,
|
||||||
|
...(animated && animationDuration !== 2000
|
||||||
|
? { animationDuration: `${animationDuration}ms` }
|
||||||
|
: {}),
|
||||||
|
...style,
|
||||||
|
}}>
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox={`0 0 ${size} ${size}`}
|
||||||
|
style={{ position: 'absolute', top: 0, left: 0 }}>
|
||||||
|
<polygon
|
||||||
|
points={hexPoints(size - borderWidth)}
|
||||||
|
transform={`translate(${borderWidth / 2}, ${borderWidth / 2})`}
|
||||||
|
fill={filled ? (color || 'var(--dt-card-color, var(--color-primary))') : 'none'}
|
||||||
|
stroke={color || 'var(--dt-card-color, var(--color-primary))'}
|
||||||
|
strokeWidth={borderWidth}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{children && (
|
||||||
|
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
packages/react/src/components/DTLabel.tsx
Normal file
33
packages/react/src/components/DTLabel.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* DTLabel — Filled badge/label with top-right bevel and mode color.
|
||||||
|
*
|
||||||
|
* CSS reference: bevels.css .badge, .badge-mode, .mode-*
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode, CSSProperties } from 'react';
|
||||||
|
import type { DTVariant } from '@dangerousthings/tokens';
|
||||||
|
import { cx } from '../utils/cx';
|
||||||
|
import { getVariantClass } from '../utils/variantClasses';
|
||||||
|
|
||||||
|
interface DTLabelProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Color variant @default 'normal' */
|
||||||
|
variant?: DTVariant;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DTLabel({
|
||||||
|
children,
|
||||||
|
variant = 'normal',
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: DTLabelProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cx('badge', 'p-3', 'badge-mode', getVariantClass(variant), className)}
|
||||||
|
style={style}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
packages/react/src/components/DTMediaFrame.tsx
Normal file
44
packages/react/src/components/DTMediaFrame.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* DTMediaFrame — Diagonal beveled frame (top-left + bottom-right).
|
||||||
|
*
|
||||||
|
* CSS reference: bevels.css .dt-bevel-media
|
||||||
|
*
|
||||||
|
* The outer div provides the colored bevel border via clip-path.
|
||||||
|
* A ::before pseudo-element fills the inner surface.
|
||||||
|
* Direct img/video children are auto-clipped to match.
|
||||||
|
* A ::after pseudo-element shows "MISSING MEDIA" when empty.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode, CSSProperties } from 'react';
|
||||||
|
import type { DTVariant } from '@dangerousthings/tokens';
|
||||||
|
import { cx } from '../utils/cx';
|
||||||
|
import { getVariantClass } from '../utils/variantClasses';
|
||||||
|
|
||||||
|
interface DTMediaFrameProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Color variant for the bevel border @default 'normal' */
|
||||||
|
variant?: DTVariant;
|
||||||
|
/** Custom placeholder shown when children is nullish (overrides CSS ::after) */
|
||||||
|
placeholder?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DTMediaFrame({
|
||||||
|
children,
|
||||||
|
variant = 'normal',
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: DTMediaFrameProps) {
|
||||||
|
const isEmpty = children == null || children === false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx('dt-bevel-media', getVariantClass(variant), className)}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{isEmpty && placeholder ? placeholder : children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
packages/react/src/components/DTMenu.tsx
Normal file
108
packages/react/src/components/DTMenu.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* DTMenu — Hierarchical menu with beveled items.
|
||||||
|
*
|
||||||
|
* CSS reference: forms-dt.css .dt-menu-item, .active, .selected
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, type ReactNode, type CSSProperties } from 'react';
|
||||||
|
import type { DTVariant } from '@dangerousthings/tokens';
|
||||||
|
import { cx } from '../utils/cx';
|
||||||
|
import { getVariantClass } from '../utils/variantClasses';
|
||||||
|
|
||||||
|
export interface DTMenuItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
items?: DTMenuItem[];
|
||||||
|
isActive?: boolean;
|
||||||
|
isSelected?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DTMenuProps {
|
||||||
|
items: DTMenuItem[];
|
||||||
|
/** Color variant @default 'normal' */
|
||||||
|
variant?: DTVariant;
|
||||||
|
onItemClick?: (id: string) => void;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DTMenu({
|
||||||
|
items,
|
||||||
|
variant = 'normal',
|
||||||
|
onItemClick,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: DTMenuProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(getVariantClass(variant), className)}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '4px', ...style }}>
|
||||||
|
{items.map(item => (
|
||||||
|
<MenuItem
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
level={0}
|
||||||
|
onItemClick={onItemClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuItem({
|
||||||
|
item,
|
||||||
|
level,
|
||||||
|
onItemClick,
|
||||||
|
}: {
|
||||||
|
item: DTMenuItem;
|
||||||
|
level: number;
|
||||||
|
onItemClick?: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const hasChildren = item.items && item.items.length > 0;
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (hasChildren) {
|
||||||
|
setExpanded(prev => !prev);
|
||||||
|
}
|
||||||
|
onItemClick?.(item.id);
|
||||||
|
}, [hasChildren, item.id, onItemClick]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className={cx(
|
||||||
|
'dt-menu-item',
|
||||||
|
item.isActive && 'active',
|
||||||
|
item.isSelected && 'selected',
|
||||||
|
)}
|
||||||
|
style={{ '--dt-menu-level': level } as CSSProperties}
|
||||||
|
onClick={handleClick}
|
||||||
|
aria-expanded={hasChildren ? expanded : undefined}
|
||||||
|
type="button">
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
{item.icon}
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
{hasChildren && (
|
||||||
|
<span
|
||||||
|
className="dt-accordion-chevron"
|
||||||
|
style={{
|
||||||
|
transition: 'transform 250ms ease-in-out',
|
||||||
|
transform: expanded ? 'rotate(180deg)' : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{hasChildren && expanded && item.items!.map(child => (
|
||||||
|
<MenuItem
|
||||||
|
key={child.id}
|
||||||
|
item={child}
|
||||||
|
level={level + 1}
|
||||||
|
onItemClick={onItemClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
packages/react/src/components/DTMobileFilterOverlay.tsx
Normal file
168
packages/react/src/components/DTMobileFilterOverlay.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* DTMobileFilterOverlay — Full-screen slide-up filter panel.
|
||||||
|
*
|
||||||
|
* CSS reference: forms-dt.css .dt-filter-overlay, .dt-filter-overlay-backdrop,
|
||||||
|
* .dt-filter-overlay-content
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useCallback, useRef, type ReactNode, type CSSProperties } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import type { DTVariant } from '@dangerousthings/tokens';
|
||||||
|
import { cx } from '../utils/cx';
|
||||||
|
import { getVariantClass } from '../utils/variantClasses';
|
||||||
|
|
||||||
|
interface DTMobileFilterOverlayProps {
|
||||||
|
visible: boolean;
|
||||||
|
onDismiss: () => void;
|
||||||
|
/** Heading text @default 'Filters' */
|
||||||
|
heading?: string;
|
||||||
|
/** Number of active filters (shown as badge) */
|
||||||
|
activeFilterCount?: number;
|
||||||
|
/** Called when "Clear All" is pressed */
|
||||||
|
onClearAll?: () => void;
|
||||||
|
/** Color variant @default 'normal' */
|
||||||
|
variant?: DTVariant;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DTMobileFilterOverlay({
|
||||||
|
visible,
|
||||||
|
onDismiss,
|
||||||
|
heading = 'Filters',
|
||||||
|
activeFilterCount,
|
||||||
|
onClearAll,
|
||||||
|
variant = 'normal',
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: DTMobileFilterOverlayProps) {
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const triggerRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onDismiss();
|
||||||
|
},
|
||||||
|
[onDismiss],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Escape key
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}
|
||||||
|
}, [visible, handleKeyDown]);
|
||||||
|
|
||||||
|
// Body scroll lock
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => { document.body.style.overflow = ''; };
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
// Focus trap
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
triggerRef.current = document.activeElement as HTMLElement;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const first = panelRef.current?.querySelector<HTMLElement>(
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||||
|
);
|
||||||
|
first?.focus();
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
const handleTab = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Tab' || !panelRef.current) return;
|
||||||
|
const focusable = panelRef.current.querySelectorAll<HTMLElement>(
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||||
|
);
|
||||||
|
if (focusable.length === 0) return;
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
if (e.shiftKey && document.activeElement === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!e.shiftKey && document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleTab);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
document.removeEventListener('keydown', handleTab);
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
};
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className={cx('dt-filter-overlay', getVariantClass(variant), className)} style={style}>
|
||||||
|
<div className="dt-filter-overlay-backdrop" onClick={onDismiss} />
|
||||||
|
<div ref={panelRef} className="dt-filter-overlay-content">
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '16px',
|
||||||
|
borderBottom: '1px solid rgba(var(--color-primary-rgb), 0.2)',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: '1.125rem', letterSpacing: '0.05em' }}>
|
||||||
|
{heading}
|
||||||
|
{activeFilterCount !== undefined && activeFilterCount > 0 && (
|
||||||
|
<span
|
||||||
|
className="badge badge-mode"
|
||||||
|
style={{ marginLeft: '8px', fontSize: '0.75rem' }}>
|
||||||
|
{activeFilterCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
|
{onClearAll && activeFilterCount !== undefined && activeFilterCount > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={onClearAll}
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
Clear All
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
fontSize: '20px',
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '0 4px',
|
||||||
|
}}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Content */}
|
||||||
|
<div style={{ padding: '16px', overflow: 'auto', maxHeight: 'calc(85vh - 60px)' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
118
packages/react/src/components/DTModal.tsx
Normal file
118
packages/react/src/components/DTModal.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* DTModal — Portal-based modal with beveled card content.
|
||||||
|
*
|
||||||
|
* CSS reference: bevels.css .dt-bevel-modal, .card, .mode-*
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useCallback, type ReactNode, type CSSProperties } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import type { DTVariant } from '@dangerousthings/tokens';
|
||||||
|
import { cx } from '../utils/cx';
|
||||||
|
import { getVariantClass } from '../utils/variantClasses';
|
||||||
|
|
||||||
|
interface DTModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onDismiss: () => void;
|
||||||
|
title?: string;
|
||||||
|
/** Color variant @default 'normal' */
|
||||||
|
variant?: DTVariant;
|
||||||
|
children: ReactNode;
|
||||||
|
/** Whether clicking backdrop dismisses @default true */
|
||||||
|
dismissable?: boolean;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DTModal({
|
||||||
|
visible,
|
||||||
|
onDismiss,
|
||||||
|
title,
|
||||||
|
variant = 'normal',
|
||||||
|
children,
|
||||||
|
dismissable = true,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: DTModalProps) {
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && dismissable) onDismiss();
|
||||||
|
},
|
||||||
|
[onDismiss, dismissable],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}
|
||||||
|
}, [visible, handleKeyDown]);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="dt-modal-overlay"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
}}>
|
||||||
|
<div
|
||||||
|
className="dt-modal-backdrop"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
}}
|
||||||
|
onClick={dismissable ? onDismiss : undefined}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'card',
|
||||||
|
'dt-bevel-modal',
|
||||||
|
getVariantClass(variant),
|
||||||
|
'dt-animate-scale-in',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
maxWidth: '90vw',
|
||||||
|
maxHeight: '85vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true">
|
||||||
|
{title && (
|
||||||
|
<div className="card-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0 }}>
|
||||||
|
<span>{title}</span>
|
||||||
|
{dismissable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDismiss}
|
||||||
|
aria-label="Close"
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: 'inherit',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
fontSize: '1.25rem',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ padding: 'var(--space-6, 24px)', overflow: 'auto', flex: '1 1 auto' }}>{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
45
packages/react/src/components/DTProgressBar.tsx
Normal file
45
packages/react/src/components/DTProgressBar.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* DTProgressBar — Angular progress bar (horizontal or vertical).
|
||||||
|
*
|
||||||
|
* CSS reference: forms-dt.css .dt-progress, .dt-progress-fill, .dt-progress-label
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
import { cx } from '../utils/cx';
|
||||||
|
|
||||||
|
interface DTProgressBarProps {
|
||||||
|
/** Progress value (0–1) */
|
||||||
|
value: number;
|
||||||
|
/** Text label below/beside the bar */
|
||||||
|
label?: string;
|
||||||
|
/** Layout direction @default 'horizontal' */
|
||||||
|
direction?: 'horizontal' | 'vertical';
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DTProgressBar({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
direction = 'horizontal',
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: DTProgressBarProps) {
|
||||||
|
const pct = Math.round(Math.max(0, Math.min(1, value)) * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} style={style}>
|
||||||
|
<div className={cx('dt-progress', direction === 'vertical' && 'vertical')}>
|
||||||
|
<div
|
||||||
|
className="dt-progress-fill"
|
||||||
|
style={
|
||||||
|
direction === 'vertical'
|
||||||
|
? { height: `${pct}%` }
|
||||||
|
: { width: `${pct}%` }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{label && <span className="dt-progress-label">{label}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
packages/react/src/components/DTQuantityStepper.tsx
Normal file
50
packages/react/src/components/DTQuantityStepper.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* DTQuantityStepper — Beveled +/- stepper.
|
||||||
|
*
|
||||||
|
* CSS reference: forms-dt.css .dt-stepper, .dt-stepper-btn, .dt-stepper-value
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
import { cx } from '../utils/cx';
|
||||||
|
|
||||||
|
interface DTQuantityStepperProps {
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DTQuantityStepper({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
min = 0,
|
||||||
|
max = 99,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: DTQuantityStepperProps) {
|
||||||
|
return (
|
||||||
|
<div className={cx('dt-stepper', className)} style={style}>
|
||||||
|
<button
|
||||||
|
className="dt-stepper-btn"
|
||||||
|
onClick={() => onChange(Math.max(min, value - 1))}
|
||||||
|
disabled={disabled || value <= min}
|
||||||
|
type="button"
|
||||||
|
aria-label="Decrease">
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<span className="dt-stepper-value">{value}</span>
|
||||||
|
<button
|
||||||
|
className="dt-stepper-btn"
|
||||||
|
onClick={() => onChange(Math.min(max, value + 1))}
|
||||||
|
disabled={disabled || value >= max}
|
||||||
|
type="button"
|
||||||
|
aria-label="Increase">
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
packages/react/src/components/DTRadioGroup.tsx
Normal file
68
packages/react/src/components/DTRadioGroup.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* DTRadioGroup — Hexagonal radio buttons.
|
||||||
|
*
|
||||||
|
* Uses a custom span for the hexagon indicator since ::before pseudo-elements
|
||||||
|
* on <input> are unreliable in Chromium/Electron.
|
||||||
|
*
|
||||||
|
* CSS reference: forms-dt.css .dt-radio-hex, .dt-radio-option
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useId, type CSSProperties } from 'react';
|
||||||
|
import { cx } from '../utils/cx';
|
||||||
|
|
||||||
|
interface DTRadioOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DTRadioGroupProps {
|
||||||
|
options: DTRadioOption[];
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
name?: string;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { type DTRadioOption };
|
||||||
|
|
||||||
|
export function DTRadioGroup({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
name,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: DTRadioGroupProps) {
|
||||||
|
const groupId = useId();
|
||||||
|
const groupName = name || `dt-radio-${groupId}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx('dt-radio-group', className)}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: '4px', ...style }}
|
||||||
|
role="radiogroup">
|
||||||
|
{options.map(option => {
|
||||||
|
const checked = value === option.value;
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={option.value}
|
||||||
|
className={cx('dt-radio-option', checked && 'selected')}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={groupName}
|
||||||
|
value={option.value}
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => onChange(option.value)}
|
||||||
|
style={{ position: 'absolute', opacity: 0, width: 0, height: 0 }}
|
||||||
|
/>
|
||||||
|
<span className={cx('dt-radio-hex', checked && 'checked')}>
|
||||||
|
<span className="dt-radio-hex-inner" />
|
||||||
|
</span>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
packages/react/src/components/DTSearchInput.tsx
Normal file
70
packages/react/src/components/DTSearchInput.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* DTSearchInput — Search field with icon.
|
||||||
|
*
|
||||||
|
* CSS reference: forms-dt.css .input
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, type CSSProperties, type KeyboardEvent } from 'react';
|
||||||
|
import { cx } from '../utils/cx';
|
||||||
|
|
||||||
|
interface DTSearchInputProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
onSearch?: (value: string) => void;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DTSearchInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Search...',
|
||||||
|
onSearch,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: DTSearchInputProps) {
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && onSearch) {
|
||||||
|
onSearch(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSearch, value],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx('dt-search-input-wrapper', className)}
|
||||||
|
style={{ position: 'relative', ...style }}>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="input"
|
||||||
|
value={value}
|
||||||
|
onChange={e => onChange(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
style={{ width: '100%', padding: '8px 12px 8px 36px' }}
|
||||||
|
/>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '12px',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
opacity: 0.6,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
packages/react/src/components/DTStaggerContainer.tsx
Normal file
47
packages/react/src/components/DTStaggerContainer.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* DTStaggerContainer — Staggered scale-in animation for children.
|
||||||
|
*
|
||||||
|
* CSS reference: animations.css .dt-stagger-container
|
||||||
|
* Each child is wrapped in a div that carries the animationDelay.
|
||||||
|
* Wrapper divs inherit display:contents would break animation, so
|
||||||
|
* the CSS rule targets .dt-stagger-container > * directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Children, type ReactNode, type CSSProperties } from 'react';
|
||||||
|
import { cx } from '../utils/cx';
|
||||||
|
|
||||||
|
interface DTStaggerContainerProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Duration per child animation @default '0.33s' */
|
||||||
|
duration?: string;
|
||||||
|
/** Delay between each child in ms @default 150 */
|
||||||
|
interval?: number;
|
||||||
|
/** Base delay before the first child animates in ms @default 0 */
|
||||||
|
delay?: number;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DTStaggerContainer({
|
||||||
|
children,
|
||||||
|
duration,
|
||||||
|
interval = 150,
|
||||||
|
delay = 0,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: DTStaggerContainerProps) {
|
||||||
|
const cssVars: Record<string, string> = {};
|
||||||
|
if (duration) cssVars['--dt-stagger-duration'] = duration;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx('dt-stagger-container', className)}
|
||||||
|
style={{ ...cssVars, ...style } as CSSProperties}>
|
||||||
|
{Children.map(children, (child, i) => (
|
||||||
|
<div style={{ animationDelay: `${delay + i * interval}ms` }}>
|
||||||
|
{child}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
packages/react/src/components/DTSwitch.tsx
Normal file
44
packages/react/src/components/DTSwitch.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* DTSwitch — Angular toggle switch.
|
||||||
|
*
|
||||||
|
* CSS reference: forms-dt.css .dt-switch, .dt-switch-track, .dt-switch-thumb
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
import { cx } from '../utils/cx';
|
||||||
|
|
||||||
|
interface DTSwitchProps {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
label?: string;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DTSwitch({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
label,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: DTSwitchProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx('dt-switch-wrapper', className)}
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: '12px', ...style }}>
|
||||||
|
<label className="dt-switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={e => onChange(e.target.checked)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<span className="dt-switch-track" />
|
||||||
|
<span className="dt-switch-thumb" />
|
||||||
|
</label>
|
||||||
|
{label && <span>{label}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
packages/react/src/components/DTTextInput.tsx
Normal file
52
packages/react/src/components/DTTextInput.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* DTTextInput — Styled text input with focus glow.
|
||||||
|
*
|
||||||
|
* CSS reference: forms-dt.css input[type="text"], .input, .error
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CSSProperties, InputHTMLAttributes } from 'react';
|
||||||
|
import { cx } from '../utils/cx';
|
||||||
|
|
||||||
|
interface DTTextInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'className' | 'style'> {
|
||||||
|
/** Show error state */
|
||||||
|
error?: boolean;
|
||||||
|
/** Label text above input */
|
||||||
|
label?: string;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DTTextInput({
|
||||||
|
error = false,
|
||||||
|
label,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
id,
|
||||||
|
...inputProps
|
||||||
|
}: DTTextInputProps) {
|
||||||
|
const inputId = id || (label ? `dt-input-${label.replace(/\s+/g, '-').toLowerCase()}` : undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cx('dt-text-input-wrapper', className)} style={style}>
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
htmlFor={inputId}
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '4px',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
}}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
{...inputProps}
|
||||||
|
id={inputId}
|
||||||
|
className={cx('input', error && 'error')}
|
||||||
|
style={{ width: '100%', padding: '8px 12px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
packages/react/src/components/DTWebThemeProvider.tsx
Normal file
57
packages/react/src/components/DTWebThemeProvider.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* DTWebThemeProvider
|
||||||
|
*
|
||||||
|
* Sets data-brand and data-theme attributes on a wrapper div (default)
|
||||||
|
* or on document.documentElement (documentLevel mode).
|
||||||
|
* Provides React context for child components to read the active brand/theme.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createContext, useEffect, type ReactNode } from 'react';
|
||||||
|
import type { ThemeBrand, ThemeMode } from '@dangerousthings/tokens';
|
||||||
|
import { cx } from '../utils/cx';
|
||||||
|
|
||||||
|
interface DTWebThemeContextValue {
|
||||||
|
brand: ThemeBrand;
|
||||||
|
theme: ThemeMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DTWebThemeContext = createContext<DTWebThemeContextValue | null>(null);
|
||||||
|
|
||||||
|
interface DTWebThemeProviderProps {
|
||||||
|
/** Brand to apply @default 'dt' */
|
||||||
|
brand?: ThemeBrand;
|
||||||
|
/** Theme mode @default 'dark' */
|
||||||
|
theme?: ThemeMode;
|
||||||
|
/** When true, sets data-brand/data-theme on document.documentElement instead of a wrapper div */
|
||||||
|
documentLevel?: boolean;
|
||||||
|
/** Additional className for the wrapper div (ignored when documentLevel is true) */
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DTWebThemeProvider({
|
||||||
|
brand = 'dt',
|
||||||
|
theme = 'dark',
|
||||||
|
documentLevel,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: DTWebThemeProviderProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (documentLevel) {
|
||||||
|
document.documentElement.dataset.brand = brand;
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
}
|
||||||
|
}, [documentLevel, brand, theme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DTWebThemeContext.Provider value={{ brand, theme }}>
|
||||||
|
{documentLevel ? (
|
||||||
|
children
|
||||||
|
) : (
|
||||||
|
<div data-brand={brand} data-theme={theme} className={cx('dt-theme-root', className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DTWebThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
packages/react/src/hooks/useDTWebTheme.ts
Normal file
14
packages/react/src/hooks/useDTWebTheme.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Hook to access the DTWebThemeProvider context.
|
||||||
|
*/
|
||||||
|
import { useContext } from 'react';
|
||||||
|
import { DTWebThemeContext } from '../components/DTWebThemeProvider';
|
||||||
|
import type { ThemeBrand, ThemeMode } from '@dangerousthings/tokens';
|
||||||
|
|
||||||
|
export function useDTWebTheme(): { brand: ThemeBrand; theme: ThemeMode } {
|
||||||
|
const ctx = useContext(DTWebThemeContext);
|
||||||
|
if (!ctx) {
|
||||||
|
return { brand: 'dt', theme: 'dark' };
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
24
packages/react/src/hooks/usePulse.ts
Normal file
24
packages/react/src/hooks/usePulse.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Hook: toggles dt-animate-pulse CSS class based on enabled flag.
|
||||||
|
*/
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export function usePulse(
|
||||||
|
ref: React.RefObject<HTMLElement | null>,
|
||||||
|
enabled = false,
|
||||||
|
): void {
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
el.classList.add('dt-animate-pulse');
|
||||||
|
} else {
|
||||||
|
el.classList.remove('dt-animate-pulse');
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
el.classList.remove('dt-animate-pulse');
|
||||||
|
};
|
||||||
|
}, [ref, enabled]);
|
||||||
|
}
|
||||||
29
packages/react/src/hooks/useScaleIn.ts
Normal file
29
packages/react/src/hooks/useScaleIn.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Hook: applies dt-animate-scale-in CSS class on mount.
|
||||||
|
*/
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useScaleIn(
|
||||||
|
ref: React.RefObject<HTMLElement | null>,
|
||||||
|
options?: { duration?: number; delay?: number },
|
||||||
|
): void {
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
if (options?.duration) {
|
||||||
|
el.style.animationDuration = `${options.duration}ms`;
|
||||||
|
}
|
||||||
|
if (options?.delay) {
|
||||||
|
el.style.animationDelay = `${options.delay}ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.classList.add('dt-animate-scale-in');
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
el.classList.remove('dt-animate-scale-in');
|
||||||
|
el.style.animationDuration = '';
|
||||||
|
el.style.animationDelay = '';
|
||||||
|
};
|
||||||
|
}, [ref, options?.duration, options?.delay]);
|
||||||
|
}
|
||||||
62
packages/react/src/index.ts
Normal file
62
packages/react/src/index.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* @dangerousthings/react
|
||||||
|
*
|
||||||
|
* React web components for the Dangerous Things design system.
|
||||||
|
* Wraps @dangerousthings/web CSS classes with React component APIs
|
||||||
|
* matching @dangerousthings/react-native for cross-platform parity.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Theme
|
||||||
|
export { DTWebThemeProvider } from './components/DTWebThemeProvider';
|
||||||
|
export { useDTWebTheme } from './hooks/useDTWebTheme';
|
||||||
|
|
||||||
|
// Types (re-exported from tokens for convenience)
|
||||||
|
export type { DTVariant, ThemeBrand, ThemeMode } from '@dangerousthings/tokens';
|
||||||
|
export { variantToClassName, variantToCSSProperty } from '@dangerousthings/tokens';
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
export { cx } from './utils/cx';
|
||||||
|
export { getVariantClass, featureStateToVariant, variantToBadgeClass } from './utils/variantClasses';
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
export { useScaleIn } from './hooks/useScaleIn';
|
||||||
|
export { usePulse } from './hooks/usePulse';
|
||||||
|
|
||||||
|
// Components — bevel & layout
|
||||||
|
export { DTCard } from './components/DTCard';
|
||||||
|
export { DTButton } from './components/DTButton';
|
||||||
|
export { DTLabel } from './components/DTLabel';
|
||||||
|
export { DTChip } from './components/DTChip';
|
||||||
|
export { DTMediaFrame } from './components/DTMediaFrame';
|
||||||
|
export { DTModal } from './components/DTModal';
|
||||||
|
export { DTDrawer } from './components/DTDrawer';
|
||||||
|
|
||||||
|
// Components — forms
|
||||||
|
export { DTTextInput } from './components/DTTextInput';
|
||||||
|
export { DTCheckbox } from './components/DTCheckbox';
|
||||||
|
export { DTSwitch } from './components/DTSwitch';
|
||||||
|
export { DTRadioGroup } from './components/DTRadioGroup';
|
||||||
|
export type { DTRadioOption } from './components/DTRadioGroup';
|
||||||
|
export { DTQuantityStepper } from './components/DTQuantityStepper';
|
||||||
|
export { DTSearchInput } from './components/DTSearchInput';
|
||||||
|
|
||||||
|
// Components — layout & animation
|
||||||
|
export { DTProgressBar } from './components/DTProgressBar';
|
||||||
|
export { DTAccordion } from './components/DTAccordion';
|
||||||
|
export type { DTAccordionSection } from './components/DTAccordion';
|
||||||
|
export { DTBadgeOverlay } from './components/DTBadgeOverlay';
|
||||||
|
|
||||||
|
// Components — filter & feature
|
||||||
|
export { DTMenu } from './components/DTMenu';
|
||||||
|
export type { DTMenuItem } from './components/DTMenu';
|
||||||
|
export { DTFeatureLegend } from './components/DTFeatureLegend';
|
||||||
|
export type { DTFeatureItem } from './components/DTFeatureLegend';
|
||||||
|
export { DTMobileFilterOverlay } from './components/DTMobileFilterOverlay';
|
||||||
|
|
||||||
|
// Components — media & decorative
|
||||||
|
export { DTGallery } from './components/DTGallery';
|
||||||
|
export type { DTGalleryItem } from './components/DTGallery';
|
||||||
|
export { DTHexagon } from './components/DTHexagon';
|
||||||
|
|
||||||
|
// Components — animation
|
||||||
|
export { DTStaggerContainer } from './components/DTStaggerContainer';
|
||||||
7
packages/react/src/utils/cx.ts
Normal file
7
packages/react/src/utils/cx.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Lightweight className composition utility.
|
||||||
|
* Filters out falsy values and joins with spaces.
|
||||||
|
*/
|
||||||
|
export function cx(...args: (string | false | null | undefined)[]): string {
|
||||||
|
return args.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
31
packages/react/src/utils/variantClasses.ts
Normal file
31
packages/react/src/utils/variantClasses.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* DTVariant → CSS class name mapping
|
||||||
|
*/
|
||||||
|
import { variantToClassName } from '@dangerousthings/tokens';
|
||||||
|
import type { DTVariant } from '@dangerousthings/tokens';
|
||||||
|
|
||||||
|
export function getVariantClass(variant: DTVariant): string {
|
||||||
|
return variantToClassName[variant];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map feature state to variant */
|
||||||
|
export function featureStateToVariant(state: 'supported' | 'disabled' | 'unsupported'): DTVariant {
|
||||||
|
const map: Record<typeof state, DTVariant> = {
|
||||||
|
supported: 'normal',
|
||||||
|
disabled: 'emphasis',
|
||||||
|
unsupported: 'warning',
|
||||||
|
};
|
||||||
|
return map[state];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map variant to badge CSS class (for status badge colors) */
|
||||||
|
export function variantToBadgeClass(variant: DTVariant): string {
|
||||||
|
const map: Record<DTVariant, string> = {
|
||||||
|
normal: 'badge-info',
|
||||||
|
emphasis: 'badge-warning',
|
||||||
|
warning: 'badge-error',
|
||||||
|
success: 'badge-success',
|
||||||
|
other: 'badge-info',
|
||||||
|
};
|
||||||
|
return map[variant];
|
||||||
|
}
|
||||||
9
packages/react/tsconfig.json
Normal file
9
packages/react/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
18
packages/showcase/desktop/electron-builder.yml
Normal file
18
packages/showcase/desktop/electron-builder.yml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
appId: com.dangerousthings.showcase-desktop
|
||||||
|
productName: DT Design System Showcase
|
||||||
|
directories:
|
||||||
|
output: release
|
||||||
|
buildResources: resources
|
||||||
|
npmRebuild: false
|
||||||
|
artifactName: "dt-showcase-desktop-${version}-${arch}.${ext}"
|
||||||
|
files:
|
||||||
|
- dist/**/*
|
||||||
|
- package.json
|
||||||
|
- "!node_modules"
|
||||||
|
mac:
|
||||||
|
target: [dmg, zip]
|
||||||
|
win:
|
||||||
|
target: [nsis, portable]
|
||||||
|
linux:
|
||||||
|
target: [AppImage, deb]
|
||||||
|
category: Development
|
||||||
58
packages/showcase/desktop/package.json
Normal file
58
packages/showcase/desktop/package.json
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"name": "@dangerousthings/showcase-desktop",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"description": "Desktop showcase for the Dangerous Things design system",
|
||||||
|
"author": {
|
||||||
|
"name": "Dangerous Things",
|
||||||
|
"email": "info@dangerousthings.com"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/dangerous-tac0s/dt-design-system.git",
|
||||||
|
"directory": "packages/showcase/desktop"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/dangerous-tac0s/dt-design-system",
|
||||||
|
"main": "dist/main/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --config vite.config.ts",
|
||||||
|
"dev:electron": "npm run build:main && concurrently \"vite --config vite.config.ts\" \"wait-on http://localhost:5173 && electron .\"",
|
||||||
|
"build": "tsc -p tsconfig.main.json && vite build --config vite.config.ts",
|
||||||
|
"build:main": "tsc -p tsconfig.main.json",
|
||||||
|
"start": "electron .",
|
||||||
|
"package": "npm run build && electron-builder",
|
||||||
|
"package:dir": "npm run build && electron-builder --dir",
|
||||||
|
"package:win": "npm run build && electron-builder --win",
|
||||||
|
"package:mac": "npm run build && electron-builder --mac",
|
||||||
|
"package:linux": "npm run build && electron-builder --linux",
|
||||||
|
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.main.json",
|
||||||
|
"clean": "rm -rf dist release"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dangerousthings/hex-background": "^0.2.0",
|
||||||
|
"@dangerousthings/react": "*",
|
||||||
|
"@dangerousthings/tokens": "*",
|
||||||
|
"@dangerousthings/web": "*",
|
||||||
|
"@react-three/fiber": "^9.5.0",
|
||||||
|
"react": "^18.0.0",
|
||||||
|
"react-dom": "^18.0.0",
|
||||||
|
"react-icons": "^5.6.0",
|
||||||
|
"three": "^0.183.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@dangerousthings/tailwind-preset": "*",
|
||||||
|
"@types/react": "^18.2.0",
|
||||||
|
"@types/react-dom": "^18.2.0",
|
||||||
|
"@vitejs/plugin-react": "^4.0.0",
|
||||||
|
"autoprefixer": "^10.4.0",
|
||||||
|
"concurrently": "^9.0.0",
|
||||||
|
"electron": "33.4.11",
|
||||||
|
"electron-builder": "^25.0.0",
|
||||||
|
"postcss": "^8.4.0",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"vite": "^6.0.0",
|
||||||
|
"wait-on": "^8.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
packages/showcase/desktop/postcss.config.mjs
Normal file
6
packages/showcase/desktop/postcss.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
30
packages/showcase/desktop/src/main/main.ts
Normal file
30
packages/showcase/desktop/src/main/main.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { app, BrowserWindow } from 'electron';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
backgroundColor: '#000000',
|
||||||
|
titleBarStyle: 'hiddenInset',
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.VITE_DEV_SERVER_URL) {
|
||||||
|
win.loadURL(process.env.VITE_DEV_SERVER_URL);
|
||||||
|
} else {
|
||||||
|
win.loadFile(path.join(__dirname, '../renderer/index.html'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(createWindow);
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') app.quit();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||||
|
});
|
||||||
1
packages/showcase/desktop/src/main/preload.ts
Normal file
1
packages/showcase/desktop/src/main/preload.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// Minimal preload — no IPC needed for the showcase
|
||||||
104
packages/showcase/desktop/src/renderer/App.tsx
Normal file
104
packages/showcase/desktop/src/renderer/App.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { themes } from '@dangerousthings/tokens';
|
||||||
|
import type { ThemeBrand, ThemeMode } from '@dangerousthings/tokens';
|
||||||
|
import { HexGridBackground } from '@dangerousthings/hex-background';
|
||||||
|
import type { HexGridBackgroundProps } from '@dangerousthings/hex-background';
|
||||||
|
import { HomePage } from './pages/HomePage';
|
||||||
|
import { BevelsPage } from './pages/BevelsPage';
|
||||||
|
import { FormsPage } from './pages/FormsPage';
|
||||||
|
import { AnimationsPage } from './pages/AnimationsPage';
|
||||||
|
import { CardsAdvancedPage } from './pages/CardsAdvancedPage';
|
||||||
|
import { TokensPage } from './pages/TokensPage';
|
||||||
|
import { HexBackgroundPage } from './pages/HexBackgroundPage';
|
||||||
|
|
||||||
|
const navPages = [
|
||||||
|
{ hash: '', label: 'Home' },
|
||||||
|
{ hash: 'bevels', label: 'Bevels' },
|
||||||
|
{ hash: 'forms', label: 'Forms' },
|
||||||
|
{ hash: 'animations', label: 'Animations' },
|
||||||
|
{ hash: 'cards-advanced', label: 'Advanced Cards' },
|
||||||
|
{ hash: 'hex-background', label: 'Hex Background' },
|
||||||
|
{ hash: 'tokens', label: 'Tokens' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [brand, setBrand] = useState<ThemeBrand>('dt');
|
||||||
|
const [theme, setTheme] = useState<ThemeMode>('dark');
|
||||||
|
const [currentHash, setCurrentHash] = useState('');
|
||||||
|
const [hexProps, setHexProps] = useState<HexGridBackgroundProps>({});
|
||||||
|
|
||||||
|
// Sync brand/theme to <html> so CSS custom properties cascade everywhere
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute('data-brand', brand);
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
}, [brand, theme]);
|
||||||
|
|
||||||
|
// Hash-based routing
|
||||||
|
useEffect(() => {
|
||||||
|
function onHashChange() {
|
||||||
|
setCurrentHash(window.location.hash.replace('#/', '').replace('#', ''));
|
||||||
|
}
|
||||||
|
window.addEventListener('hashchange', onHashChange);
|
||||||
|
onHashChange();
|
||||||
|
return () => window.removeEventListener('hashchange', onHashChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
switch (currentHash) {
|
||||||
|
case 'bevels': return <BevelsPage />;
|
||||||
|
case 'forms': return <FormsPage />;
|
||||||
|
case 'animations': return <AnimationsPage />;
|
||||||
|
case 'cards-advanced': return <CardsAdvancedPage />;
|
||||||
|
case 'hex-background': return <HexBackgroundPage hexProps={hexProps} onHexPropsChange={setHexProps} />;
|
||||||
|
case 'tokens': return <TokensPage brand={brand} />;
|
||||||
|
default: return <HomePage />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<nav id="sidebar">
|
||||||
|
<div className="nav-title">DT DESIGN SYSTEM</div>
|
||||||
|
<div className="brand-switcher-container">
|
||||||
|
{themes.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
className={`brand-btn${t.id === brand ? ' active' : ''}`}
|
||||||
|
onClick={() => setBrand(t.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div className="mode-switcher">
|
||||||
|
{(['dark', 'light', 'auto'] as ThemeMode[]).map(m => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
className={`mode-btn${m === theme ? ' active' : ''}`}
|
||||||
|
onClick={() => setTheme(m)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{m}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="nav-links">
|
||||||
|
{navPages.map(p => (
|
||||||
|
<a
|
||||||
|
key={p.hash}
|
||||||
|
href={`#/${p.hash}`}
|
||||||
|
className={`nav-link${p.hash === currentHash ? ' active' : ''}`}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<HexGridBackground {...hexProps} />
|
||||||
|
<main id="content">
|
||||||
|
{renderPage()}
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { ReactNode, CSSProperties } from 'react';
|
||||||
|
|
||||||
|
export function Section({ title, description, children }: { title: string; description: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="demo-section">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<p className="demo-description">{description}</p>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Row({ children, style }: { children: ReactNode; style?: CSSProperties }) {
|
||||||
|
return <div className="demo-row" style={style}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodeLabel({ text }: { text: string }) {
|
||||||
|
return <div className="code-label">{text}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DemoLabel({ text }: { text: string }) {
|
||||||
|
return <div className="demo-label">{text}</div>;
|
||||||
|
}
|
||||||
12
packages/showcase/desktop/src/renderer/index.html
Normal file
12
packages/showcase/desktop/src/renderer/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-brand="dt" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>DT Design System — Desktop Showcase</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="./main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
6
packages/showcase/desktop/src/renderer/main.tsx
Normal file
6
packages/showcase/desktop/src/renderer/main.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import '@dangerousthings/web/index.css';
|
||||||
|
import './style.css';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { App } from './App';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('app')!).render(<App />);
|
||||||
194
packages/showcase/desktop/src/renderer/pages/AnimationsPage.tsx
Normal file
194
packages/showcase/desktop/src/renderer/pages/AnimationsPage.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { DTCard } from '@dangerousthings/react';
|
||||||
|
import { Section, Row, CodeLabel } from '../components/Section';
|
||||||
|
|
||||||
|
function AnimBox({ className, label }: { className: string; label: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-primary)',
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--color-bg)',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const VARIANTS = ['normal', 'emphasis', 'warning', 'success', 'other'] as const;
|
||||||
|
|
||||||
|
function ProgressBarDemo() {
|
||||||
|
const [progress, setProgress] = useState<number[]>([0, 0, 0, 0, 0]);
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
// Reset then animate up
|
||||||
|
setProgress([0, 0, 0, 0, 0]);
|
||||||
|
setRunning(true);
|
||||||
|
let tick = 0;
|
||||||
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
tick++;
|
||||||
|
setProgress(prev =>
|
||||||
|
prev.map((_, i) => Math.min(100, Math.round(tick * (1.5 + i * 0.4))))
|
||||||
|
);
|
||||||
|
if (tick > 80) {
|
||||||
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
|
setRunning(false);
|
||||||
|
}
|
||||||
|
}, 40);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Auto-run on mount
|
||||||
|
const t = setTimeout(animate, 500);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(t);
|
||||||
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', marginBottom: 16 }}>
|
||||||
|
{VARIANTS.map((v, i) => (
|
||||||
|
<DTCard
|
||||||
|
key={v}
|
||||||
|
variant={v}
|
||||||
|
title={`${v} ${progress[i]}%`}
|
||||||
|
progress={progress[i]}
|
||||||
|
style={{ width: 160, transition: 'all 0.1s ease-out' }}
|
||||||
|
>
|
||||||
|
<div className="card-body" style={{ fontSize: '0.75rem', minHeight: 60, display: 'flex', alignItems: 'center' }}>
|
||||||
|
Progress fills the left edge from bottom to top
|
||||||
|
</div>
|
||||||
|
</DTCard>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={animate}
|
||||||
|
disabled={running}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{running ? 'ANIMATING...' : 'REPLAY PROGRESS'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnimationsPage() {
|
||||||
|
const [entranceKey, setEntranceKey] = useState(0);
|
||||||
|
const [accordionExpanded, setAccordionExpanded] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 className="page-title">Animations</h1>
|
||||||
|
<p className="page-subtitle">Entrance animations, interactive effects, and transition utilities.</p>
|
||||||
|
|
||||||
|
<Section title="Entrance Animations" description="One-shot animations for elements entering the viewport.">
|
||||||
|
<Row key={entranceKey}>
|
||||||
|
<AnimBox className="dt-animate-scale-in" label="SCALE IN" />
|
||||||
|
<AnimBox className="dt-animate-fade-in" label="FADE IN" />
|
||||||
|
<AnimBox className="dt-animate-slide-up" label="SLIDE UP" />
|
||||||
|
</Row>
|
||||||
|
<button className="btn-secondary" style={{ marginTop: 'var(--space-4)' }} onClick={() => setEntranceKey(k => k + 1)} type="button">
|
||||||
|
REPLAY
|
||||||
|
</button>
|
||||||
|
<CodeLabel text=".dt-animate-scale-in | .dt-animate-fade-in | .dt-animate-slide-up" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Interactive Animations" description="Looping animations for active/loading states.">
|
||||||
|
<Row>
|
||||||
|
<AnimBox className="dt-animate-pulse" label="PULSE" />
|
||||||
|
<AnimBox className="dt-animate-ping" label="PING" />
|
||||||
|
<AnimBox className="dt-animate-spin" label="SPIN" />
|
||||||
|
</Row>
|
||||||
|
<CodeLabel text=".dt-animate-pulse | .dt-animate-ping | .dt-animate-spin" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Transition Utilities" description="Accordion expand, chevron rotation, and progress bar transitions.">
|
||||||
|
<div style={{ maxWidth: 400 }}>
|
||||||
|
<button
|
||||||
|
className="dt-accent-top"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
width: '100%',
|
||||||
|
padding: 'var(--space-3) var(--space-4)',
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
aria-expanded={accordionExpanded}
|
||||||
|
onClick={() => setAccordionExpanded(!accordionExpanded)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Click to expand
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
transition: 'transform 250ms ease-in-out',
|
||||||
|
transform: accordionExpanded ? 'rotate(180deg)' : undefined,
|
||||||
|
}}>
|
||||||
|
▼
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div style={{
|
||||||
|
maxHeight: accordionExpanded ? 200 : 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: 'max-height 250ms ease-in-out',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
padding: 'var(--space-4)',
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderTop: 'none',
|
||||||
|
}}>
|
||||||
|
This content expands with a smooth max-height transition. The chevron rotates 180 degrees.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CodeLabel text=".dt-transition-accordion | .dt-transition-chevron | .dt-transition-progress" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Card Progress Bar" description="Cards have a vertical progress bar on the left edge. Animate it by transitioning --dt-card-progress from 0 to 100.">
|
||||||
|
<ProgressBarDemo />
|
||||||
|
<CodeLabel text="<DTCard progress={value} /> — CSS ::after gradient driven by --dt-card-progress" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Scrollbar Styling" description="Thin neon scrollbar scoped under [data-brand='dt'].">
|
||||||
|
<div
|
||||||
|
className="dt-scrollbar"
|
||||||
|
style={{
|
||||||
|
maxHeight: 120,
|
||||||
|
overflowY: 'auto',
|
||||||
|
background: 'var(--color-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
padding: 'var(--space-3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 20 }, (_, i) => (
|
||||||
|
<div key={i} style={{ padding: '4px 0', color: 'var(--color-text)' }}>
|
||||||
|
Scrollbar line {i + 1} — thin neon scrollbar styled with --color-primary
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<CodeLabel text=".dt-scrollbar — scrollbar-color: var(--color-primary)" />
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
packages/showcase/desktop/src/renderer/pages/BevelsPage.tsx
Normal file
111
packages/showcase/desktop/src/renderer/pages/BevelsPage.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { DTCard, DTModal, DTDrawer } from '@dangerousthings/react';
|
||||||
|
import type { DTVariant } from '@dangerousthings/tokens';
|
||||||
|
import { Section, Row, CodeLabel } from '../components/Section';
|
||||||
|
|
||||||
|
const modes: DTVariant[] = ['normal', 'emphasis', 'warning', 'success', 'other'];
|
||||||
|
|
||||||
|
export function BevelsPage() {
|
||||||
|
const [modalVariant, setModalVariant] = useState<DTVariant | null>(null);
|
||||||
|
const [drawerSide, setDrawerSide] = useState<'right' | 'left' | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 className="page-title">Bevels</h1>
|
||||||
|
<p className="page-subtitle">Angular clip-path patterns from the DT design system. Active on the DT brand.</p>
|
||||||
|
|
||||||
|
<Section title="Card Bevels" description="Dual bottom bevels using clip-path. Outer bg = border color, ::before = surface fill.">
|
||||||
|
<DTCard title="CARD TITLE">
|
||||||
|
<div className="card-body">Dual bottom bevels — bottom-right (bevel-md) and bottom-left (bevel-sm). Uses the dual-element technique with ::before pseudo-element.</div>
|
||||||
|
</DTCard>
|
||||||
|
<CodeLabel text="<DTCard title='...'> or .card" />
|
||||||
|
<DTCard>
|
||||||
|
<div className="card-body">Card without title — no header bar, just the beveled container.</div>
|
||||||
|
</DTCard>
|
||||||
|
<CodeLabel text="<DTCard> (no title prop)" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Badge Bevels" description="Top-right bevel with dual-element technique. Color variants via CSS custom properties.">
|
||||||
|
<Row>
|
||||||
|
<span className="badge">DEFAULT</span>
|
||||||
|
<span className="badge badge-success">IN STOCK</span>
|
||||||
|
<span className="badge badge-error">SOLD OUT</span>
|
||||||
|
<span className="badge badge-warning">LAB</span>
|
||||||
|
<span className="badge badge-info">NFC</span>
|
||||||
|
</Row>
|
||||||
|
<CodeLabel text=".badge | .badge-success | .badge-error | .badge-warning | .badge-info" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Media Frame Bevels" description="Diagonal opposite corners (top-left + bottom-right). Dual-element border with inner surface fill.">
|
||||||
|
<div className="grid grid-cols-3 gap-dt-4">
|
||||||
|
<div className="dt-bevel-media mode-normal" style={{ aspectRatio: '16/9' }}>
|
||||||
|
<img src="https://images.unsplash.com/photo-1550751827-4bd374c3f58b?w=600&h=338&fit=crop" alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
</div>
|
||||||
|
<div className="dt-bevel-media mode-emphasis" style={{ aspectRatio: '16/9' }}>
|
||||||
|
<img src="https://images.unsplash.com/photo-1518770660439-4636190af475?w=600&h=338&fit=crop" alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
</div>
|
||||||
|
<div className="dt-bevel-media mode-warning" style={{ aspectRatio: '16/9' }} />
|
||||||
|
</div>
|
||||||
|
<CodeLabel text=".dt-bevel-media | .dt-bevel-media (no img = 'MISSING MEDIA' placeholder)" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Modals" description="Beveled card dialog with backdrop blur. Click to open, click backdrop or press Escape to dismiss.">
|
||||||
|
<Row>
|
||||||
|
{modes.map(mode => (
|
||||||
|
<button key={mode} className={`dt-menu-item mode-${mode}`} type="button" onClick={() => setModalVariant(mode)}>
|
||||||
|
{mode} Modal
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
<CodeLabel text="<DTModal variant='normal' visible onDismiss title='...'>content</DTModal>" />
|
||||||
|
<DTModal visible={modalVariant !== null} onDismiss={() => setModalVariant(null)} variant={modalVariant ?? 'normal'} title={`${modalVariant ?? 'normal'} Modal`}>
|
||||||
|
<p style={{ marginBottom: 'var(--space-4)' }}>This is a <strong>{modalVariant}</strong> modal with beveled card shape, backdrop blur, and scale-in animation.</p>
|
||||||
|
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.85rem' }}>Click the backdrop or press Escape to dismiss.</p>
|
||||||
|
</DTModal>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Drawers" description="Sliding side panel with beveled edges. Click to open from either side.">
|
||||||
|
<Row>
|
||||||
|
<button className="dt-menu-item mode-emphasis" type="button" onClick={() => setDrawerSide('right')}>
|
||||||
|
OPEN RIGHT DRAWER
|
||||||
|
</button>
|
||||||
|
<button className="dt-menu-item mode-other" type="button" onClick={() => setDrawerSide('left')}>
|
||||||
|
OPEN LEFT DRAWER
|
||||||
|
</button>
|
||||||
|
</Row>
|
||||||
|
<CodeLabel text="<DTDrawer position='right' heading='...' visible onDismiss>content</DTDrawer>" />
|
||||||
|
<DTDrawer visible={drawerSide !== null} onDismiss={() => setDrawerSide(null)} position={drawerSide ?? 'right'} heading={`${drawerSide ?? 'right'} Drawer`} headingVariant={drawerSide === 'left' ? 'other' : 'emphasis'}>
|
||||||
|
<p style={{ marginBottom: 'var(--space-4)' }}>Sliding panel from the <strong>{drawerSide}</strong> edge with beveled corners and backdrop blur.</p>
|
||||||
|
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.85rem' }}>Click the backdrop, press Escape, or click ✕ to dismiss.</p>
|
||||||
|
</DTDrawer>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Small Bevel Utility" description="For compact elements — arrows, stepper buttons.">
|
||||||
|
<Row>
|
||||||
|
<div className="dt-bevel-sm" style={{ background: 'var(--color-primary)', width: 60, height: 60, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<span style={{ color: 'var(--color-bg)', fontWeight: 700, fontSize: '0.75rem' }}>SM</span>
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
<CodeLabel text=".dt-bevel-sm" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Accent Top" description="Used on accordion headers and menu items.">
|
||||||
|
<div className="dt-accent-top" style={{ padding: 'var(--space-4)', background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Thick Top Border Accent</span>
|
||||||
|
</div>
|
||||||
|
<CodeLabel text=".dt-accent-top" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Card Color Modes" description="Per-card mode coloring — see Advanced Cards page for full demos.">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-dt-3">
|
||||||
|
{modes.map(mode => (
|
||||||
|
<DTCard key={mode} variant={mode} title={mode}>
|
||||||
|
<div className="card-body" style={{ fontSize: '0.75rem' }}>mode-{mode}</div>
|
||||||
|
</DTCard>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<CodeLabel text="<DTCard variant='normal'> | 'emphasis' | 'warning' | 'success' | 'other'" />
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { createElement, type CSSProperties } from 'react';
|
||||||
|
import {
|
||||||
|
DTCard,
|
||||||
|
DTFeatureLegend,
|
||||||
|
} from '@dangerousthings/react';
|
||||||
|
import type { DTFeatureItem } from '@dangerousthings/react';
|
||||||
|
import type { DTVariant } from '@dangerousthings/tokens';
|
||||||
|
import { Section, CodeLabel } from '../components/Section';
|
||||||
|
|
||||||
|
// Feature icons from dt-shopify-storefront
|
||||||
|
import {
|
||||||
|
MdOutlinePhonelinkRing,
|
||||||
|
MdOutlineVpnKey,
|
||||||
|
MdOutlineMobileScreenShare,
|
||||||
|
MdOutlineCreditCard,
|
||||||
|
MdOutlineCopyAll,
|
||||||
|
MdOutlineLightbulb,
|
||||||
|
MdOutlineThermostat,
|
||||||
|
MdOutlineSensors,
|
||||||
|
MdOutlineFitbit,
|
||||||
|
MdOutlineVibration,
|
||||||
|
MdOutlineExplore,
|
||||||
|
MdLightbulbOutline,
|
||||||
|
MdOutlineVisibility,
|
||||||
|
} from 'react-icons/md';
|
||||||
|
import { FaUserShield } from 'react-icons/fa';
|
||||||
|
import { LuBinary } from 'react-icons/lu';
|
||||||
|
|
||||||
|
const modes: DTVariant[] = ['normal', 'emphasis', 'warning', 'success', 'other'];
|
||||||
|
|
||||||
|
// Wrap react-icons to avoid IconType/ReactNode type mismatch
|
||||||
|
const ico = (C: any) => createElement(C, { style: { fontSize: '2rem' } });
|
||||||
|
|
||||||
|
// Full chip feature legend (9 features — from storefront UseCaseLegend)
|
||||||
|
// NExT v2: NTAGI2C (HF) + T5577 (LF) with power-harvesting LED
|
||||||
|
const chipFeatures: DTFeatureItem[] = [
|
||||||
|
{ key: 'smartphone', name: 'Smartphone', icon: ico(MdOutlinePhonelinkRing), state: 'supported', detail: 'Full NFC smartphone support' },
|
||||||
|
{ key: 'access_control', name: 'Access Control', icon: ico(MdOutlineVpnKey), state: 'supported', detail: 'Legacy' },
|
||||||
|
{ key: 'digital_security', name: 'Digital Security', icon: ico(FaUserShield), state: 'unsupported', detail: 'Not Supported' },
|
||||||
|
{ key: 'cryptography', name: 'Cryptography', icon: ico(LuBinary), state: 'unsupported', detail: 'Not Supported' },
|
||||||
|
{ key: 'data_sharing', name: 'Data Sharing', icon: ico(MdOutlineMobileScreenShare), state: 'supported', detail: '1 kB' },
|
||||||
|
{ key: 'payment', name: 'Payment', icon: ico(MdOutlineCreditCard), state: 'unsupported', detail: 'Not Supported' },
|
||||||
|
{ key: 'magic', name: 'Magic', icon: ico(MdOutlineCopyAll), state: 'supported', detail: 'Yes' },
|
||||||
|
{ key: 'Illumination', name: 'Illumination', icon: ico(MdOutlineLightbulb), state: 'supported', detail: 'HF: green, blue, white' },
|
||||||
|
{ key: 'temperature', name: 'Sensors', icon: ico(MdOutlineThermostat), state: 'unsupported', detail: 'None' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Full biomagnet feature legend (4 features — from storefront MagnetUseCaseLegend)
|
||||||
|
// m0422a: axial neodymium disc, bioresin coated
|
||||||
|
const magnetFeatures: DTFeatureItem[] = [
|
||||||
|
{ key: 'sensing', name: 'Sensing', icon: ico(MdOutlineSensors), state: 'supported', detail: 'RP 22,860 G/g' },
|
||||||
|
{ key: 'lifting', name: 'Lifting', icon: ico(MdOutlineFitbit), state: 'supported', detail: '~20 g' },
|
||||||
|
{ key: 'haptics', name: 'Haptics', icon: ico(MdOutlineVibration), state: 'supported', detail: 'Push/Pull' },
|
||||||
|
{ key: 'polarity', name: 'Polarity Detection', icon: ico(MdOutlineExplore), state: 'supported', detail: 'Monopole' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Full aesthetic feature legend (2 features — from storefront AestheticUseCaseLegend)
|
||||||
|
const aestheticFeatures: DTFeatureItem[] = [
|
||||||
|
{ key: 'illumination', name: 'Illumination', icon: ico(MdLightbulbOutline), state: 'supported', detail: 'HF: red, green, blue, white' },
|
||||||
|
{ key: 'prominence', name: 'Prominence', icon: ico(MdOutlineVisibility), state: 'supported', detail: 'Low' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CardsAdvancedPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 className="page-title">Advanced Cards</h1>
|
||||||
|
<p className="page-subtitle">Card color modes, progress bars, badge overlays, and interactive bevel buttons.</p>
|
||||||
|
|
||||||
|
<Section title="Card Color Modes" description="Per-card color via variant prop. Sets --dt-card-color, glow color, and accent.">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-dt-3">
|
||||||
|
{modes.map(mode => (
|
||||||
|
<DTCard key={mode} variant={mode} title={mode}>
|
||||||
|
<div className="card-body" style={{ fontSize: '0.8rem' }}>mode-{mode}</div>
|
||||||
|
</DTCard>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<CodeLabel text="<DTCard variant='normal'> | 'emphasis' | 'warning' | 'success' | 'other'" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
|
||||||
|
<Section title="Card Grow" description="Cards stretch to equal height in a row via grow prop. Compare the varying content heights.">
|
||||||
|
<div style={{ display: 'flex', gap: 'var(--space-3)' }}>
|
||||||
|
<DTCard variant="normal" title="SHORT" grow>
|
||||||
|
<div className="card-body" style={{ fontSize: '0.8rem' }}>One line</div>
|
||||||
|
</DTCard>
|
||||||
|
<DTCard variant="emphasis" title="TALL" grow>
|
||||||
|
<div className="card-body" style={{ fontSize: '0.8rem' }}>
|
||||||
|
Multiple lines of content to demonstrate that the shorter card stretches to match this card's height.
|
||||||
|
</div>
|
||||||
|
</DTCard>
|
||||||
|
<DTCard variant="other" title="MEDIUM" grow>
|
||||||
|
<div className="card-body" style={{ fontSize: '0.8rem' }}>Two lines of content here.</div>
|
||||||
|
</DTCard>
|
||||||
|
</div>
|
||||||
|
<CodeLabel text="<DTCard grow /> — flex-grow: 1 + height: 100% to fill container" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Card Progress Bar" description="Vertical left-edge bar driven by progress prop (0-100).">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-dt-3">
|
||||||
|
{[0, 25, 50, 75, 100].map((val, i) => (
|
||||||
|
<DTCard key={val} variant={modes[i % modes.length]} title={`${val}%`} progress={val}>
|
||||||
|
<div className="card-body" style={{ fontSize: '0.8rem' }}>Progress: {val}%</div>
|
||||||
|
</DTCard>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<CodeLabel text="<DTCard progress={50} /> — height driven by --dt-card-progress" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Card Badges" description="Bottom-right chip badge on card product image area. Inherits card mode color.">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-dt-4">
|
||||||
|
{[
|
||||||
|
{ label: 'LAB', mode: 'warning' as DTVariant, img: 'https://images.unsplash.com/photo-1526374965328-7f61d4dc18c5?w=400&h=400&fit=crop' },
|
||||||
|
{ label: 'BUNDLE', mode: 'other' as DTVariant, img: 'https://images.unsplash.com/photo-1518770660439-4636190af475?w=400&h=400&fit=crop' },
|
||||||
|
{ label: 'NFC', mode: 'normal' as DTVariant, img: 'https://images.unsplash.com/photo-1550751827-4bd374c3f58b?w=400&h=400&fit=crop' },
|
||||||
|
].map(badge => (
|
||||||
|
<DTCard key={badge.label} variant={badge.mode} title={badge.label + ' PRODUCT'}
|
||||||
|
badge={<span>{badge.label}</span>}>
|
||||||
|
<div className="card-body-flush" style={{ aspectRatio: '1' }}>
|
||||||
|
<img src={badge.img} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
||||||
|
</div>
|
||||||
|
</DTCard>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<CodeLabel text="<DTCard badge={<span>LAB</span>}> — badge rendered and positioned by card" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Buttons" description="Beveled buttons with active/selected states and mode colors. Nested levels use inline paddingLeft.">
|
||||||
|
<div style={{ maxWidth: 300 }}>
|
||||||
|
{[
|
||||||
|
{ name: 'All Products', cls: ' active', pad: 0 },
|
||||||
|
{ name: 'NFC Implants', cls: '', pad: 0 },
|
||||||
|
{ name: 'RFID Tags', cls: '', pad: 32 },
|
||||||
|
{ name: 'Accessories', cls: '', pad: 32 },
|
||||||
|
{ name: 'Lab Products', cls: ' mode-warning', pad: 0 },
|
||||||
|
].map(item => (
|
||||||
|
<button
|
||||||
|
key={item.name}
|
||||||
|
className={`dt-menu-item${item.cls}`}
|
||||||
|
style={item.pad ? { paddingLeft: item.pad } : undefined}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<CodeLabel text=".dt-menu-item | .active | paddingLeft for nesting" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Feature Legends" description="Interactive product feature grids from the storefront. Hover icons for details. Toggle labels with the ? button.">
|
||||||
|
<DTFeatureLegend features={chipFeatures} title="NExT v2 Features" variant="normal" columns={5} />
|
||||||
|
<div style={{ height: 'var(--space-6)' }} />
|
||||||
|
<DTFeatureLegend features={magnetFeatures} title="m0422a Features" variant="emphasis" columns={4} />
|
||||||
|
<div style={{ height: 'var(--space-6)' }} />
|
||||||
|
<DTFeatureLegend features={aestheticFeatures} title="xLED HF Features" variant="other" columns={2} />
|
||||||
|
<div style={{ height: 'var(--space-4)' }} />
|
||||||
|
<CodeLabel text="<DTFeatureLegend features={chipFeatures} title='NExT Features' variant='normal' />" />
|
||||||
|
<CodeLabel text="<DTFeatureLegend features={magnetFeatures} variant='emphasis' columns={4} />" />
|
||||||
|
<CodeLabel text="<DTFeatureLegend features={aestheticFeatures} variant='other' columns={2} />" />
|
||||||
|
<div style={{ marginTop: 'var(--space-3)', display: 'flex', gap: 'var(--space-4)', fontSize: '0.75rem' }}>
|
||||||
|
<span><span style={{ color: 'var(--mode-normal)' }}>●</span> Supported</span>
|
||||||
|
<span><span style={{ color: 'var(--mode-emphasis)' }}>●</span> Disabled</span>
|
||||||
|
<span><span style={{ color: 'var(--mode-warning)' }}>●</span> Unsupported</span>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
packages/showcase/desktop/src/renderer/pages/FormsPage.tsx
Normal file
128
packages/showcase/desktop/src/renderer/pages/FormsPage.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
DTTextInput,
|
||||||
|
DTCheckbox,
|
||||||
|
DTSwitch,
|
||||||
|
DTRadioGroup,
|
||||||
|
DTProgressBar,
|
||||||
|
DTAccordion,
|
||||||
|
DTQuantityStepper,
|
||||||
|
} from '@dangerousthings/react';
|
||||||
|
import type { DTAccordionSection } from '@dangerousthings/react';
|
||||||
|
import { Section, Row, CodeLabel, DemoLabel } from '../components/Section';
|
||||||
|
|
||||||
|
export function FormsPage() {
|
||||||
|
const [checks, setChecks] = useState({ a: false, b: true, c: true, d: false });
|
||||||
|
const [switches, setSwitches] = useState({ a: false, b: true, c: true, d: false });
|
||||||
|
const [radioValue, setRadioValue] = useState('nfc');
|
||||||
|
const [stepperValue, setStepperValue] = useState(1);
|
||||||
|
|
||||||
|
const accordionSections: DTAccordionSection[] = [
|
||||||
|
{ key: 'size', title: 'Size', children: <div style={{ padding: 'var(--space-4)' }}>Small, Medium, Large options available.</div> },
|
||||||
|
{ key: 'chip', title: 'Chip Type', children: <div style={{ padding: 'var(--space-4)' }}>NTAG, DESFire, MIFARE Classic.</div> },
|
||||||
|
{ key: 'freq', title: 'Frequency', children: <div style={{ padding: 'var(--space-4)' }}>13.56 MHz (HF), 125 kHz (LF).</div> },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 className="page-title">Forms</h1>
|
||||||
|
<p className="page-subtitle">DT-branded form components using @dangerousthings/react.</p>
|
||||||
|
|
||||||
|
<Section title="Text Input" description="Sharp corners + focus glow bar. 2px solid border, no border-radius.">
|
||||||
|
<DTTextInput placeholder="Normal input — click to focus" style={{ maxWidth: 400 }} />
|
||||||
|
<CodeLabel text="<DTTextInput /> — focus glow bar" />
|
||||||
|
<br />
|
||||||
|
<DTTextInput error placeholder="Error state input" style={{ maxWidth: 400 }} />
|
||||||
|
<CodeLabel text="<DTTextInput error /> — red focus bar" />
|
||||||
|
<br />
|
||||||
|
<textarea placeholder="Textarea element" style={{ maxWidth: 400, minHeight: 80 }} />
|
||||||
|
<CodeLabel text="textarea — same styling" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Checkbox" description="Beveled hexagon shape via clip-path. 30% corner cuts.">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<DTCheckbox checked={checks.a} onChange={v => setChecks(p => ({ ...p, a: v }))} label="Unchecked checkbox" />
|
||||||
|
<DTCheckbox checked={checks.b} onChange={v => setChecks(p => ({ ...p, b: v }))} label="Checked checkbox" />
|
||||||
|
<DTCheckbox checked={checks.c} onChange={() => {}} disabled label="Disabled checked" />
|
||||||
|
<DTCheckbox checked={checks.d} onChange={() => {}} disabled label="Disabled unchecked" />
|
||||||
|
</div>
|
||||||
|
<CodeLabel text="<DTCheckbox checked onChange label />" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Switch / Toggle" description="Angular track + sliding square thumb. 48x26 track, 20x20 thumb.">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<DTSwitch checked={switches.a} onChange={v => setSwitches(p => ({ ...p, a: v }))} label="Off" />
|
||||||
|
<DTSwitch checked={switches.b} onChange={v => setSwitches(p => ({ ...p, b: v }))} label="On" />
|
||||||
|
<DTSwitch checked={switches.c} onChange={() => {}} disabled label="Disabled (on)" />
|
||||||
|
<DTSwitch checked={switches.d} onChange={() => {}} disabled label="Disabled (off)" />
|
||||||
|
</div>
|
||||||
|
<CodeLabel text="<DTSwitch checked onChange label />" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Radio Button" description="Hexagonal indicator via clip-path. Flat-top hexagon shape.">
|
||||||
|
<DTRadioGroup
|
||||||
|
options={[
|
||||||
|
{ value: 'nfc', label: 'NFC (Near Field Communication)' },
|
||||||
|
{ value: 'rfid', label: 'RFID (Radio Frequency ID)' },
|
||||||
|
{ value: 'ble', label: 'BLE (Bluetooth Low Energy)' },
|
||||||
|
]}
|
||||||
|
value={radioValue}
|
||||||
|
onChange={setRadioValue}
|
||||||
|
/>
|
||||||
|
<CodeLabel text="<DTRadioGroup options value onChange />" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Progress Bar" description="Angular, no border-radius. 4px default height.">
|
||||||
|
<DemoLabel text="25%" />
|
||||||
|
<DTProgressBar value={0.25} />
|
||||||
|
<DemoLabel text="50% WITH LABEL" />
|
||||||
|
<DTProgressBar value={0.5} label="50%" />
|
||||||
|
<DemoLabel text="75%" />
|
||||||
|
<DTProgressBar value={0.75} />
|
||||||
|
<DemoLabel text="100%" />
|
||||||
|
<DTProgressBar value={1.0} label="100%" />
|
||||||
|
<CodeLabel text="<DTProgressBar value={0.5} label='50%' />" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Accordion" description="Click headers to expand. 5px top border, chevron rotation.">
|
||||||
|
<DTAccordion sections={accordionSections} />
|
||||||
|
<CodeLabel text="<DTAccordion sections={[{ key, title, children }]} />" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Quantity Stepper" description="Beveled +/- buttons with center display.">
|
||||||
|
<DTQuantityStepper value={stepperValue} onChange={setStepperValue} min={0} max={10} />
|
||||||
|
<CodeLabel text="<DTQuantityStepper value onChange min max />" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Filter Header" description="Thick top border accent. Active state switches to secondary color.">
|
||||||
|
<div style={{ maxWidth: 400 }}>
|
||||||
|
<button className="dt-filter-header" type="button">FILTER CATEGORY</button>
|
||||||
|
<button className="dt-filter-header active" type="button">ACTIVE FILTER</button>
|
||||||
|
</div>
|
||||||
|
<CodeLabel text=".dt-filter-header | .dt-filter-header.active" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Buttons" description="Beveled buttons with active state and mode colors. Nested levels use inline paddingLeft.">
|
||||||
|
<div style={{ maxWidth: 300 }}>
|
||||||
|
{[
|
||||||
|
{ name: 'All Products', cls: ' active', pad: 0 },
|
||||||
|
{ name: 'NFC Implants', cls: '', pad: 0 },
|
||||||
|
{ name: 'RFID Tags', cls: '', pad: 32 },
|
||||||
|
{ name: 'Accessories', cls: '', pad: 32 },
|
||||||
|
{ name: 'Lab Products', cls: ' mode-warning', pad: 0 },
|
||||||
|
].map(item => (
|
||||||
|
<button
|
||||||
|
key={item.name}
|
||||||
|
className={`dt-menu-item${item.cls}`}
|
||||||
|
style={item.pad ? { paddingLeft: item.pad } : undefined}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<CodeLabel text=".dt-menu-item | .active | paddingLeft for nesting" />
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import type { HexGridBackgroundProps } from '@dangerousthings/hex-background';
|
||||||
|
import { Section } from '../components/Section';
|
||||||
|
|
||||||
|
const DEFAULTS: Required<HexGridBackgroundProps> = {
|
||||||
|
opacity: 0.5,
|
||||||
|
hexRadius: 0.5,
|
||||||
|
margin: 0.05,
|
||||||
|
maxHeight: 3,
|
||||||
|
animationInterval: 1500,
|
||||||
|
cameraSpeed: 0.02,
|
||||||
|
cameraRadius: 8,
|
||||||
|
fov: 40,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SliderConfig {
|
||||||
|
key: keyof HexGridBackgroundProps;
|
||||||
|
label: string;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sliders: SliderConfig[] = [
|
||||||
|
{ key: 'opacity', label: 'Opacity', min: 0, max: 1, step: 0.05 },
|
||||||
|
{ key: 'hexRadius', label: 'Hex Radius', min: 0.1, max: 2, step: 0.1 },
|
||||||
|
{ key: 'margin', label: 'Margin', min: 0, max: 0.5, step: 0.01 },
|
||||||
|
{ key: 'maxHeight', label: 'Max Height', min: 0.5, max: 10, step: 0.5 },
|
||||||
|
{ key: 'animationInterval', label: 'Animation Interval (ms)', min: 200, max: 5000, step: 100 },
|
||||||
|
{ key: 'cameraSpeed', label: 'Camera Speed', min: 0, max: 0.2, step: 0.005 },
|
||||||
|
{ key: 'cameraRadius', label: 'Camera Radius', min: 3, max: 20, step: 0.5 },
|
||||||
|
{ key: 'fov', label: 'Field of View', min: 20, max: 90, step: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface HexBackgroundPageProps {
|
||||||
|
hexProps: HexGridBackgroundProps;
|
||||||
|
onHexPropsChange: (props: HexGridBackgroundProps) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HexBackgroundPage({ hexProps, onHexPropsChange }: HexBackgroundPageProps) {
|
||||||
|
const handleChange = (key: keyof HexGridBackgroundProps, value: number) => {
|
||||||
|
onHexPropsChange({ ...hexProps, [key]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
onHexPropsChange({ ...DEFAULTS });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 className="page-title">Hex Background</h1>
|
||||||
|
<p className="page-subtitle">
|
||||||
|
3D hexagon grid background powered by Three.js + React Three Fiber.
|
||||||
|
Adjust parameters below — changes apply to the global background in real time.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Section title="Parameters" description="All HexGridBackground props exposed as controls.">
|
||||||
|
<div style={{ display: 'grid', gap: 'var(--space-4)' }}>
|
||||||
|
{sliders.map(({ key, label, min, max, step }) => (
|
||||||
|
<div key={key} style={{ display: 'grid', gridTemplateColumns: '1fr auto', alignItems: 'center', gap: 'var(--space-4)' }}>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<span style={{ fontSize: '0.8rem', color: 'var(--color-text-muted)', letterSpacing: '0.1em' }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={hexProps[key] ?? DEFAULTS[key]}
|
||||||
|
onChange={e => handleChange(key, parseFloat(e.target.value))}
|
||||||
|
style={{ width: '100%', accentColor: 'var(--color-primary)' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
minWidth: '4.5em',
|
||||||
|
textAlign: 'right',
|
||||||
|
}}>
|
||||||
|
{hexProps[key] ?? DEFAULTS[key]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="dt-btn mode-normal"
|
||||||
|
onClick={handleReset}
|
||||||
|
type="button"
|
||||||
|
style={{ marginTop: 'var(--space-6)' }}
|
||||||
|
>
|
||||||
|
Reset to Defaults
|
||||||
|
</button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Usage" description="Drop-in component for any React app with Three.js.">
|
||||||
|
<div className="terminal dt-accent-top">
|
||||||
|
<code>{`import { HexGridBackground } from '@dangerousthings/hex-background';
|
||||||
|
|
||||||
|
// Full-viewport fixed background (renders behind content)
|
||||||
|
<HexGridBackground
|
||||||
|
opacity={${hexProps.opacity ?? DEFAULTS.opacity}}
|
||||||
|
hexRadius={${hexProps.hexRadius ?? DEFAULTS.hexRadius}}
|
||||||
|
maxHeight={${hexProps.maxHeight ?? DEFAULTS.maxHeight}}
|
||||||
|
cameraSpeed={${hexProps.cameraSpeed ?? DEFAULTS.cameraSpeed}}
|
||||||
|
/>`}</code>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
packages/showcase/desktop/src/renderer/pages/HomePage.tsx
Normal file
99
packages/showcase/desktop/src/renderer/pages/HomePage.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { DTCard } from '@dangerousthings/react';
|
||||||
|
import type { DTVariant } from '@dangerousthings/tokens';
|
||||||
|
import { Section, CodeLabel } from '../components/Section';
|
||||||
|
|
||||||
|
const categories: { hash: string; title: string; desc: string; mode: DTVariant; count: number }[] = [
|
||||||
|
{ hash: 'bevels', title: 'Bevels', desc: 'Angular clip-path patterns — cards, buttons, badges, media frames, modals, drawers', mode: 'normal', count: 8 },
|
||||||
|
{ hash: 'forms', title: 'Forms', desc: 'Text inputs, checkboxes, switches, radios, progress bars, accordions, steppers', mode: 'success', count: 7 },
|
||||||
|
{ hash: 'animations', title: 'Animations', desc: 'Entrance animations, transition utilities, scrollbar styling', mode: 'other', count: 5 },
|
||||||
|
{ hash: 'cards-advanced', title: 'Advanced Cards', desc: 'Card color modes, progress bars, badge overlays, interactive bevel buttons, feature legend', mode: 'warning', count: 6 },
|
||||||
|
{ hash: 'tokens', title: 'Tokens', desc: 'Color palette, typography, spacing, and shape values for the active brand', mode: 'normal', count: 3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function HexDecor({ color, size = 14, opacity = 0.5 }: { color: string; size?: number; opacity?: number }) {
|
||||||
|
return (
|
||||||
|
<svg width={size * 2} height={size * 2} viewBox="0 0 28 28" style={{ opacity }}>
|
||||||
|
<polygon
|
||||||
|
points="14,1 25.5,7.5 25.5,20.5 14,27 2.5,20.5 2.5,7.5"
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HomePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 'var(--space-8)' }}>
|
||||||
|
<h1 style={{
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
fontSize: '2.5rem',
|
||||||
|
fontWeight: 900,
|
||||||
|
letterSpacing: '0.15em',
|
||||||
|
marginBottom: '0.25rem',
|
||||||
|
}}>
|
||||||
|
DANGEROUS THINGS
|
||||||
|
</h1>
|
||||||
|
<p style={{
|
||||||
|
color: 'var(--mode-emphasis, var(--color-secondary))',
|
||||||
|
fontSize: '1.25rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.2em',
|
||||||
|
marginTop: 0,
|
||||||
|
}}>
|
||||||
|
DESIGN SYSTEM
|
||||||
|
</p>
|
||||||
|
<p style={{
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
marginTop: 'var(--space-2)',
|
||||||
|
}}>
|
||||||
|
React component showcase — switch brands and modes in the sidebar
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 12,
|
||||||
|
marginTop: 'var(--space-6)',
|
||||||
|
}}>
|
||||||
|
<HexDecor color="var(--mode-normal, var(--color-primary))" opacity={0.4} />
|
||||||
|
<HexDecor color="var(--mode-emphasis, var(--color-secondary))" opacity={0.6} />
|
||||||
|
<HexDecor color="var(--mode-warning, var(--color-error))" opacity={0.4} />
|
||||||
|
<HexDecor color="var(--mode-success, var(--color-accent))" opacity={0.6} />
|
||||||
|
<HexDecor color="var(--mode-other, var(--color-other))" opacity={0.4} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
letterSpacing: '0.15em',
|
||||||
|
marginBottom: 'var(--space-4)',
|
||||||
|
}}>
|
||||||
|
COMPONENT CATALOG
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-dt-4">
|
||||||
|
{categories.map(cat => (
|
||||||
|
<a key={cat.hash} href={`#/${cat.hash}`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||||
|
<DTCard variant={cat.mode} title={cat.title}>
|
||||||
|
<div className="card-body">{cat.desc}</div>
|
||||||
|
{cat.count > 0 && (
|
||||||
|
<div style={{ color: 'var(--color-text-muted)', fontSize: '0.7rem', marginTop: 'var(--space-2)' }}>
|
||||||
|
{cat.count} components
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DTCard>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Section title="Quick Start" description="Import the React components and CSS to get started.">
|
||||||
|
<div className="terminal dt-accent-top">
|
||||||
|
<code>{`import { DTCard, DTButton } from '@dangerousthings/react';\nimport '@dangerousthings/web/index.css';`}</code>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
packages/showcase/desktop/src/renderer/pages/TokensPage.tsx
Normal file
99
packages/showcase/desktop/src/renderer/pages/TokensPage.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { brands } from '@dangerousthings/tokens';
|
||||||
|
import type { ThemeBrand } from '@dangerousthings/tokens';
|
||||||
|
import { Section, CodeLabel } from '../components/Section';
|
||||||
|
|
||||||
|
interface TokensPageProps {
|
||||||
|
brand: ThemeBrand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TokensPage({ brand: brandId }: TokensPageProps) {
|
||||||
|
const brand = brands[brandId];
|
||||||
|
if (!brand) return null;
|
||||||
|
|
||||||
|
const darkColors = brand.dark;
|
||||||
|
const darkEntries = Object.entries(darkColors) as [string, string][];
|
||||||
|
const lightColors = brand.light;
|
||||||
|
const lightEntries = Object.entries(lightColors) as [string, string][];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 className="page-title">Tokens</h1>
|
||||||
|
<p className="page-subtitle">Design token values for the active brand. Switch brands in the sidebar.</p>
|
||||||
|
|
||||||
|
<Section title={`Color Palette — ${brand.name} (Dark Mode)`} description={brand.description}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 'var(--space-3)' }}>
|
||||||
|
{darkEntries.map(([name, hex]) => (
|
||||||
|
<div key={name} style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||||
|
<div style={{ width: 32, height: 32, background: hex, border: '1px solid rgba(255,255,255,0.15)', flexShrink: 0 }} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: '0.8125rem' }}>{name}</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.6875rem', opacity: 0.5 }}>{hex}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title={`Color Palette — ${brand.name} (Light Mode)`} description="Light mode color tokens.">
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 'var(--space-3)' }}>
|
||||||
|
{lightEntries.map(([name, hex]) => (
|
||||||
|
<div key={name} style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||||
|
<div style={{ width: 32, height: 32, background: hex, border: '1px solid rgba(255,255,255,0.15)', flexShrink: 0 }} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: '0.8125rem' }}>{name}</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.6875rem', opacity: 0.5 }}>{hex}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Typography" description="Font family tokens for headings, body text, and monospace.">
|
||||||
|
<div className="terminal" style={{ marginBottom: 'var(--space-4)' }}>
|
||||||
|
<code>{`heading: ${brand.typography.heading}\nbody: ${brand.typography.body}\nmono: ${brand.typography.mono}`}</code>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Typography Scale" description="Live rendering with the current brand's font families.">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
|
||||||
|
<h1 style={{ fontFamily: 'var(--font-heading)', fontSize: '2rem', fontWeight: 900 }}>Heading 1</h1>
|
||||||
|
<h2 style={{ fontFamily: 'var(--font-heading)', fontSize: '1.5rem', fontWeight: 700 }}>Heading 2</h2>
|
||||||
|
<h3 style={{ fontFamily: 'var(--font-heading)', fontSize: '1.25rem', fontWeight: 600 }}>Heading 3</h3>
|
||||||
|
<p style={{ fontFamily: 'var(--font-body)', fontSize: '1rem' }}>Body text — the quick brown fox jumps over the lazy dog</p>
|
||||||
|
<code style={{ fontFamily: 'var(--font-mono)', fontSize: '0.875rem' }}>{'const monospace = "code";'}</code>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Shape Tokens" description="Bevel and radius values determine the visual language — angular (DT) vs rounded (Classic).">
|
||||||
|
<div className="terminal" style={{ marginBottom: 'var(--space-4)' }}>
|
||||||
|
<code>{`bevelSm: ${brand.shape.bevelSm}\nbevelMd: ${brand.shape.bevelMd}\nbevelLg: ${brand.shape.bevelLg}\nradiusSm: ${brand.shape.radiusSm}\nradius: ${brand.shape.radius}\nradiusLg: ${brand.shape.radiusLg}`}</code>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Spacing Scale" description="Shared spacing tokens across all brands.">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
|
||||||
|
{[
|
||||||
|
{ name: '--space-1', value: '0.25rem (4px)', width: '1.5rem' },
|
||||||
|
{ name: '--space-2', value: '0.5rem (8px)', width: '3rem' },
|
||||||
|
{ name: '--space-3', value: '0.75rem (12px)', width: '4.5rem' },
|
||||||
|
{ name: '--space-4', value: '1rem (16px)', width: '6rem' },
|
||||||
|
{ name: '--space-6', value: '1.5rem (24px)', width: '9rem' },
|
||||||
|
{ name: '--space-8', value: '2rem (32px)', width: '12rem' },
|
||||||
|
].map(sp => (
|
||||||
|
<div key={sp.name} style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||||
|
<div style={{ width: sp.width, height: 12, background: 'var(--color-primary)', opacity: 0.6 }} />
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.75rem', opacity: 0.6 }}>{sp.name}: {sp.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="CSS Custom Properties" description="These are generated from TypeScript tokens and applied per-brand via data-brand attribute.">
|
||||||
|
<div className="terminal">
|
||||||
|
<code>{`/* Generated CSS custom properties */\n--color-bg: ${darkColors.bg};\n--color-primary: ${darkColors.primary};\n--color-secondary: ${darkColors.secondary};\n--color-error: ${darkColors.error};\n--color-success: ${darkColors.success};\n--bevel-sm: ${brand.shape.bevelSm};\n--bevel-md: ${brand.shape.bevelMd};\n--font-heading: ${brand.typography.heading};`}</code>
|
||||||
|
</div>
|
||||||
|
<CodeLabel text="See packages/tokens/src/scripts/generate-css.ts" />
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
302
packages/showcase/desktop/src/renderer/style.css
Normal file
302
packages/showcase/desktop/src/renderer/style.css
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* Showcase App Layout */
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Sidebar ---- */
|
||||||
|
#sidebar {
|
||||||
|
width: 220px;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-right: 2px solid var(--color-border);
|
||||||
|
padding: var(--space-6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-title {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--color-primary);
|
||||||
|
padding-bottom: var(--space-4);
|
||||||
|
border-bottom: 2px solid var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-switcher-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: 2px solid var(--color-primary);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-btn:hover {
|
||||||
|
background: rgba(var(--color-primary-rgb), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-btn.active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-switcher {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-btn.active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-bg);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
display: block;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-left-color: var(--color-primary);
|
||||||
|
background: rgba(var(--color-primary-rgb), 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active {
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-left-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Main Content ---- */
|
||||||
|
#content {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 220px;
|
||||||
|
padding: var(--space-8);
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Demo Sections ---- */
|
||||||
|
.demo-section {
|
||||||
|
margin-bottom: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-section h2 {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
padding-bottom: var(--space-2);
|
||||||
|
border-bottom: 2px solid var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-description {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-label {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-label {
|
||||||
|
color: rgba(var(--color-text-primary-rgb, 255, 255, 255), 0.35);
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Shared Component Base Styles ---- */
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary,
|
||||||
|
.btn-danger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-3) var(--space-6);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-primary);
|
||||||
|
border: 2px solid var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--color-error);
|
||||||
|
color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards — DT brand padding is set in bevels.css via --_card-pad */
|
||||||
|
.card {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Classic brand: cards need explicit padding (no bevel CSS provides it) */
|
||||||
|
[data-brand="classic"] .card {
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 1rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inputs */
|
||||||
|
.input,
|
||||||
|
input[type="text"],
|
||||||
|
input[type="email"],
|
||||||
|
input[type="password"] {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus,
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Terminal */
|
||||||
|
.terminal {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: var(--space-6);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-accent, var(--color-primary));
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page title */
|
||||||
|
.page-title {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: var(--space-8);
|
||||||
|
}
|
||||||
10
packages/showcase/desktop/tailwind.config.js
Normal file
10
packages/showcase/desktop/tailwind.config.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import dtPreset from '@dangerousthings/tailwind-preset';
|
||||||
|
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
presets: [dtPreset],
|
||||||
|
content: ['./src/renderer/**/*.{tsx,ts,html}'],
|
||||||
|
corePlugins: {
|
||||||
|
preflight: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
11
packages/showcase/desktop/tsconfig.json
Normal file
11
packages/showcase/desktop/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist/renderer",
|
||||||
|
"rootDir": "src/renderer",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/renderer"]
|
||||||
|
}
|
||||||
14
packages/showcase/desktop/tsconfig.main.json
Normal file
14
packages/showcase/desktop/tsconfig.main.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist/main",
|
||||||
|
"rootDir": "src/main",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"declaration": false,
|
||||||
|
"declarationMap": false,
|
||||||
|
"sourceMap": false
|
||||||
|
},
|
||||||
|
"include": ["src/main"]
|
||||||
|
}
|
||||||
30
packages/showcase/desktop/vite.config.ts
Normal file
30
packages/showcase/desktop/vite.config.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root: 'src/renderer',
|
||||||
|
base: './',
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
outDir: '../../dist/renderer',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@dangerousthings/web': path.resolve(__dirname, '../../web/dist'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
// Don't pre-bundle design system packages — they're local workspace deps
|
||||||
|
// that change frequently during development. Pre-bundling caches them in
|
||||||
|
// node_modules/.vite which causes stale CSS/JS after rebuilds.
|
||||||
|
exclude: ['@dangerousthings/web', '@dangerousthings/react', '@dangerousthings/tokens'],
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
// Watch design system dist directories for changes during dev
|
||||||
|
watch: {
|
||||||
|
ignored: ['!**/packages/web/dist/**', '!**/packages/react/dist/**', '!**/packages/tokens/dist/**'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user