The header is still inline in KanbanBoard — title, task counter, and the two toggle buttons. In this lesson we consolidate everything into BoardToolbar, the top application bar: board title, total badge, sprint selector, action buttons, and the theme + language toggles.
Files this lesson: src/app/components/BoardToolbar.tsx (new), src/app/messages.ts (extended), src/app/components/KanbanBoard.tsx (rewritten). page.tsx stays untouched.
Astryx this lesson: Toolbar, Heading, Badge, Selector, Divider (vertical), IconButton, Icon, Button. Concepts: application action bars, single-select dropdowns, divider separation, and primary action buttons.
Toolbar Components
Toolbar: Container providing flex layout slotting viastartContentandendContent.Selector: Single-select dropdown control for switching active sprints.Divider: Vertical divider (orientation="vertical") with custom height styling (styles.toolbarDivider).IconButton&Icon: Icon-only action triggers — now including the theme and language toggles.Button: Primary CTA button with an integrated leading icon.
Implementation: BoardToolbar.tsx
First extend the catalogs with the “Add task” string so it localizes:
export const en: Catalog = { '@app.board.title': { defaultMessage: 'Sprint Board' }, '@app.switchLanguage': { defaultMessage: 'En français' }, '@app.tasks': { defaultMessage: 'tasks' }, '@app.addTask': { defaultMessage: 'Add task' },};
export const fr: Catalog = { '@app.board.title': { defaultMessage: 'Tableau de sprint' }, '@app.switchLanguage': { defaultMessage: 'In English' }, '@app.tasks': { defaultMessage: 'tâches' }, '@app.addTask': { defaultMessage: 'Ajouter une tâche' },};Create src/app/components/BoardToolbar.tsx:
'use client';
import { Toolbar } from '@astryxdesign/core/Toolbar';import { Heading } from '@astryxdesign/core/Text';import { Badge } from '@astryxdesign/core/Badge';import { Selector } from '@astryxdesign/core/Selector';import { Divider } from '@astryxdesign/core/Divider';import { IconButton } from '@astryxdesign/core/IconButton';import { Button } from '@astryxdesign/core/Button';import { Icon } from '@astryxdesign/core/Icon';import { HStack } from '@astryxdesign/core/Layout';import { useTranslator } from '@astryxdesign/core/i18n';import { ArrowsUpDownIcon, FunnelIcon, MagnifyingGlassIcon, MoonIcon, SunIcon, LanguageIcon, PlusIcon,} from '@heroicons/react/24/outline';import { styles } from '../styles';import { usePreferences } from '../providers';
interface BoardToolbarProps { totalTasks: number; sprint: string; onSprintChange: (sprint: string) => void;}
export function BoardToolbar({ totalTasks, sprint, onSprintChange,}: BoardToolbarProps) { const t = useTranslator(); const { mode, setMode, locale, setLocale } = usePreferences();
return ( <Toolbar label="Board actions" gap={2} startContent={ <> <Heading level={3}>{t('@app.board.title')}</Heading> <Badge label={totalTasks} variant="neutral" /> </> } endContent={ <HStack gap={2}> <Selector label="Sprint" width={200} isLabelHidden value={sprint} onChange={onSprintChange} options={[ { value: '003', label: 'Sprint 003' }, { value: '002', label: 'Sprint 002' }, { value: '001', label: 'Sprint 001' }, ]} /> <Divider variant="strong" orientation="vertical" xstyle={styles.toolbarDivider} /> <HStack gap={1} vAlign="center"> <IconButton icon={<Icon icon={ArrowsUpDownIcon} size="sm" />} label="Sort" /> <IconButton icon={<Icon icon={FunnelIcon} size="sm" />} label="Filter" /> <IconButton icon={<Icon icon={MagnifyingGlassIcon} size="sm" />} label="Search" /> </HStack> <Divider variant="strong" orientation="vertical" xstyle={styles.toolbarDivider} /> {/* Dark / light toggle — cycles system → light → dark → system */} <IconButton icon={<Icon icon={mode === 'dark' ? SunIcon : MoonIcon} size="sm" />} label={mode === 'dark' ? 'Light mode' : 'Dark mode'} onClick={() => setMode( mode === 'system' ? 'light' : mode === 'light' ? 'dark' : 'system', ) } /> {/* Language toggle */} <IconButton icon={<Icon icon={LanguageIcon} size="sm" />} label={t('@app.switchLanguage')} onClick={() => setLocale(locale === 'en' ? 'fr' : 'en')} /> <Button label={t('@app.addTask')} variant="primary" icon={<Icon icon={PlusIcon} size="sm" />} /> </HStack> } /> );}The two toggles you built in lessons 3 and 5 now live in the toolbar as icon buttons, reading the same usePreferences() context — the app-level controls are all in one place. The moon/sun icon reflects the current mode, and the language button keeps its localized label.
Using It: KanbanBoard.tsx
Rewrite src/app/components/KanbanBoard.tsx to lift sprint into state and hand the whole header to BoardToolbar:
'use client';
import { useState } from 'react';import { Section } from '@astryxdesign/core/Section';import { Layout, LayoutHeader, LayoutContent, HStack, VStack,} from '@astryxdesign/core/Layout';import { Heading, Text } from '@astryxdesign/core/Text';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 { BoardToolbar } from './BoardToolbar';import { COLUMNS, INITIAL_ITEMS } from '../data';import type { ColumnId } from '../types';
export default function KanbanBoard() { const t = useTranslator(); const { mode, setMode, locale, setLocale } = usePreferences(); const [sprint, setSprint] = useState('003');
// 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> <BoardToolbar totalTasks={INITIAL_ITEMS.length} sprint={sprint} onSprintChange={setSprint} /> </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 ( <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> );}KanbanBoard now owns the sprint state and passes it down; the header, title, counters, and toggles all moved into BoardToolbar. Note the header no longer needs Heading/Text/Button imports — the toolbar owns that rendering now.
See It in Action
Run npm run dev. The header is now a single Toolbar: title + total badge on the left; sprint selector, action icons, theme toggle, language toggle, and the Add task primary button on the right. Change the sprint — the selector reflects it. Flip the theme (moon/sun icon swaps) and the language — both still work, now as compact icon buttons in the toolbar.
What You Built
BoardToolbar.tsx— the consolidated top bar with sprint selection, action buttons, and the app-level theme + language toggles.- A board whose header is fully delegated to a dedicated component.
In the next lesson, we will extract the drag-and-drop interaction engine into a custom hook.