Add storefront component migration: cards, badges, progress bars, animations, and React wrapper package

Major feature migration from dt-shopify-storefront into the design system:

- Card system: per-card color modes (normal/emphasis/warning/success/other),
  progress bars, full-bleed card body (card-body-flush), image bevel clip-paths,
  and visible structural borders at bevel corners
- Badge system: dt-card-badge with position-aware offsets for cards vs media frames
- Media frames: dt-bevel-media with inner surface fill, image clipping, and
  "MISSING MEDIA" placeholder for empty containers
- Interactive bevel buttons: dt-btn with hover/selected states and pulse animation
- Menu items: dt-menu-item with active states and level indentation
- Animations: scale-in, fade-in, slide-up, pulse, ping, spin keyframes
- Feature legend: CSS grid for product feature icons with state colors
- Scrollbar styling for DT brand
- New @dangerousthings/react package wrapping web CSS components
- New @dangerousthings/tailwind-preset package
- New @dangerousthings/hex-background package
- Desktop showcase rewritten in React with Tailwind CSS
- Mobile showcase updated with new screens (animations, filters, advanced cards)
- Tokens: added mode color tokens, RGB variants, and selected-state tokens

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
michael
2026-03-05 13:26:03 -08:00
parent 47e8085581
commit e74c285b70
109 changed files with 7196 additions and 1026 deletions

View File

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

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

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

View File

@@ -0,0 +1,66 @@
/**
* 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 the bottom up.
*/
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;
/** Progress value (0100) for left-edge vertical progress bar @default 0 */
progress?: number;
className?: string;
style?: CSSProperties;
onClick?: () => void;
}
export function DTCard({
children,
variant = 'normal',
title,
showHeader,
progress = 0,
className,
style,
onClick,
}: DTCardProps) {
const shouldShowHeader = showHeader ?? !!title;
const Tag = onClick ? 'button' : 'div';
return (
<Tag
className={cx(
'card',
getVariantClass(variant),
className,
)}
style={{
...style,
'--dt-card-progress': progress,
} as CSSProperties}
onClick={onClick}
type={onClick ? 'button' : undefined}>
{shouldShowHeader && (
<div className="card-title">
{title}
</div>
)}
{children}
</Tag>
);
}

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

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

View File

@@ -0,0 +1,144 @@
/**
* DTDrawer — Sliding side panel with beveled edges.
*
* CSS reference: bevels.css .dt-bevel-drawer-right, .dt-bevel-drawer-left
*/
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 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 handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onDismiss();
},
[onDismiss],
);
useEffect(() => {
if (visible) {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}
}, [visible, handleKeyDown]);
if (!visible) return null;
const bevelClass = position === 'right' ? 'dt-bevel-drawer-right' : 'dt-bevel-drawer-left';
const widthValue = typeof width === 'number' ? `${width}px` : width;
return createPortal(
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 1000,
}}>
{/* Backdrop */}
<div
style={{
position: 'absolute',
inset: 0,
background: 'rgba(0, 0, 0, 0.5)',
backdropFilter: 'blur(4px)',
animation: 'dt-fade-in 0.2s ease-in-out both',
}}
onClick={onDismiss}
/>
{/* Panel */}
<div
className={cx(
bevelClass,
getVariantClass(headingVariant),
className,
)}
style={{
position: 'absolute',
top: 0,
bottom: 0,
[position]: 0,
width: widthValue,
maxWidth: '100vw',
background: 'var(--color-bg)',
display: 'flex',
flexDirection: 'column',
animation: `dt-slide-${position === 'right' ? 'up' : 'up'} 0.2s ease-in-out both`,
...style,
}}>
{/* Header */}
<div
style={{
padding: '16px',
background: `var(${getVariantCSSVar(headingVariant)}, var(--color-primary))`,
color: 'var(--color-bg)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontWeight: 700,
fontSize: '18px',
letterSpacing: '0.5px',
}}>
<span>{heading}</span>
<button
onClick={onDismiss}
style={{
background: 'none',
border: 'none',
color: 'inherit',
fontSize: '20px',
fontWeight: 700,
cursor: 'pointer',
padding: '0 8px',
}}
type="button">
</button>
</div>
{/* Content */}
<div style={{ flex: 1, overflow: 'auto', padding: '16px' }}>
{children}
</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];
}

View File

