Skip to content

Mastering Astryx / lesson 7 of 13

Presenting Work Items: Card, Badge & MoreMenu

Extract the work-item presentation into BoardCardBody using Card, Badge, Heading, Text, and MoreMenu.

The board renders tasks inline right now. In this lesson we extract the presentation into its own component — BoardCardBody — the reusable body shown both inside columns and, later, inside the floating drag clone.

Files this lesson: src/app/components/BoardCardBody.tsx (new), src/app/components/KanbanBoard.tsx (rewritten). page.tsx stays untouched.

Astryx this lesson: Card, Badge, Heading, Text, MoreMenu. Concepts: content hierarchy (heading levels, type="supporting"/color="secondary"), line clamping (maxLines), and the MoreMenu items contract (actions + dividers).

Component Features

  1. Header Row: Task reference badge (Badge variant="neutral"), priority badge (Badge variant={priority.variant}), and contextual actions dropdown (MoreMenu).
  2. Title & Description: Heading level={5} and clamped description Text maxLines={2}.
  3. Metadata Footer: Secondary text displaying last edited time and due date.
  4. Move Actions: Dynamically generates "Move to [Column]" menu items for non-current columns.
  5. MoreMenu items contract: each item is either an action ({ label, onClick }) or a separator ({ type: 'divider' }) — the same contract Astryx’s menu system uses across the design system.

Implementation: BoardCardBody.tsx

Create src/app/components/BoardCardBody.tsx:

src/app/components/BoardCardBody.tsx
import { HStack, VStack } from '@astryxdesign/core/Layout';
import { Heading, Text } from '@astryxdesign/core/Text';
import { Badge } from '@astryxdesign/core/Badge';
import { MoreMenu } from '@astryxdesign/core/MoreMenu';
import { COLUMNS, PRIORITY_META } from '../data';
import type { ColumnId, WorkItem } from '../types';
interface BoardCardBodyProps {
item: WorkItem;
onMove: (id: string, to: ColumnId) => void;
}
export function BoardCardBody({ item, onMove }: BoardCardBodyProps) {
const priority = PRIORITY_META[item.priority];
// Dynamic move options for the MoreMenu dropdown
const moveTargets = COLUMNS
.filter(c => c.id !== item.column)
.map(c => ({
label: `Move to ${c.title}`,
onClick: () => onMove(item.id, c.id),
}));
return (
<VStack gap={2}>
<HStack hAlign="between" vAlign="start">
<HStack gap={1} vAlign="center" wrap="wrap">
<Badge label={item.ref} variant="neutral" />
<Badge label={priority.label} variant={priority.variant} />
</HStack>
<MoreMenu
label="Work item actions"
size="sm"
items={[
{ label: 'Open', onClick: () => {} },
{ label: 'Assign to me', onClick: () => {} },
{ type: 'divider' },
...moveTargets,
]}
/>
</HStack>
<VStack gap={1}>
<Heading level={5}>{item.title}</Heading>
<Text type="supporting" color="secondary" maxLines={2}>
{item.description}
</Text>
</VStack>
<Text type="supporting" color="secondary">
Edited {item.lastEdited} · Due {item.dueDate}
</Text>
</VStack>
);
}

Read the pieces:

By decoupling the body from the draggable container, we can render the exact same UI inside column lists and inside the floating drag preview clone (lesson 12).

Using It: KanbanBoard.tsx

Rewrite src/app/components/KanbanBoard.tsx so the task card in each column renders a BoardCardBody (and a temporary alert stub for moves — real state-driven moves arrive with the drag hook in lesson 11):

src/app/components/KanbanBoard.tsx
'use client';
import { Section } from '@astryxdesign/core/Section';
import {
Layout,
LayoutHeader,
LayoutContent,
HStack,
VStack,
} from '@astryxdesign/core/Layout';
import { Heading, Text } from '@astryxdesign/core/Text';
import { Card } from '@astryxdesign/core/Card';
import { Button } from '@astryxdesign/core/Button';
import { useTranslator } from '@astryxdesign/core/i18n';
import { usePreferences } from '../providers';
import { styles } from '../styles';
import { BoardCardBody } from './BoardCardBody';
import { COLUMNS, INITIAL_ITEMS } from '../data';
import type { ColumnId } from '../types';
export default function KanbanBoard() {
const t = useTranslator();
const { mode, setMode, locale, setLocale } = usePreferences();
// Temporary — real moves arrive with the drag hook in lesson 11.
const moveItem = (id: string, to: ColumnId) => {
alert(`Move task ${id} to ${to}`);
};
return (
<Section height="100dvh">
<Layout
height="fill"
header={
<LayoutHeader hasDivider padding={4}>
<HStack hAlign="between" vAlign="center">
<Heading level={3}>{t('@app.board.title')}</Heading>
<HStack gap={2} vAlign="center">
<Text type="supporting" color="secondary" hasTabularNumbers>
{INITIAL_ITEMS.length} {t('@app.tasks')}
</Text>
<Button
label={t('@app.switchLanguage')}
variant="secondary"
onClick={() => setLocale(locale === 'en' ? 'fr' : 'en')}
/>
<Button
label={
mode === 'system'
? 'Light mode'
: mode === 'light'
? 'Dark mode'
: 'System mode'
}
variant="secondary"
onClick={() =>
setMode(
mode === 'system'
? 'light'
: mode === 'light'
? 'dark'
: 'system',
)
}
/>
</HStack>
</HStack>
</LayoutHeader>
}
content={
<LayoutContent padding={0}>
<HStack gap={4} xstyle={styles.boardColumns}>
{COLUMNS.map(col => {
const items = INITIAL_ITEMS.filter(it => it.column === col.id);
return (
<Card
key={col.id}
variant="muted"
padding={0}
xstyle={styles.columnShell}
>
<VStack gap={3} padding={3}>
<Heading level={4}>{col.title}</Heading>
<Text
type="supporting"
color="secondary"
hasTabularNumbers
>
{items.length} {t('@app.tasks')}
</Text>
<VStack gap={2}>
{items.map(item => (
<Card
key={item.id}
padding={3}
xstyle={styles.card}
>
<Heading level={5}>{item.title}</Heading>
<Text
type="supporting"
color="secondary"
maxLines={2}
>
{item.description}
</Text>
<BoardCardBody item={item} onMove={moveItem} />
</Card>
))}
</VStack>
</VStack>
</Card>
);
})}
</HStack>
</LayoutContent>
}
/>
</Section>
);
}

Each task card is now a Card shell (hoverable via styles.card) wrapping a BoardCardBody. The body carries the badges, title, description, metadata footer, and the actions menu.

See It in Action

Run npm run dev. Task cards now show the reference badge, the priority badge (colored per PRIORITY_META), a clamped description, the Edited/Due footer, and a menu. Open a card’s menu and pick Move to … — you get an alert stub for now. Flip the theme toggle: the priority badge colors adapt to the dark token set automatically.

What You Built

In the next lesson, we will wrap this body in BoardCard.tsx with pointer drag listeners.


Share this post on:

Previous
Data Modeling with Astryx Icons & Semantic Variants
Next
Interactive Cards & the xstyle Prop