open-design/apps/web/src/components/DesignSystemsTab.tsx
2026-05-31 13:59:18 +08:00

916 lines
33 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import { useAnalytics } from '../analytics/provider';
import {
trackDesignSystemsTemplateCardClick,
trackDesignSystemsTopClick,
trackDesignSystemStatusResult,
trackPageView,
} from '../analytics/events';
import type {
TrackingDesignSystemStatusAction,
TrackingDesignSystemStatusValue,
} from '@open-design/contracts/analytics';
import { useI18n } from '../i18n';
import {
localizeDesignSystemCategory,
localizeDesignSystemSummary,
} from '../i18n/content';
import {
deleteDesignSystemDraft,
fetchDesignSystemShowcase,
updateDesignSystemDraft,
} from '../providers/registry';
import { buildSrcdoc } from '../runtime/srcdoc';
import { Icon } from './Icon';
import type { DesignSystemSummary, ProjectTemplate, Surface } from '../types';
interface Props {
systems: DesignSystemSummary[];
selectedId: string | null;
onSelect: (id: string) => void;
onPreview: (id: string) => void;
onCreate?: () => void;
onOpenSystem?: (id: string) => void;
onSystemsRefresh?: () => Promise<void> | void;
templates?: ProjectTemplate[];
}
const CATEGORY_ORDER = [
'Starter',
'AI & LLM',
'Developer Tools',
'Productivity & SaaS',
'Backend & Data',
'Design & Creative',
'Fintech & Crypto',
'E-Commerce & Retail',
'Media & Consumer',
'Automotive',
];
type SurfaceFilter = 'all' | Surface;
type UserListFilter = 'all' | 'published' | 'draft';
type PrimaryCollection = 'design-system' | 'template';
type DesignSystemCollection = 'mine' | 'official' | 'enterprise';
type TemplateCollection = 'mine' | 'enterprise';
const SURFACE_PILLS: { value: SurfaceFilter; labelKey: 'examples.modeAll' | 'ds.surfaceWeb' | 'ds.surfaceImage' | 'ds.surfaceVideo' | 'ds.surfaceAudio' }[] = [
{ value: 'all', labelKey: 'examples.modeAll' },
{ value: 'web', labelKey: 'ds.surfaceWeb' },
{ value: 'image', labelKey: 'ds.surfaceImage' },
{ value: 'video', labelKey: 'ds.surfaceVideo' },
{ value: 'audio', labelKey: 'ds.surfaceAudio' },
];
function surfaceOf(system: DesignSystemSummary): Surface {
return system.surface ?? 'web';
}
function isUserSystem(system: DesignSystemSummary): boolean {
return system.source === 'user' || system.isEditable === true;
}
// `system.status` is the DesignSystemSummary status string from the
// daemon; map it onto the tracking enum used by
// `design_system_status_result.status_before|status_after`. The
// summary type today only carries `'draft' | 'published'`; the wider
// tracking enum keeps room for `ready`/`failed`/`archived` once those
// land server-side. Unknown values collapse to `'unknown'`.
function mapStatusToTracking(
status: string | null | undefined,
): TrackingDesignSystemStatusValue {
switch (status) {
case 'draft':
case 'published':
return status;
default:
return 'unknown';
}
}
function formatShortDate(
value: number | string | undefined,
locale?: string,
emptyLabel = 'just now',
): string {
if (!value) return emptyLabel;
const time = typeof value === 'number' ? value : Date.parse(value);
if (!Number.isFinite(time)) return String(value);
return new Intl.DateTimeFormat(locale, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(time));
}
export function DesignSystemsTab({
systems,
selectedId,
onSelect,
onPreview,
onCreate,
onOpenSystem,
onSystemsRefresh,
templates = [],
}: Props) {
const { locale, t } = useI18n();
const isZhCN = locale === 'zh-CN';
const primaryDesignSystemLabel = isZhCN ? t('ds.userSystemsEyebrow') : 'Design system';
const primaryTemplateLabel = isZhCN ? t('ds.templatesTitle') : 'Template';
const userSystemsLabel = isZhCN ? t('ds.userSystemsTitle') : 'Your systems';
const officialPresetsLabel = isZhCN ? t('ds.libraryTitle') : 'Official presets';
const enterpriseLabel = isZhCN ? '企业' : 'Enterprise';
const templateMineLabel = isZhCN ? t('ds.templatesTitle') : 'Your templates';
const analytics = useAnalytics();
const designSystemsPageViewFiredRef = useRef(false);
useEffect(() => {
if (designSystemsPageViewFiredRef.current) return;
designSystemsPageViewFiredRef.current = true;
// v2 doc: the DS list page also carries `area` / `view_type` /
// `entry_from` so it can stitch the cross-surface DS funnel.
// `entry_from` is `unknown` here because the tab is reached
// through the home nav rail; a router-aware entry mapper can
// refine this later.
trackPageView(analytics.track, {
page_name: 'design_systems',
area: 'design_system_list',
view_type: 'page',
entry_from: 'unknown',
available_design_system_count: systems.length,
});
}, [analytics.track, systems.length]);
const searchTrackedRef = useRef(false);
const categoryTrackedRef = useRef(false);
const [filter, setFilter] = useState('');
const [userFilter, setUserFilter] = useState<UserListFilter>('all');
const [busyId, setBusyId] = useState<string | null>(null);
const [primaryCollection, setPrimaryCollection] = useState<PrimaryCollection>('design-system');
const [designSystemCollection, setDesignSystemCollection] = useState<DesignSystemCollection>('mine');
const [templateCollection, setTemplateCollection] = useState<TemplateCollection>('mine');
const [surfaceFilter, setSurfaceFilter] = useState<SurfaceFilter>('all');
const [category, setCategory] = useState<string>('All');
// Cache fetched showcase HTML across re-renders so cards never re-flicker
// when the user filters / scrolls back. null = "in flight"; undefined =
// "not yet requested". Mirrors the pattern used by ExamplesTab.
const [thumbs, setThumbs] = useState<Record<string, string | null>>({});
const librarySystems = useMemo(
() => systems.filter((system) => !isUserSystem(system)),
[systems],
);
const surfaceScoped = useMemo(
() => surfaceFilter === 'all'
? librarySystems
: librarySystems.filter((s) => surfaceOf(s) === surfaceFilter),
[librarySystems, surfaceFilter],
);
const userSystems = useMemo(() => {
const editable = systems.filter(isUserSystem);
if (userFilter === 'all') return editable;
return editable.filter((system) => (system.status ?? 'draft') === userFilter);
}, [systems, userFilter]);
const showOfficialLibrary =
primaryCollection === 'design-system' &&
(designSystemCollection === 'official' ||
(designSystemCollection === 'mine' && userSystems.length === 0 && librarySystems.length > 0));
// Total systems per surface, ignoring every active filter. Drives the
// "this surface is now empty" fallback below — that guard must react to
// the catalog itself, not to a transient style/search filter.
const surfaceTotals = useMemo(() => {
const counts: Record<SurfaceFilter, number> = { all: librarySystems.length, web: 0, image: 0, video: 0, audio: 0 };
for (const s of librarySystems) counts[surfaceOf(s)]++;
return counts;
}, [librarySystems]);
const categories = useMemo(() => {
const cats = new Set<string>();
for (const s of surfaceScoped) cats.add(s.category || 'Uncategorized');
const ordered: string[] = [];
for (const c of CATEGORY_ORDER) if (cats.has(c)) ordered.push(c);
for (const c of [...cats].sort()) if (!ordered.includes(c)) ordered.push(c);
return ['All', ...ordered];
}, [surfaceScoped]);
// Keep surfaceFilter and category in sync when systems changes dynamically.
// If the currently selected surface has zero items, fall back to 'all'.
// If the current category is no longer present in the filtered list, fall back to 'All'.
useEffect(() => {
if (surfaceFilter !== 'all' && surfaceTotals[surfaceFilter] === 0) {
setSurfaceFilter('all');
setCategory('All');
} else if (category !== 'All' && !categories.includes(category)) {
setCategory('All');
}
}, [systems, surfaceFilter, surfaceTotals, category, categories]);
// Systems matching the active style category and search text, before the
// surface filter is applied. Both the surface pill counts and the visible
// grid derive from this so a surface chip always reports its own result
// set rather than the unfiltered catalog total.
const queryScoped = useMemo(() => {
const q = filter.trim().toLowerCase();
return librarySystems.filter((s) => {
if (category !== 'All' && (s.category || 'Uncategorized') !== category) return false;
if (!q) return true;
const summary = localizeDesignSystemSummary(locale, s).toLowerCase();
const categoryLabel = localizeDesignSystemCategory(
locale,
s.category || 'Uncategorized',
).toLowerCase();
return (
s.title.toLowerCase().includes(q) ||
s.summary.toLowerCase().includes(q) ||
summary.includes(q) ||
categoryLabel.includes(q)
);
});
}, [librarySystems, filter, category, locale]);
const surfaceCounts = useMemo(() => {
const counts: Record<SurfaceFilter, number> = {
all: queryScoped.length, web: 0, image: 0, video: 0, audio: 0,
};
for (const s of queryScoped) counts[surfaceOf(s)]++;
return counts;
}, [queryScoped]);
const filtered = useMemo(
() => surfaceFilter === 'all'
? queryScoped
: queryScoped.filter((s) => surfaceOf(s) === surfaceFilter),
[queryScoped, surfaceFilter],
);
// Category metadata is authored in English; keep raw values in state for
// filtering while localizing the visible labels for the current UI locale.
const renderCategory = (c: string) => {
if (c === 'All') return t('ds.categoryAll');
if (c === 'Uncategorized') return t('ds.categoryUncategorized');
return localizeDesignSystemCategory(locale, c);
};
function loadThumb(id: string) {
setThumbs((prev) => {
if (prev[id] !== undefined) return prev;
void fetchDesignSystemShowcase(id).then((html) => {
setThumbs((p) => ({ ...p, [id]: html }));
});
return { ...prev, [id]: null };
});
}
async function refreshSystems() {
await onSystemsRefresh?.();
}
async function togglePublished(system: DesignSystemSummary) {
setBusyId(system.id);
const startedAt = performance.now();
const willPublish = system.status !== 'published';
const action: TrackingDesignSystemStatusAction = willPublish
? 'publish'
: 'unpublish';
const statusBefore = mapStatusToTracking(system.status);
const isDefaultBefore = system.id === selectedId;
let succeeded = false;
let errorCode: string | undefined;
try {
const updated = await updateDesignSystemDraft(system.id, {
status: willPublish ? 'published' : 'draft',
});
succeeded = Boolean(updated);
if (!succeeded) errorCode = 'DS_STATUS_UPDATE_RETURNED_NULL';
await refreshSystems();
} catch (err) {
errorCode = err instanceof Error
? `DS_STATUS_UPDATE_THREW:${err.message.slice(0, 80)}`
: 'DS_STATUS_UPDATE_THREW';
throw err;
} finally {
setBusyId(null);
trackDesignSystemStatusResult(analytics.track, {
page_name: 'design_systems',
area: 'design_system_status',
action,
result: succeeded ? 'success' : 'failed',
design_system_id: system.id,
status_before: statusBefore,
status_after: succeeded
? willPublish
? 'published'
: 'draft'
: statusBefore,
is_default_before: isDefaultBefore,
is_default_after: isDefaultBefore,
error_code: errorCode,
duration_ms: Math.round(performance.now() - startedAt),
});
}
}
async function deleteSystem(system: DesignSystemSummary) {
const ok = window.confirm(t('ds.deleteConfirm', { title: system.title }));
if (!ok) {
trackDesignSystemStatusResult(analytics.track, {
page_name: 'design_systems',
area: 'design_system_status',
action: 'delete',
result: 'cancelled',
design_system_id: system.id,
status_before: mapStatusToTracking(system.status),
status_after: mapStatusToTracking(system.status),
is_default_before: system.id === selectedId,
is_default_after: system.id === selectedId,
duration_ms: 0,
});
return;
}
setBusyId(system.id);
const startedAt = performance.now();
const statusBefore = mapStatusToTracking(system.status);
const wasDefault = system.id === selectedId;
let succeeded = false;
let errorCode: string | undefined;
try {
const deleted = await deleteDesignSystemDraft(system.id);
succeeded = Boolean(deleted);
if (!succeeded) errorCode = 'DS_DELETE_RETURNED_FALSE';
if (succeeded && selectedId === system.id) {
const fallback = systems.find((candidate) =>
candidate.id !== system.id && isUserSystem(candidate),
);
if (fallback) onSelect(fallback.id);
}
await refreshSystems();
} catch (err) {
errorCode = err instanceof Error
? `DS_DELETE_THREW:${err.message.slice(0, 80)}`
: 'DS_DELETE_THREW';
throw err;
} finally {
setBusyId(null);
trackDesignSystemStatusResult(analytics.track, {
page_name: 'design_systems',
area: 'design_system_status',
action: 'delete',
result: succeeded ? 'success' : 'failed',
design_system_id: system.id,
status_before: statusBefore,
status_after: succeeded ? 'deleted' : statusBefore,
is_default_before: wasDefault,
// After a successful delete the row is gone; if it was the
// default the consumer remapped to a fallback above, so this
// DS is no longer the default either way.
is_default_after: false,
error_code: errorCode,
duration_ms: Math.round(performance.now() - startedAt),
});
}
}
function handleMakeDefaultClick(system: DesignSystemSummary): void {
const wasDefault = system.id === selectedId;
const statusBefore = mapStatusToTracking(system.status);
onSelect(system.id);
trackDesignSystemStatusResult(analytics.track, {
page_name: 'design_systems',
area: 'design_system_status',
action: wasDefault ? 'unset_default' : 'set_default',
result: 'success',
design_system_id: system.id,
status_before: statusBefore,
status_after: statusBefore,
is_default_before: wasDefault,
is_default_after: !wasDefault,
duration_ms: 0,
});
}
return (
<div className="tab-panel design-systems-manager" data-testid="design-systems-tab">
<div className="ds-manager-tabs">
<div className="subtab-pill" role="tablist" aria-label="Design systems area">
<button
type="button"
role="tab"
aria-selected={primaryCollection === 'design-system'}
className={primaryCollection === 'design-system' ? 'active' : ''}
onClick={() => setPrimaryCollection('design-system')}
>
{primaryDesignSystemLabel}
</button>
<button
type="button"
role="tab"
aria-selected={primaryCollection === 'template'}
className={primaryCollection === 'template' ? 'active' : ''}
onClick={() => setPrimaryCollection('template')}
>
{primaryTemplateLabel}
</button>
</div>
</div>
{primaryCollection === 'design-system' ? (
<div className="ds-manager-subtabs">
<div className="ds-tag-tabs" role="tablist" aria-label="Design system source">
<button
type="button"
role="tab"
aria-selected={designSystemCollection === 'mine'}
className={designSystemCollection === 'mine' ? 'active' : ''}
onClick={() => setDesignSystemCollection('mine')}
>
{userSystemsLabel}
</button>
<button
type="button"
role="tab"
aria-selected={designSystemCollection === 'official'}
className={designSystemCollection === 'official' ? 'active' : ''}
onClick={() => setDesignSystemCollection('official')}
>
{officialPresetsLabel}
</button>
<button
type="button"
role="tab"
aria-selected={designSystemCollection === 'enterprise'}
className={designSystemCollection === 'enterprise' ? 'active' : ''}
onClick={() => setDesignSystemCollection('enterprise')}
>
{enterpriseLabel}
</button>
</div>
</div>
) : (
<div className="ds-manager-subtabs">
<div className="ds-tag-tabs" role="tablist" aria-label="Template source">
<button
type="button"
role="tab"
aria-selected={templateCollection === 'mine'}
className={templateCollection === 'mine' ? 'active' : ''}
onClick={() => setTemplateCollection('mine')}
>
{templateMineLabel}
</button>
<button
type="button"
role="tab"
aria-selected={templateCollection === 'enterprise'}
className={templateCollection === 'enterprise' ? 'active' : ''}
onClick={() => setTemplateCollection('enterprise')}
>
Enterprise
</button>
</div>
</div>
)}
{primaryCollection === 'design-system' ? (
<p className="ds-private-note">{t('ds.privateNote')}</p>
) : null}
{primaryCollection === 'design-system' && designSystemCollection === 'mine' ? (
<section className="ds-settings-card" aria-label={t('ds.userSystemsAria')}>
<div className="ds-settings-card__head">
<div>
<span className="ds-manager-eyebrow">{t('ds.userSystemsEyebrow')}</span>
<h2>{t('ds.userSystemsTitle')}</h2>
</div>
<select
aria-label={t('ds.userFilterAria')}
value={userFilter}
onChange={(event) => setUserFilter(event.target.value as UserListFilter)}
>
<option value="all">{t('ds.categoryAll')}</option>
<option value="published">{t('ds.statusPublished')}</option>
<option value="draft">{t('ds.statusDraft')}</option>
</select>
</div>
{onCreate ? (
<button type="button" className="ds-create-row" onClick={onCreate}>
<span>
<strong>{t('ds.createTitle')}</strong>
<small>{t('ds.createDescription')}</small>
</span>
<span className="ds-create-row__action">{t('ds.createAction')}</span>
</button>
) : null}
{userSystems.length === 0 ? (
<div className="ds-user-empty">
{t('ds.userEmpty')}
</div>
) : (
<div className="ds-user-list">
{userSystems.map((system) => {
const status = system.status ?? 'draft';
const canUseInProjects = status === 'published';
const selected = canUseInProjects && system.id === selectedId;
const busy = busyId === system.id;
return (
<div className="ds-user-row" key={system.id}>
<button
type="button"
className="ds-user-row__open"
onClick={() => onOpenSystem?.(system.id)}
>
<span className="ds-user-row__title">
<span>{system.title}</span>
{selected ? <span className="ds-card-badge">{t('ds.badgeDefaultInline')}</span> : null}
</span>
<span className="ds-user-row__meta">
{t('ds.userUpdatedMeta', {
date: formatShortDate(system.updatedAt, locale, t('ds.dateJustNow')),
})}
</span>
</button>
<div className="ds-user-row__actions">
{onOpenSystem ? (
<button
type="button"
className="ghost compact"
onClick={() => onOpenSystem(system.id)}
disabled={busy}
>
{t('ds.actionEdit')}
</button>
) : null}
{!selected && canUseInProjects ? (
<button
type="button"
className="ghost compact"
onClick={() => handleMakeDefaultClick(system)}
disabled={busy}
>
{t('ds.actionMakeDefault')}
</button>
) : null}
<button
type="button"
className={`ds-status-toggle ${status === 'published' ? 'is-on' : ''}`}
aria-pressed={status === 'published'}
onClick={() => void togglePublished(system)}
disabled={busy}
>
<span>{status === 'published' ? t('ds.statusPublished') : t('ds.statusDraft')}</span>
<i aria-hidden />
</button>
{onOpenSystem ? (
<button
type="button"
className="icon-btn"
aria-label={t('ds.actionOpenNamed', { title: system.title })}
onClick={() => onOpenSystem(system.id)}
>
<Icon name="external-link" />
</button>
) : null}
<button
type="button"
className="icon-btn danger"
aria-label={t('ds.actionDeleteNamed', { title: system.title })}
onClick={() => void deleteSystem(system)}
disabled={busy}
>
<Icon name="close" />
</button>
</div>
</div>
);
})}
</div>
)}
</section>
) : null}
{showOfficialLibrary ? (
<section className="ds-settings-card" aria-label={t('ds.libraryAria')}>
<div className="ds-settings-card__head">
<div>
<span className="ds-manager-eyebrow">{t('ds.libraryEyebrow')}</span>
<h2>{t('ds.libraryTitle')}</h2>
</div>
</div>
<div className="tab-panel-toolbar ds-manager-toolbar">
<input
data-testid="design-systems-search"
placeholder={t('ds.searchPlaceholder')}
value={filter}
onFocus={() => {
if (searchTrackedRef.current) return;
searchTrackedRef.current = true;
trackDesignSystemsTopClick(analytics.track, {
page_name: 'design_systems',
area: 'design_systems',
element: 'search_input',
});
}}
onChange={(e) => setFilter(e.target.value)}
/>
<select
data-testid="design-systems-category-select"
value={category}
onFocus={() => {
if (categoryTrackedRef.current) return;
categoryTrackedRef.current = true;
trackDesignSystemsTopClick(analytics.track, {
page_name: 'design_systems',
area: 'design_systems',
element: 'search_dropdown',
});
}}
onChange={(e) => setCategory(e.target.value)}
>
{categories.map((c) => (
<option key={c} value={c}>
{renderCategory(c)}
</option>
))}
</select>
</div>
<div
className="examples-filter-row"
role="tablist"
aria-label={t('ds.surfaceLabel')}
>
<span className="examples-filter-label">{t('ds.surfaceLabel')}</span>
{/* Hide chips with no items in the active style/search filter, but
always keep "all" and the currently selected surface — otherwise a
transient search could remove the active chip and leave the grid
filtered with no chip showing aria-selected. */}
{SURFACE_PILLS.filter(
(p) => p.value === surfaceFilter || p.value === 'all' || surfaceCounts[p.value] > 0,
).map((p) => (
<button
key={p.value}
type="button"
role="tab"
aria-selected={surfaceFilter === p.value}
data-testid={`design-systems-surface-${p.value}`}
className={`filter-pill ${surfaceFilter === p.value ? 'active' : ''}`}
onClick={() => {
trackDesignSystemsTopClick(analytics.track, {
page_name: 'design_systems',
area: 'design_systems',
element: 'filter_chip',
filter_name: p.value,
});
setSurfaceFilter(p.value);
}}
>
{t(p.labelKey)}
<span className="filter-pill-count">{surfaceCounts[p.value]}</span>
</button>
))}
</div>
{filtered.length === 0 ? (
<div className="tab-empty" data-testid="design-systems-empty">{t('ds.emptyNoMatch')}</div>
) : (
<div className="ds-grid" data-testid="design-systems-grid">
{filtered.map((s) => (
<DesignSystemCard
key={s.id}
system={s}
active={s.id === selectedId}
thumbHtml={thumbs[s.id]}
onIntersect={() => loadThumb(s.id)}
onSelect={() => {
trackDesignSystemsTemplateCardClick(analytics.track, {
page_name: 'design_systems',
area: 'templates_card',
element: 'templates_card',
templates_id: s.id,
templates_type: s.source ?? 'library',
});
onSelect(s.id);
}}
onPreview={() => {
trackDesignSystemsTemplateCardClick(analytics.track, {
page_name: 'design_systems',
area: 'templates_card',
element: 'templates_card',
templates_id: s.id,
templates_type: s.source ?? 'library',
});
onPreview(s.id);
}}
/>
))}
</div>
)}
</section>
) : null}
{primaryCollection === 'design-system' && designSystemCollection === 'enterprise' ? (
<ComingSoonPanel
eyebrow="Design Systems"
title="Enterprise design systems"
body="Shared team design systems and governed brand libraries are coming soon."
/>
) : null}
{primaryCollection === 'template' && templateCollection === 'mine' ? (
<section className="ds-settings-card ds-templates-card" aria-label="Your templates">
<div className="ds-settings-card__head">
<div>
<span className="ds-manager-eyebrow">Templates</span>
<h2>Your templates</h2>
</div>
</div>
{templates.length === 0 ? (
<div className="ds-user-empty">
No templates yet. Create one from any generated project via Share once template publishing is enabled.
</div>
) : (
<div className="ds-template-list">
{templates.map((template) => (
<div className="ds-template-row" key={template.id}>
<div>
<strong>{template.name}</strong>
<span>{template.description?.trim() || 'Created from a project'}</span>
</div>
<small>{formatShortDate(template.createdAt)}</small>
</div>
))}
</div>
)}
</section>
) : null}
{primaryCollection === 'template' && templateCollection === 'enterprise' ? (
<ComingSoonPanel
eyebrow="Templates"
title="Enterprise templates"
body="Team-approved templates and organization-wide publishing are coming soon."
/>
) : null}
</div>
);
}
function ComingSoonPanel({
eyebrow,
title,
body,
}: {
eyebrow: string;
title: string;
body: string;
}) {
return (
<section className="ds-settings-card ds-coming-soon-card" aria-label={title}>
<div className="ds-settings-card__head">
<div>
<span className="ds-manager-eyebrow">{eyebrow}</span>
<h2>{title}</h2>
</div>
<span className="ds-coming-soon-badge">Coming soon</span>
</div>
<div className="ds-user-empty">{body}</div>
</section>
);
}
interface CardProps {
system: DesignSystemSummary;
active: boolean;
thumbHtml: string | null | undefined;
onIntersect: () => void;
onSelect: () => void;
onPreview: () => void;
}
function DesignSystemCard({
system,
active,
thumbHtml,
onIntersect,
onSelect,
onPreview,
}: CardProps) {
const { locale, t } = useI18n();
const ref = useRef<HTMLDivElement | null>(null);
// Lazy-load the showcase iframe only when the card scrolls into the
// viewport. With ~120 design systems we can't afford to mount every
// iframe up front — even with `loading="lazy"`, srcDoc iframes ignore
// the native lazy hint, so we gate via IntersectionObserver.
useEffect(() => {
if (thumbHtml !== undefined) return;
const node = ref.current;
if (!node || typeof IntersectionObserver === 'undefined') {
onIntersect();
return;
}
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
onIntersect();
observer.disconnect();
break;
}
}
},
{ rootMargin: '200px' },
);
observer.observe(node);
return () => observer.disconnect();
}, [thumbHtml, onIntersect]);
const localizedSummary = localizeDesignSystemSummary(locale, system);
const categoryLabel = localizeDesignSystemCategory(
locale,
system.category || 'Uncategorized',
);
return (
<div
ref={ref}
className={`ds-card ${active ? 'active' : ''}`}
role="button"
tabIndex={0}
data-testid={`design-system-card-${system.id}`}
onClick={onSelect}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect();
}
}}
>
<div
className="ds-card-thumb"
data-testid={`design-system-preview-${system.id}`}
onClick={(e) => {
e.stopPropagation();
onPreview();
}}
title={t('ds.previewTitle')}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onPreview();
}
}}
>
{thumbHtml ? (
<iframe
title={t('ds.previewFrameTitle', { title: system.title })}
sandbox="allow-scripts"
srcDoc={buildSrcdoc(thumbHtml)}
tabIndex={-1}
aria-hidden
/>
) : (
<div className="ds-card-thumb-fallback" aria-hidden>
{system.swatches && system.swatches.length > 0 ? (
<div className="ds-card-thumb-swatches">
{system.swatches.map((c, i) => (
<span key={i} style={{ background: c }} />
))}
</div>
) : (
<span className="ds-card-thumb-placeholder">
{thumbHtml === null ? '' : ''}
</span>
)}
</div>
)}
<span className="ds-card-thumb-overlay" aria-hidden>
{t('ds.preview')}
</span>
</div>
<div className="ds-card-meta" data-testid={`design-system-select-${system.id}`}>
<div className="ds-card-title-row">
<span className="ds-card-title">{system.title}</span>
{active ? (
<span className="ds-card-badge">{t('ds.badgeDefault')}</span>
) : null}
</div>
<div className="ds-card-summary">{localizedSummary}</div>
<div className="ds-card-footer">
<span className="ds-card-category">{categoryLabel}</span>
{system.swatches && system.swatches.length > 0 ? (
<div className="ds-card-swatches" aria-hidden>
{system.swatches.map((c, i) => (
<span key={i} style={{ background: c }} title={c} />
))}
</div>
) : null}
</div>
</div>
</div>
);
}