@@ -0,0 +1,77 @@
/**
* DTFeatureLegend — Grid of product feature icons with rotated labels.
*
* CSS reference: feature-legend.css .dt-feature-legend, .dt-feature-legend-header,
* .dt-feature-legend-grid, .dt-feature-legend-item, .dt-feature-supported, etc.
*/
import type { ReactNode, CSSProperties } from 'react';
import type { DTVariant } from '@dangerousthings/tokens';
import { cx } from '../utils/cx';
import { getVariantClass, featureStateToVariant } from '../utils/variantClasses';
export interface DTFeatureItem {
key: string;
name: string;
icon: ReactNode;
state: 'supported' | 'disabled' | 'unsupported';
}
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;
className?: string;
style?: CSSProperties;
}
export function DTFeatureLegend({
features,
title,
variant = 'normal',
columns = 5,
iconSize = 42,
className,
style,
}: DTFeatureLegendProps) {
// Suppress unused import warning — featureStateToVariant is available for consumers
void featureStateToVariant;
return (
<div
className={cx('dt-feature-legend', getVariantClass(variant), className)}
style={style}>
{title && (
<div className="dt-feature-legend-header">{title}</div>
)}
<div
className="dt-feature-legend-grid"
style={{ '--dt-feature-columns': columns } as CSSProperties}>
{features.map(feature => (
<div
key={feature.key}
className={cx('dt-feature-legend-item', stateClassMap[feature.state])}
style={{ width: `${100 / columns}%` }}>
<div
className="dt-feature-legend-icon"
style={{ width: `${iconSize}px`, height: `${iconSize}px` }}>
{feature.icon}
</div>
<div className="dt-feature-legend-label">{feature.name}</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,91 @@
/**
* DTGallery — Image gallery with beveled media frame and thumbnails.
*
* CSS reference: bevels.css .dt-bevel-media
*/
import { useState, type CSSProperties } from 'react';
import type { DTVariant } from '@dangerousthings/tokens';
import { cx } from '../utils/cx';
import { getVariantClass } from '../utils/variantClasses';
export interface DTGalleryItem {
key: string;
src: string;
alt?: string;
thumbnail?: string;
}
interface DTGalleryProps {
items: DTGalleryItem[];
/** Color variant @default 'normal' */
variant?: DTVariant;
className?: string;
style?: CSSProperties;
}
export function DTGallery({
items,
variant = 'normal',
className,
style,
}: DTGalleryProps) {
const [activeIndex, setActiveIndex] = useState(0);
if (items.length === 0) return null;
const current = items[activeIndex];
return (
<div className={cx(getVariantClass(variant), className)} style={style}>
{/* Main image */}
<div className="dt-bevel-media" style={{ overflow: 'hidden' }}>
<img
src={current.src}
alt={current.alt || ''}
style={{
width: '100%',
height: 'auto',
display: 'block',
transition: 'opacity 300ms ease-in-out',
}}
/>
</div>
{/* Thumbnails */}
{items.length > 1 && (
<div
style={{
display: 'flex',
gap: '8px',
marginTop: '8px',
overflowX: 'auto',
}}>
{items.map((item, i) => (
<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',
}}>
<img
src={item.thumbnail || item.src}
alt={item.alt || ''}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</button>
))}
</div>
)}
</div>
);
}

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

View 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', 'badge-mode', getVariantClass(variant), className)}
style={style}>
{children}
</span>
);
}

View File

@@ -0,0 +1,26 @@
/**
* DTMediaFrame — Diagonal beveled frame (top-left + bottom-right).
*
* CSS reference: bevels.css .dt-bevel-media
*/
import type { ReactNode, CSSProperties } from 'react';
import { cx } from '../utils/cx';
interface DTMediaFrameProps {
children: ReactNode;
className?: string;
style?: CSSProperties;
}
export function DTMediaFrame({
children,
className,
style,
}: DTMediaFrameProps) {
return (
<div className={cx('dt-bevel-media', className)} style={style}>
{children}
</div>
);
}

View 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}
/>
))}
</>
);
}

View File

@@ -0,0 +1,120 @@
/**
* 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, 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 handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onDismiss();
},
[onDismiss],
);
useEffect(() => {
if (visible) {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}
}, [visible, handleKeyDown]);
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 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', textTransform: 'uppercase', 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',
textTransform: 'uppercase',
}}>
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,
);
}

View File

@@ -0,0 +1,96 @@
/**
* 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',
overflow: 'auto',
...style,
}}
role="dialog"
aria-modal="true">
{title && <div className="card-title">{title}</div>}
<div style={{ padding: 'var(--space-6, 24px)' }}>{children}</div>
</div>
</div>,
document.body,
);
}

View 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 (01) */
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>
);
}

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

View File

@@ -0,0 +1,58 @@
/**
* DTRadioGroup — Hexagonal radio buttons.
*
* CSS reference: forms-dt.css input[type="radio"], .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 => (
<label
key={option.value}
className={cx('dt-radio-option', value === option.value && 'selected')}>
<input
type="radio"
name={groupName}
value={option.value}
checked={value === option.value}
onChange={() => onChange(option.value)}
/>
<span>{option.label}</span>
</label>
))}
</div>
);
}

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

View File

@@ -0,0 +1,39 @@
/**
* DTStaggerContainer — Staggered scale-in animation for children.
*
* CSS reference: animations.css .dt-stagger-container
* CSS handles all stagger timing via nth-child — no JS animation needed.
*/
import type { ReactNode, 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 @default '75ms' */
interval?: string;
className?: string;
style?: CSSProperties;
}
export function DTStaggerContainer({
children,
duration,
interval,
className,
style,
}: DTStaggerContainerProps) {
const cssVars: Record<string, string> = {};
if (duration) cssVars['--dt-stagger-duration'] = duration;
if (interval) cssVars['--dt-stagger-interval'] = interval;
return (
<div
className={cx('dt-stagger-container', className)}
style={{ ...cssVars, ...style } as CSSProperties}>
{children}
</div>
);
}

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

View File

@@ -0,0 +1,53 @@
/**
* 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,
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}>
{label}
</label>
)}
<input
{...inputProps}
id={inputId}
className={cx('input', error && 'error')}
style={{ width: '100%', padding: '8px 12px' }}
/>
</div>
);
}

View File

@@ -0,0 +1,42 @@
/**
* DTWebThemeProvider
*
* Sets data-brand and data-theme attributes on a wrapper div.
* Provides React context for child components to read the active brand/theme.
*/
import { createContext, 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;
/** Additional className for the wrapper div */
className?: string;
children: ReactNode;
}
export function DTWebThemeProvider({
brand = 'dt',
theme = 'dark',
className,
children,
}: DTWebThemeProviderProps) {
return (
<DTWebThemeContext.Provider value={{ brand, theme }}>
<div data-brand={brand} data-theme={theme} className={cx('dt-theme-root', className)}>
{children}
</div>
</DTWebThemeContext.Provider>
);
}

View 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;
}

View 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]);
}

View 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]);
}

View File

@@ -0,0 +1,60 @@
/**
* @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 { DTStaggerContainer } from './components/DTStaggerContainer';
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';

View 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(' ');
}

View 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];
}