feat: video letterboxing, card badge styling, component updates
- Add object-fit: contain + black background for video in DTMediaFrame - Style .dt-card-badge with mode-colored background, padding, and bevel-aware positioning (negative right offset for clean diagonal clip) - Restore DTStaggerContainer in react-native (was incorrectly deleted) - Update DTGallery, DTModal, DTMobileFilterOverlay, DTMediaFrame components - Refresh showcase pages for desktop and mobile Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -160,7 +160,6 @@ export function DTDrawer({
|
||||
fontWeight: 900,
|
||||
fontSize: '2em',
|
||||
letterSpacing: '0.05em',
|
||||
textTransform: 'uppercase',
|
||||
borderBottom: '1px solid var(--color-bg)',
|
||||
}}>
|
||||
<span>{heading}</span>
|
||||
|
||||
@@ -1,56 +1,168 @@
|
||||
/**
|
||||
* DTGallery — Image gallery with beveled media frame and thumbnails.
|
||||
* DTGallery — Media gallery with beveled frame and thumbnails.
|
||||
* Supports images, 3D models (via <model-viewer>), videos, and external videos.
|
||||
*
|
||||
* CSS reference: bevels.css .dt-bevel-media
|
||||
*/
|
||||
|
||||
import { useState, type CSSProperties } from 'react';
|
||||
import { useState, useEffect, 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;
|
||||
/* ── TypeScript declaration for <model-viewer> web component ── */
|
||||
declare global {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
'model-viewer': React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLElement>,
|
||||
HTMLElement
|
||||
> & {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
poster?: string;
|
||||
'camera-controls'?: boolean | string;
|
||||
'auto-rotate'?: boolean | string;
|
||||
'interaction-prompt'?: string;
|
||||
ar?: boolean | string;
|
||||
loading?: 'auto' | 'lazy' | 'eager';
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Gallery item types ── */
|
||||
|
||||
export type DTGalleryItem =
|
||||
| { type?: 'image'; key: string; src: string; alt?: string; thumbnail?: string }
|
||||
| { type: 'model3d'; key: string; src: string; alt?: string; thumbnail?: string; poster?: string }
|
||||
| { type: 'video'; key: string; src: string; alt?: string; thumbnail?: string; poster?: string }
|
||||
| { type: 'external-video'; key: string; src: string; alt?: string; thumbnail?: string };
|
||||
|
||||
interface DTGalleryProps {
|
||||
items: DTGalleryItem[];
|
||||
/** Color variant @default 'normal' */
|
||||
variant?: DTVariant;
|
||||
/** Aspect ratio (width / height) for the display area @default 1 */
|
||||
aspectRatio?: number;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/* ── Model-viewer script loader ── */
|
||||
|
||||
const MODEL_VIEWER_SRC =
|
||||
'https://unpkg.com/@google/model-viewer@v1.12.1/dist/model-viewer.min.js';
|
||||
|
||||
let modelViewerLoaded = false;
|
||||
|
||||
function useModelViewerScript(needed: boolean) {
|
||||
useEffect(() => {
|
||||
if (!needed || modelViewerLoaded || typeof document === 'undefined') return;
|
||||
if (customElements.get('model-viewer')) {
|
||||
modelViewerLoaded = true;
|
||||
return;
|
||||
}
|
||||
const script = document.createElement('script');
|
||||
script.type = 'module';
|
||||
script.src = MODEL_VIEWER_SRC;
|
||||
document.head.appendChild(script);
|
||||
modelViewerLoaded = true;
|
||||
}, [needed]);
|
||||
}
|
||||
|
||||
/* ── Shared styles ── */
|
||||
|
||||
const fillStyle: CSSProperties = {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'block',
|
||||
};
|
||||
|
||||
/* ── Component ── */
|
||||
|
||||
export function DTGallery({
|
||||
items,
|
||||
variant = 'normal',
|
||||
aspectRatio = 1,
|
||||
className,
|
||||
style,
|
||||
}: DTGalleryProps) {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
const hasModel = items.some((i) => i.type === 'model3d');
|
||||
useModelViewerScript(hasModel);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const current = items[activeIndex];
|
||||
const currentType = current.type || 'image';
|
||||
|
||||
return (
|
||||
<div className={cx(getVariantClass(variant), className)} style={style}>
|
||||
{/* Main 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',
|
||||
}}
|
||||
/>
|
||||
{/* Main display */}
|
||||
<div
|
||||
className="dt-bevel-media"
|
||||
style={{ overflow: 'hidden', aspectRatio, position: 'relative' }}
|
||||
>
|
||||
{currentType === 'image' && (
|
||||
<img
|
||||
src={current.src}
|
||||
alt={current.alt || ''}
|
||||
style={{
|
||||
...fillStyle,
|
||||
objectFit: 'contain',
|
||||
transition: 'opacity 300ms ease-in-out',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentType === 'model3d' && (
|
||||
<model-viewer
|
||||
src={current.src}
|
||||
alt={current.alt || ''}
|
||||
poster={'poster' in current ? current.poster : undefined}
|
||||
camera-controls=""
|
||||
auto-rotate=""
|
||||
interaction-prompt="auto"
|
||||
loading="lazy"
|
||||
style={{
|
||||
...fillStyle,
|
||||
backgroundColor: 'var(--color-bg, #000)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentType === 'video' && (
|
||||
<video
|
||||
src={current.src}
|
||||
poster={'poster' in current ? current.poster : undefined}
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
style={{
|
||||
...fillStyle,
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentType === 'external-video' && (
|
||||
<iframe
|
||||
src={current.src}
|
||||
title={current.alt || 'Video'}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
style={{
|
||||
...fillStyle,
|
||||
border: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thumbnails */}
|
||||
{items.length > 1 && (
|
||||
<div
|
||||
@@ -59,31 +171,59 @@ export function DTGallery({
|
||||
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>
|
||||
))}
|
||||
}}
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
const itemType = item.type || 'image';
|
||||
const thumbSrc = item.thumbnail || ('poster' in item ? item.poster : undefined) || item.src;
|
||||
const isMedia = itemType === 'video' || itemType === 'external-video';
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => setActiveIndex(i)}
|
||||
type="button"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: '64px',
|
||||
height: '64px',
|
||||
padding: 0,
|
||||
border:
|
||||
i === activeIndex
|
||||
? '2px solid var(--dt-card-color, var(--color-primary))'
|
||||
: '2px solid transparent',
|
||||
background: 'none',
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={thumbSrc}
|
||||
alt={item.alt || ''}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
{/* Play icon overlay for video thumbnails */}
|
||||
{isMedia && (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="white"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
margin: 'auto',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.6))',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,25 +2,43 @@
|
||||
* DTMediaFrame — Diagonal beveled frame (top-left + bottom-right).
|
||||
*
|
||||
* CSS reference: bevels.css .dt-bevel-media
|
||||
*
|
||||
* The outer div provides the colored bevel border via clip-path.
|
||||
* A ::before pseudo-element fills the inner surface.
|
||||
* Direct img/video children are auto-clipped to match.
|
||||
* A ::after pseudo-element shows "MISSING MEDIA" when empty.
|
||||
*/
|
||||
|
||||
import type { ReactNode, CSSProperties } from 'react';
|
||||
import type { DTVariant } from '@dangerousthings/tokens';
|
||||
import { cx } from '../utils/cx';
|
||||
import { getVariantClass } from '../utils/variantClasses';
|
||||
|
||||
interface DTMediaFrameProps {
|
||||
children: ReactNode;
|
||||
/** Color variant for the bevel border @default 'normal' */
|
||||
variant?: DTVariant;
|
||||
/** Custom placeholder shown when children is nullish (overrides CSS ::after) */
|
||||
placeholder?: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export function DTMediaFrame({
|
||||
children,
|
||||
variant = 'normal',
|
||||
placeholder,
|
||||
className,
|
||||
style,
|
||||
}: DTMediaFrameProps) {
|
||||
const isEmpty = children == null || children === false;
|
||||
|
||||
return (
|
||||
<div className={cx('dt-bevel-media', className)} style={style}>
|
||||
{children}
|
||||
<div
|
||||
className={cx('dt-bevel-media', getVariantClass(variant), className)}
|
||||
style={style}
|
||||
>
|
||||
{isEmpty && placeholder ? placeholder : children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* .dt-filter-overlay-content
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback, type ReactNode, type CSSProperties } from 'react';
|
||||
import { useEffect, useCallback, useRef, type ReactNode, type CSSProperties } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { DTVariant } from '@dangerousthings/tokens';
|
||||
import { cx } from '../utils/cx';
|
||||
@@ -38,6 +38,9 @@ export function DTMobileFilterOverlay({
|
||||
className,
|
||||
style,
|
||||
}: DTMobileFilterOverlayProps) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onDismiss();
|
||||
@@ -45,6 +48,7 @@ export function DTMobileFilterOverlay({
|
||||
[onDismiss],
|
||||
);
|
||||
|
||||
// Escape key
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
@@ -52,12 +56,57 @@ export function DTMobileFilterOverlay({
|
||||
}
|
||||
}, [visible, handleKeyDown]);
|
||||
|
||||
// Body scroll lock
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// Focus trap
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
triggerRef.current = document.activeElement as HTMLElement;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
const first = panelRef.current?.querySelector<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
first?.focus();
|
||||
}, 100);
|
||||
|
||||
const handleTab = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab' || !panelRef.current) return;
|
||||
const focusable = panelRef.current.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleTab);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener('keydown', handleTab);
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
}, [visible]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className={cx('dt-filter-overlay', getVariantClass(variant), className)} style={style}>
|
||||
<div className="dt-filter-overlay-backdrop" onClick={onDismiss} />
|
||||
<div className="dt-filter-overlay-content">
|
||||
<div ref={panelRef} className="dt-filter-overlay-content">
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
@@ -67,7 +116,7 @@ export function DTMobileFilterOverlay({
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid rgba(var(--color-primary-rgb), 0.2)',
|
||||
}}>
|
||||
<span style={{ fontWeight: 700, fontSize: '1.125rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
<span style={{ fontWeight: 700, fontSize: '1.125rem', letterSpacing: '0.05em' }}>
|
||||
{heading}
|
||||
{activeFilterCount !== undefined && activeFilterCount > 0 && (
|
||||
<span
|
||||
@@ -88,7 +137,6 @@ export function DTMobileFilterOverlay({
|
||||
color: 'var(--color-primary)',
|
||||
fontSize: '0.875rem',
|
||||
cursor: 'pointer',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
Clear All
|
||||
</button>
|
||||
|
||||
@@ -82,13 +82,35 @@ export function DTModal({
|
||||
position: 'relative',
|
||||
maxWidth: '90vw',
|
||||
maxHeight: '85vh',
|
||||
overflow: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
...style,
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true">
|
||||
{title && <div className="card-title">{title}</div>}
|
||||
<div style={{ padding: 'var(--space-6, 24px)' }}>{children}</div>
|
||||
{title && (
|
||||
<div className="card-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0 }}>
|
||||
<span>{title}</span>
|
||||
{dismissable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
aria-label="Close"
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'inherit',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
fontSize: '1.25rem',
|
||||
lineHeight: 1,
|
||||
}}>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ padding: 'var(--space-6, 24px)', overflow: 'auto', flex: '1 1 auto' }}>{children}</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
|
||||
@@ -36,7 +36,6 @@ export function DTTextInput({
|
||||
marginBottom: '4px',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}>
|
||||
{label}
|
||||
|
||||
Reference in New Issue
Block a user