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:
42
packages/react/package.json
Normal file
42
packages/react/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@dangerousthings/react",
|
||||
"version": "0.1.0",
|
||||
"description": "React web components for the Dangerous Things design system",
|
||||
"license": "MIT",
|
||||
"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>
|
||||
);
|
||||
}
|
||||
66
packages/react/src/components/DTCard.tsx
Normal file
66
packages/react/src/components/DTCard.tsx
Normal 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 (0–100) 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
144
packages/react/src/components/DTDrawer.tsx
Normal file
144
packages/react/src/components/DTDrawer.tsx
Normal 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];
|
||||
}
|
||||
77
packages/react/src/components/DTFeatureLegend.tsx
Normal file
77
packages/react/src/components/DTFeatureLegend.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
packages/react/src/components/DTGallery.tsx
Normal file
91
packages/react/src/components/DTGallery.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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', 'badge-mode', getVariantClass(variant), className)}
|
||||
style={style}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
26
packages/react/src/components/DTMediaFrame.tsx
Normal file
26
packages/react/src/components/DTMediaFrame.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
120
packages/react/src/components/DTMobileFilterOverlay.tsx
Normal file
120
packages/react/src/components/DTMobileFilterOverlay.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
96
packages/react/src/components/DTModal.tsx
Normal file
96
packages/react/src/components/DTModal.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
58
packages/react/src/components/DTRadioGroup.tsx
Normal file
58
packages/react/src/components/DTRadioGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
39
packages/react/src/components/DTStaggerContainer.tsx
Normal file
39
packages/react/src/components/DTStaggerContainer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
53
packages/react/src/components/DTTextInput.tsx
Normal file
53
packages/react/src/components/DTTextInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
packages/react/src/components/DTWebThemeProvider.tsx
Normal file
42
packages/react/src/components/DTWebThemeProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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]);
|
||||
}
|
||||
60
packages/react/src/index.ts
Normal file
60
packages/react/src/index.ts
Normal 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';
|
||||
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"]
|
||||
}
|
||||
Reference in New Issue
Block a user