Shared/ArticleListCard
As H3
Default
Guide Alignment
With Updated Date
Without Badge
article-list-card.tsx
import type { ComponentChildren } from 'preact';
import CategoryBadge from '@/src/components/shared/category-badge';
import type { CuratedTag } from '@/src/lib/home/curated-tags';
import type { Locale } from '@/lib/i18n/types';
type HeadingLevel = 'h2' | 'h3';
type Align = 'stretch' | 'start' | 'center';
type Size = 'default' | 'lead';
export interface ArticleListCardProps {
href: string;
ariaLabel?: string;
/** Pre-rendered square thumbnail. Caller-specific (ItemVisual / ResponsiveImage / <picture>). */
thumb: ComponentChildren;
/** Optional curated-tag → a colored CategoryBadge above the title. */
curatedTag?: CuratedTag;
locale?: Locale;
title: ComponentChildren;
/** Already-formatted created date (e.g. via `formatDate`); shown with a 作成:/Created: prefix. */
createdDate?: string;
/** Already-formatted updated date; appended as `| 更新:/Updated:` when present. */
updatedDate?: string;
/** Pre-rendered excerpt / description text. */
excerpt?: ComponentChildren;
/** Pre-rendered tag list. Caller-specific (TagList / ArrowTag list). */
tags?: ComponentChildren;
/** Heading level for the title. Defaults to `h2` (use `h3` under a higher heading, e.g. the home news lead). */
as?: HeadingLevel;
/** Cross-axis alignment of the thumb cell. Defaults to `start` (pinned to the top). */
thumbAlign?: Align;
/** Cross-axis alignment of the meta cell. Defaults to `center` (vertically centered against the thumb). */
contentAlign?: Align;
/**
* Visual scale. `default` = the compact `/notes/`-style row. `lead` = the home
* news magazine's featured item: a wider thumb track, larger title/excerpt, and
* a bigger category badge — but the SAME two-row structure (thumb+meta row, then
* a full-width excerpt row) so it reads cleanly at narrow widths instead of
* cramming the excerpt into a narrow column beside a tall thumb.
*/
size?: Size;
}
const ALIGN_CLASS: Record<Align, string> = {
stretch: '',
start: 'self-start',
center: 'self-center',
};
const SIZE_CLASS: Record<
Size,
{ row: string; pad: string; badge: 'sm' | 'lg'; date: string; title: string; excerpt: string }
> = {
default: {
row: 'grid-cols-[80px_1fr] md:grid-cols-[120px_1fr] gap-x-hgap-sm',
pad: 'pt-vgap-sm',
badge: 'sm',
date: 'font-futura font-thin text-xs lg:text-sm',
title: 'font-futura text-sm md:text-base underline decoration-2 line-clamp-2',
excerpt: 'font-thin text-xs md:text-sm xl:text-base line-clamp-2',
},
lead: {
row: 'grid-cols-[100px_1fr] sm:grid-cols-[140px_1fr] md:grid-cols-[200px_1fr] gap-x-hgap-sm md:gap-x-hgap-md',
pad: 'pt-vgap-md',
badge: 'lg',
date: 'font-futura font-thin text-sm md:text-base',
title: 'font-futura text-lg md:text-xl underline decoration-2 line-clamp-2',
excerpt: 'font-thin text-sm md:text-base line-clamp-3',
},
};
/**
* Shared article/news list card. One row = `[square thumb | badge · title · date]`,
* with the description + tags on a full-width row below.
*
* SSR-safe by construction: a bare `<a>` (no hook-based `Link`), CategoryBadge, and
* plain elements only. The thumbnail and tag list are passed in as slots so each
* surface renders its own — the home news magazine needs `ItemVisual`'s restock-gallery
* / news-no logic, while the `/notes/` and guide-series listings just need a single
* image. This is the single source of truth for the badge → title → 作成-date → excerpt
* → tags layout shared by all three.
*/
export function ArticleListCard({
href,
ariaLabel,
thumb,
curatedTag,
locale = 'ja',
title,
createdDate,
updatedDate,
excerpt,
tags,
as: Heading = 'h2',
thumbAlign = 'start',
contentAlign = 'center',
size = 'default',
}: ArticleListCardProps) {
const isEn = locale === 'en';
const sz = SIZE_CLASS[size];
return (
<a
href={href}
aria-label={ariaLabel}
class="group block no-underline zd-invert-color-link border-t border-zd-white"
>
{/* 1st row: square thumb + (badge / title / date) */}
<div class={`grid ${sz.row} ${sz.pad}`}>
<div class={ALIGN_CLASS[thumbAlign]}>{thumb}</div>
<div class={ALIGN_CLASS[contentAlign]}>
{/* First line: category badge with the date to its right (no explicit
color on the date → inherits currentColor so it flips on hover/focus). */}
{(curatedTag || createdDate) && (
<div class="flex flex-wrap items-center gap-hgap-xs pb-vgap-2xs">
{curatedTag && <CategoryBadge tag={curatedTag} locale={locale} size={sz.badge} />}
{createdDate && (
<span class={sz.date}>
{isEn ? `Created: ${createdDate}` : `作成: ${createdDate}`}
{updatedDate && (
<span class="text-zd-subtext">
{` | ${isEn ? `Updated: ${updatedDate}` : `更新: ${updatedDate}`}`}
</span>
)}
</span>
)}
</div>
)}
<Heading class={sz.title}>{title}</Heading>
</div>
</div>
{/* 2nd row: description + tags, full width */}
{(excerpt || tags) && (
<div class="pt-vgap-xs pb-vgap-xs">
{excerpt && <p class={sz.excerpt}>{excerpt}</p>}
{tags && <div class="pt-vgap-xs">{tags}</div>}
</div>
)}
</a>
);
}
export default ArticleListCard;
article-list-card.stories.tsx
import ArticleListCard from './article-list-card';
import type { CuratedTag } from '@/src/lib/home/curated-tags';
/**
* ArticleListCard
*
* The shared article/news list card: `[square thumb | badge · title · 作成-date]`
* on the first row, with the excerpt + tags on a full-width row below. SSR-safe
* (bare `<a>`); the thumbnail and tag list are passed in as slots. Used by the home
* news magazine ("rest" rows), the `/notes/` listing, and the guide-series listing.
*/
export const meta = {
title: 'Shared/ArticleListCard',
};
const sampleThumb = <div class="aspect-square overflow-hidden border border-zd-white bg-zd-gray" />;
const sampleTags = (
<ul class="flex flex-wrap gap-vgap-2xs text-xs">
{['#発表/告知', '#ADDAC System', '#Modular Synthesizer'].map((t) => (
<li key={t} class="inline-flex whitespace-nowrap">
{t}
</li>
))}
</ul>
);
const newsTag: CuratedTag = { key: 'news', label: 'お知らせ', labelEn: 'News' };
const guideTag: CuratedTag = { key: 'guide', label: 'ガイド', labelEn: 'Guide' };
/**
* Default — badge + title + 作成 date, excerpt + tags full width
*/
export const Default = () => (
<ArticleListCard
href="#"
thumb={sampleThumb}
curatedTag={newsTag}
title="Notes009: ADDAC System: ADDAC120s Four Strings シリーズ 発表!"
createdDate="2026/06/21"
excerpt="ADDAC Systemより発表された、フィジカルな弦をEurorackに持ち込むモジュール群「ADDAC120s Four Strings」シリーズの紹介です。"
tags={sampleTags}
/>
);
/**
* Without a curated-tag badge (title leads)
*/
export const WithoutBadge = () => (
<ArticleListCard
href="#"
thumb={sampleThumb}
title="Notes008: SHIK S32 発表!"
createdDate="2026/06/05"
excerpt="SHIKより発表されたスタンドアロンMIDIプロセッサー/マクロコントローラーS32の紹介テキストの日本語訳です。"
tags={sampleTags}
/>
);
/**
* With an updated date (appends `| 更新:`)
*/
export const WithUpdatedDate = () => (
<ArticleListCard
href="#"
thumb={sampleThumb}
curatedTag={guideTag}
title="MetaModuleガイド EP.1: Richard Devineが語るモジュラーとの歩みとMetaModule"
createdDate="2026/06/25"
updatedDate="2026/06/28"
excerpt="4ms公式ポッドキャスト第1回のゲストは、アトランタ拠点のエレクトロニックアーティスト、リチャード・ディヴァイン。"
tags={sampleTags}
/>
);
/**
* Guide-series alignment — thumb pinned top, meta block centered (thumbAlign/contentAlign)
*/
export const GuideAlignment = () => (
<ArticleListCard
href="#"
thumb={sampleThumb}
curatedTag={guideTag}
title="OXI ONE MKII 解説 EP.1: 全体像を把握する"
createdDate="2026/03/05"
excerpt="OXI ONE MKIIの解説シリーズです。EP.1では、OXI ONE MKIIの全体像と基本的な操作フローを解説します。"
tags={sampleTags}
thumbAlign="start"
contentAlign="center"
/>
);
/**
* As `h3` — used by the home news magazine (the lead item is the column's h2)
*/
export const AsH3 = () => (
<ArticleListCard
href="#"
thumb={sampleThumb}
curatedTag={newsTag}
title="Notes007: OAM Time Machine ファームウェアアップグレード手順"
createdDate="2026/05/10"
excerpt="OAM Time MachineのファームウェアをV1.1.1にアップデートする手順をまとめました。"
tags={sampleTags}
as="h3"
/>
);