Skip to content

Mastering Astryx / lesson 9 of 13

Column Shells with StatusDot, Tooltip & EmptyState

Extract the board column into BoardColumn with status dots, tooltips, tabular task counters, and compact empty state fallbacks.

The columns are inline in KanbanBoard right now. In this lesson we extract them into BoardColumn — the container component that gives each column its status dot, tooltip, live counter, and empty-state fallback.

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

Astryx this lesson: Card (muted variant), nested Layout/LayoutHeader/LayoutContent, StatusDot, Heading, Tooltip, Icon, Text, EmptyState. Concepts: status dots, tooltip overlays, tabular counters (hasTabularNumbers), and compact empty states.

Component Design

  1. Header:
    • StatusDot: Visual variant indicator (neutral, accent, warning, success) — fed straight from ColumnMeta.variant.
    • Heading level={4}: Column title.
    • Tooltip & Icon: Informational tooltip describing column purpose (meta.tooltip).
    • Text hasTabularNumbers: Live item counter.
  2. Content Area:
    • A padded container hosting task cards — or a compact EmptyState fallback when no items are present (using the column’s meta.emptyIcon).

Implementation: BoardColumn.tsx

Create src/app/components/BoardColumn.tsx:

src/app/components/BoardColumn.tsx
import type { ReactNode } from 'react';
import { Card } from '@astryxdesign/core/Card';
import {
Layout,
LayoutHeader,
LayoutContent,
HStack,
} from '@astryxdesign/core/Layout';
import { StatusDot } from '@astryxdesign/core/StatusDot';
import { Heading, Text } from '@astryxdesign/core/Text';
import { Tooltip } from '@astryxdesign/core/Tooltip';
import { Icon } from '@astryxdesign/core/Icon';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { InformationCircleIcon } from '@heroicons/react/24/outline';
import { styles } from '../styles';
import type { ColumnMeta } from '../types';
interface BoardColumnProps {
meta: ColumnMeta;
count: number;
contentRef: (el: HTMLDivElement | null) => void;
children: ReactNode;
}
export function BoardColumn({
meta,
count,
contentRef,
children,
}: BoardColumnProps) {
return (
<Card variant="muted" padding={0} xstyle={styles.columnShell}>
<Layout
height="fill"
header={
<LayoutHeader hasDivider padding={3}>
<HStack hAlign="between" vAlign="center">
<HStack gap={2} vAlign="center">
<StatusDot variant={meta.variant} label={`${meta.title} status`} />
<Heading level={4}>{meta.title}</Heading>
<Tooltip content={meta.tooltip}>
<Icon
icon={InformationCircleIcon}
size="sm"
color="secondary"
/>
</Tooltip>
</HStack>
<Text type="supporting" color="secondary" hasTabularNumbers>
{count}
</Text>
</HStack>
</LayoutHeader>
}
content={
<LayoutContent ref={contentRef} padding={2}>
{children ?? (
<EmptyState
isCompact
xstyle={styles.columnEmptyState}
icon={
<Icon icon={meta.emptyIcon} size="lg" color="secondary" />
}
title={meta.emptyTitle}
description={meta.emptyDescription}
/>
)}
</LayoutContent>
}
/>
</Card>
);
}

Notes:

Using It: KanbanBoard.tsx

Rewrite src/app/components/KanbanBoard.tsx to render BoardColumn per COLUMNS entry, passing the column’s BoardCard list as children:

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 { BoardCard } from './BoardCard';
import { BoardColumn } from './BoardColumn';
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 => (
<BoardCard
key={item.id}
item={item}
cardRef={() => {}}
onPointerDown={() => {}}
onMove={moveItem}
/>
))}
</VStack>
</VStack>
</Card>
<BoardColumn
key={col.id}
meta={col}
count={items.length}
contentRef={() => {}}
>
{/* null children trigger BoardColumn's EmptyState fallback */}
{items.length > 0 ? (
<VStack gap={2}>
{items.map(item => (
<BoardCard
key={item.id}
item={item}
cardRef={() => {}}
onPointerDown={() => {}}
onMove={moveItem}
/>
))}
</VStack>
) : null}
</BoardColumn>
);
})}
</HStack>
</LayoutContent>
}
/>
</Section>
);
}

The column header, counter, tooltip, and empty-state logic now all live in BoardColumn. The “In review” column — which has no tasks — renders its compact EmptyState automatically.

See It in Action

Run npm run dev. Each column now has a colored status dot, a hoverable info tooltip, and an aligned counter. The empty In review column shows its compact empty state with the clipboard icon. Hover a column title’s info icon to see the tooltip; the counters use tabular numbers so they stay aligned.

What You Built

In the next lesson, we will build the top application action toolbar — and move the theme and language toggles into it.


Share this post on:

Previous
Interactive Cards & the xstyle Prop
Next
Application Toolbars with Selector & IconButton