DTCard: make progress optional, fill top-to-bottom

Progress bar now fills top-down (was bottom-up). When progress prop is
omitted the bar stays empty and the card renders at full size. Only cards
that explicitly pass progress={0–100} show the animated bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
michael
2026-03-09 20:04:15 -07:00
parent 6816ef4825
commit 34be1d0b9e
2 changed files with 95 additions and 18 deletions

View File

@@ -6,7 +6,8 @@
* *
* The progress bar is a structural element on the left edge (0 to bevel-sm). * 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). * It is always present. At 0 progress it shows surface color (empty bar).
* As progress increases, accent color fills from the bottom up. * As progress increases, accent color fills from top to bottom.
* When progress is omitted, the bar stays empty (full card, no fill).
*/ */
import type { ReactNode, CSSProperties } from 'react'; import type { ReactNode, CSSProperties } from 'react';
@@ -22,8 +23,14 @@ interface DTCardProps {
title?: string; title?: string;
/** Whether to show the accent header bar @default true when title is provided */ /** Whether to show the accent header bar @default true when title is provided */
showHeader?: boolean; showHeader?: boolean;
/** Progress value (0100) for left-edge vertical progress bar @default 0 */ /** Dismiss callback — renders a close button in the header bar */
onDismiss?: () => void;
/** Progress value (0100) for left-edge vertical progress bar. Omit for empty bar. */
progress?: number; progress?: number;
/** Grow to fill available space in flex/grid containers @default false */
grow?: boolean;
/** Which area expands when grow is true @default 'body' */
growArea?: 'body' | 'title';
className?: string; className?: string;
style?: CSSProperties; style?: CSSProperties;
onClick?: () => void; onClick?: () => void;
@@ -34,12 +41,15 @@ export function DTCard({
variant = 'normal', variant = 'normal',
title, title,
showHeader, showHeader,
progress = 0, onDismiss,
progress,
grow = false,
growArea = 'body',
className, className,
style, style,
onClick, onClick,
}: DTCardProps) { }: DTCardProps) {
const shouldShowHeader = showHeader ?? !!title; const shouldShowHeader = showHeader ?? !!(title || onDismiss);
const Tag = onClick ? 'button' : 'div'; const Tag = onClick ? 'button' : 'div';
return ( return (
@@ -47,17 +57,36 @@ export function DTCard({
className={cx( className={cx(
'card', 'card',
getVariantClass(variant), getVariantClass(variant),
grow && 'dt-card-grow',
grow && growArea === 'title' && 'dt-card-grow-title',
className, className,
)} )}
style={{ style={{
...style, ...style,
'--dt-card-progress': progress, ...(progress != null && { '--dt-card-progress': progress }),
} as CSSProperties} } as CSSProperties}
onClick={onClick} onClick={onClick}
type={onClick ? 'button' : undefined}> type={onClick ? 'button' : undefined}>
{shouldShowHeader && ( {shouldShowHeader && (
<div className="card-title"> <div className="card-title" style={onDismiss ? { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } : undefined}>
{title} <span>{title}</span>
{onDismiss && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onDismiss(); }}
aria-label="Close"
style={{
background: 'none',
border: 'none',
color: 'inherit',
cursor: 'pointer',
padding: 0,
fontSize: '1.25rem',
lineHeight: 1,
}}>
&#x2715;
</button>
)}
</div> </div>
)} )}
{children} {children}

View File

@@ -19,6 +19,8 @@
[data-brand="dt"] .card { [data-brand="dt"] .card {
--dt-card-progress: 0; --dt-card-progress: 0;
--_card-pad: var(--space-6); --_card-pad: var(--space-6);
display: flex;
flex-direction: column;
position: relative; position: relative;
isolation: isolate; isolation: isolate;
background: var(--dt-card-color, var(--color-primary)); background: var(--dt-card-color, var(--color-primary));
@@ -54,7 +56,8 @@
/* Card Progress Bar — structural fill area on the left edge /* Card Progress Bar — structural fill area on the left edge
Sits between the 3px frame borders, follows bottom-left bevel diagonal. Sits between the 3px frame borders, follows bottom-left bevel diagonal.
Gradient fills from bottom (accent) to top (surface) based on --dt-card-progress. Gradient fills top-to-bottom based on --dt-card-progress so the card
visually "opens" as the bar descends.
Filled portion: fully opaque accent color. Unfilled portion: semi-transparent surface. Filled portion: fully opaque accent color. Unfilled portion: semi-transparent surface.
At 0%: all semi-transparent surface. At 100%: all opaque accent. At 0%: all semi-transparent surface. At 100%: all opaque accent.
Override --dt-progress-empty-opacity to control unfilled portion transparency. */ Override --dt-progress-empty-opacity to control unfilled portion transparency. */
@@ -70,7 +73,7 @@
calc(var(--bevel-sm) - 3px) calc(100% - 6px), calc(var(--bevel-sm) - 3px) calc(100% - 6px),
3px calc(100% - var(--bevel-sm))); 3px calc(100% - var(--bevel-sm)));
background: linear-gradient(to top, background: linear-gradient(to bottom,
rgba(var(--dt-card-color-rgb, var(--color-primary-rgb)), 1) calc(var(--dt-card-progress, 0) * 1%), rgba(var(--dt-card-color-rgb, var(--color-primary-rgb)), 1) calc(var(--dt-card-progress, 0) * 1%),
rgba(var(--color-surface-rgb), rgba(var(--color-surface-rgb),
var(--dt-progress-empty-opacity, 0.6)) calc(var(--dt-card-progress, 0) * 1%)); var(--dt-progress-empty-opacity, 0.6)) calc(var(--dt-card-progress, 0) * 1%));
@@ -85,6 +88,26 @@
z-index: 2; z-index: 2;
} }
/* Card Grow — stretch to fill available space in flex/grid containers.
By default the body (content area) grows. Use .dt-card-grow-title to
grow the header instead. */
.dt-card-grow {
flex: 1 1 0%;
height: 100%;
}
.dt-card-grow > .card-title {
flex: 0 0 auto;
}
.dt-card-grow:not(.dt-card-grow-title) > :last-child {
flex: 1 1 auto;
}
.dt-card-grow.dt-card-grow-title > .card-title {
flex: 1 1 auto;
}
/* Card Header — storefront .card-header pattern /* Card Header — storefront .card-header pattern
Title starts at bevel-sm (past the progress bar zone) and extends to the right edge. Title starts at bevel-sm (past the progress bar zone) and extends to the right edge.
Below it, the ::before dark surface shows through for the body area. */ Below it, the ::before dark surface shows through for the body area. */
@@ -102,7 +125,6 @@
background: var(--dt-card-color, var(--color-primary)); background: var(--dt-card-color, var(--color-primary));
color: var(--color-bg); color: var(--color-bg);
font-weight: 900; font-weight: 900;
text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
font-size: 1rem; font-size: 1rem;
position: relative; position: relative;
@@ -125,11 +147,10 @@
background: var(--dt-card-color, var(--color-primary)); background: var(--dt-card-color, var(--color-primary));
} }
/* Bottom-right bevel on card body content (img/video). Clip-path matches /* Bottom-right bevel on card body content. Clip-path matches
the card's inner surface bevel so the 3px structural border is visible the card's inner surface bevel so the 3px structural border is visible
at the diagonal corner. Badge is not clipped (it's a sibling, not img). */ at the diagonal corner. Badge overlays are excluded. */
[data-brand="dt"] .card-body-flush > img, [data-brand="dt"] .card-body-flush > *:not(.dt-card-badge) {
[data-brand="dt"] .card-body-flush > video {
display: block; display: block;
clip-path: polygon( clip-path: polygon(
0% 0%, 0% 0%,
@@ -272,6 +293,37 @@
color: var(--color-bg); color: var(--color-bg);
} }
/* Mode-variant badges — bridge .mode-* vars to badge color vars */
[data-brand="dt"] .badge.mode-normal {
--badge-border-color: var(--mode-normal);
--badge-fill: var(--mode-normal);
color: var(--color-bg);
}
[data-brand="dt"] .badge.mode-emphasis {
--badge-border-color: var(--mode-emphasis);
--badge-fill: var(--mode-emphasis);
color: var(--color-bg);
}
[data-brand="dt"] .badge.mode-warning {
--badge-border-color: var(--mode-warning);
--badge-fill: var(--mode-warning);
color: var(--color-bg);
}
[data-brand="dt"] .badge.mode-success {
--badge-border-color: var(--mode-success);
--badge-fill: var(--mode-success);
color: var(--color-bg);
}
[data-brand="dt"] .badge.mode-other {
--badge-border-color: var(--mode-other);
--badge-fill: var(--mode-other);
color: var(--color-bg);
}
/* ============================================================================ /* ============================================================================
Opt-in Bevel Utility Classes Opt-in Bevel Utility Classes
Use these for custom elements that need bevel shapes Use these for custom elements that need bevel shapes
@@ -352,7 +404,6 @@
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.1em; letter-spacing: 0.1em;
text-transform: uppercase;
pointer-events: none; pointer-events: none;
} }
@@ -369,7 +420,6 @@
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.1em; letter-spacing: 0.1em;
text-transform: uppercase;
background: var(--color-surface); background: var(--color-surface);
} }
@@ -508,7 +558,6 @@
padding: 2px calc(8px + 0.2rem) 2px 8px; padding: 2px calc(8px + 0.2rem) 2px 8px;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 700; font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em; letter-spacing: 0.1em;
background-color: var(--dt-card-color, var(--color-primary)); background-color: var(--dt-card-color, var(--color-primary));
color: var(--color-bg); color: var(--color-bg);
@@ -542,7 +591,6 @@
font-weight: 600; font-weight: 600;
font-size: 0.875rem; font-size: 0.875rem;
letter-spacing: 0.05em; letter-spacing: 0.05em;
text-transform: uppercase;
cursor: pointer; cursor: pointer;
transition: background-color var(--transition-fast), transition: background-color var(--transition-fast),
color var(--transition-fast), color var(--transition-fast),