codex/broker-dashboard-redesign #49

Merged
ksv741 merged 19 commits from codex/broker-dashboard-redesign into main 2026-06-27 16:55:04 +03:00
12 changed files with 291 additions and 142 deletions
Showing only changes of commit 8df94a51dd - Show all commits

View File

@ -2,11 +2,15 @@ import { useQuery } from '@tanstack/react-query'
import type { BrokerEventsData } from '@/shared/api'
import { type BrokerEventsQuery, getBrokerEvents } from '../api/brokerEventApi'
export function useBrokerEvents(accountId: string | undefined, query: BrokerEventsQuery) {
export function useBrokerEvents(
accountId: string | undefined,
query: BrokerEventsQuery,
options: { enabled?: boolean } = {},
) {
const { from, to, types } = query
return useQuery<BrokerEventsData>({
queryKey: ['broker', 'events', accountId, from, to, types],
enabled: Boolean(accountId),
enabled: Boolean(accountId) && (options.enabled ?? true),
queryFn: async () => (await getBrokerEvents(accountId!, { from, to, types })).data,
staleTime: 300_000,
retry: 2,

View File

@ -5,10 +5,11 @@ import { type BrokerOperationQuery, getBrokerOperations } from '../api/brokerOpe
export function useBrokerOperations(
accountId: string | undefined,
query: BrokerOperationQuery = {},
options: { enabled?: boolean } = {},
) {
return useQuery<BrokerOperationsPage>({
queryKey: ['broker', 'operations', accountId, query],
enabled: Boolean(accountId),
enabled: Boolean(accountId) && (options.enabled ?? true),
queryFn: async () => (await getBrokerOperations(accountId!, query)).data,
staleTime: 300_000,
retry: 2,

View File

@ -47,51 +47,39 @@ export function BrokerAccountLayout({ children }: { children: ReactNode }) {
</Box>
<Box
component="nav"
aria-label="Разделы брокерского счёта"
sx={{
display: 'grid',
gridTemplateColumns: '1fr',
gap: 2,
'@media (min-width: 720px)': {
gridTemplateColumns: 'minmax(150px, 190px) minmax(0, 1fr)',
gap: 3,
},
display: 'flex',
gap: 0.5,
overflowX: 'auto',
scrollbarWidth: 'thin',
borderBottom: '1px solid',
borderColor: 'divider',
pb: 0.5,
}}
>
<Box
component="nav"
aria-label="Разделы брокерского счёта"
sx={{
display: 'flex',
flexDirection: 'column',
gap: 0.5,
'@media (max-width: 719px)': {
flexDirection: 'row',
overflowX: 'auto',
scrollbarWidth: 'thin',
},
}}
>
{links.map((link) => (
<Link
key={link.to}
to={`${basePath}${link.to}`}
style={baseLinkStyle}
activeOptions={{ exact: link.to === '' }}
activeProps={{
style: {
...baseLinkStyle,
color: 'var(--color-primary)',
fontWeight: 700,
},
}}
>
{link.label}
</Link>
))}
</Box>
<Box sx={{ minWidth: 0 }}>{children}</Box>
{links.map((link) => (
<Link
key={link.to}
to={`${basePath}${link.to}`}
style={baseLinkStyle}
activeOptions={{ exact: link.to === '' }}
activeProps={{
style: {
...baseLinkStyle,
color: 'var(--color-primary)',
fontWeight: 700,
background: 'rgba(25, 118, 210, 0.08)',
},
}}
>
{link.label}
</Link>
))}
</Box>
<Box sx={{ minWidth: 0 }}>{children}</Box>
</Box>
</BrokerAccountContext.Provider>
)

View File

@ -1,8 +1,14 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BrokerPortfolio } from '@/shared/api'
import { BrokerDashboard } from './BrokerDashboard'
const hookMocks = vi.hoisted(() => ({
useBrokerEvents: vi.fn(),
useBrokerOperations: vi.fn(),
}))
vi.mock('@tanstack/react-router', () => ({
Link: ({ children, to }: { children: React.ReactNode; to: string }) => (
<a href={to}>{children}</a>
@ -27,25 +33,11 @@ vi.mock('@/entities/broker-analytics', () => ({
}))
vi.mock('@/entities/broker-event', () => ({
useBrokerEvents: () => ({
data: { items: [], summary: {}, asOf: '2026-06-26T00:00:00.000Z' },
isLoading: false,
isError: false,
}),
useBrokerEvents: hookMocks.useBrokerEvents,
}))
vi.mock('@/entities/broker-operation', () => ({
useBrokerOperations: () => ({
data: {
accountId: 'acc-1',
items: [],
nextCursor: null,
hasNext: false,
asOf: '2026-06-26T00:00:00.000Z',
},
isLoading: false,
isError: false,
}),
useBrokerOperations: hookMocks.useBrokerOperations,
}))
vi.mock('@/widgets/broker-allocation-chart', () => ({
@ -80,6 +72,25 @@ const portfolio: BrokerPortfolio = {
}
describe('BrokerDashboard', () => {
beforeEach(() => {
hookMocks.useBrokerEvents.mockReturnValue({
data: { items: [], summary: {}, asOf: '2026-06-26T00:00:00.000Z' },
isLoading: false,
isError: false,
})
hookMocks.useBrokerOperations.mockReturnValue({
data: {
accountId: 'acc-1',
items: [],
nextCursor: null,
hasNext: false,
asOf: '2026-06-26T00:00:00.000Z',
},
isLoading: false,
isError: false,
})
})
it('renders the dashboard sections', () => {
render(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
@ -90,4 +101,36 @@ describe('BrokerDashboard', () => {
expect(screen.getByText('Аллокация')).toBeInTheDocument()
expect(screen.getByText('allocation chart')).toBeInTheDocument()
})
it('uses event chips as request filters', async () => {
const user = userEvent.setup()
render(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
await user.click(screen.getAllByRole('button', { name: 'Купоны' })[0])
await waitFor(() => {
expect(hookMocks.useBrokerEvents).toHaveBeenLastCalledWith(
'acc-1',
expect.objectContaining({ types: 'dividend,maturity,offer' }),
{ enabled: true },
)
})
})
it('uses income chips as request filters', async () => {
const user = userEvent.setup()
render(<BrokerDashboard accountId="acc-1" portfolio={portfolio} />)
await user.click(screen.getAllByRole('button', { name: 'Купоны' })[1])
await waitFor(() => {
expect(hookMocks.useBrokerOperations).toHaveBeenLastCalledWith(
'acc-1',
expect.objectContaining({
operationTypes: 'OPERATION_TYPE_DIVIDEND,OPERATION_TYPE_DIV_EXT',
}),
{ enabled: true },
)
})
})
})

View File

@ -6,6 +6,8 @@ import { useBrokerOperations } from '@/entities/broker-operation'
import type { BrokerPortfolio } from '@/shared/api'
import { useCursorPagination } from '@/shared/lib/useCursorPagination'
import {
type DashboardEventType,
type DashboardIncomeType,
defaultEventsFilters,
defaultIncomeFilters,
incomeTypesToOperationTypes,
@ -23,69 +25,99 @@ export function BrokerDashboard({
accountId: string
portfolio: BrokerPortfolio
}) {
const [eventFilters, _setEventFilters] = useState(defaultEventsFilters)
const [eventFilters, setEventFilters] = useState(defaultEventsFilters)
const [eventPage, setEventPage] = useState(1)
const [incomeFilters, _setIncomeFilters] = useState(defaultIncomeFilters)
const [incomeFilters, setIncomeFilters] = useState(defaultIncomeFilters)
const incomePagination = useCursorPagination()
const analytics = useBrokerAnalytics(accountId)
const events = useBrokerEvents(accountId, {
from: eventFilters.from,
to: eventFilters.to,
types: eventFilters.types.join(','),
})
const hasEventTypes = eventFilters.types.length > 0
const hasIncomeTypes = incomeFilters.types.length > 0
const events = useBrokerEvents(
accountId,
{
from: eventFilters.from,
to: eventFilters.to,
types: eventFilters.types.join(','),
},
{ enabled: hasEventTypes },
)
const eventItems = events.data?.items ?? []
const eventPageSize = 10
const eventPageItems = eventItems.slice(
(eventPage - 1) * eventPageSize,
eventPage * eventPageSize,
)
const operations = useBrokerOperations(accountId, {
from: incomeFilters.from,
to: incomeFilters.to,
operationTypes: incomeTypesToOperationTypes(incomeFilters.types),
cursor: incomePagination.cursor,
limit: 10,
})
const operations = useBrokerOperations(
accountId,
{
from: incomeFilters.from,
to: incomeFilters.to,
operationTypes: incomeTypesToOperationTypes(incomeFilters.types),
cursor: incomePagination.cursor,
limit: 10,
},
{ enabled: hasIncomeTypes },
)
const nextCursor: string | undefined = operations.data?.nextCursor
? (operations.data.nextCursor as unknown as string)
: undefined
function toggleEventType(type: DashboardEventType) {
setEventFilters((filters) => ({
...filters,
types: filters.types.includes(type)
? filters.types.filter((item) => item !== type)
: [...filters.types, type],
}))
setEventPage(1)
}
function toggleIncomeType(type: DashboardIncomeType) {
setIncomeFilters((filters) => ({
...filters,
types: filters.types.includes(type)
? filters.types.filter((item) => item !== type)
: [...filters.types, type],
}))
incomePagination.reset()
}
return (
<Box sx={{ display: 'grid', gap: 3 }}>
<BrokerDashboardHero portfolio={portfolio} analytics={analytics.data} />
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', xl: '1fr 1fr' }, gap: 3 }}>
<BrokerDashboardEventsCard
accountId={accountId}
data={events.data ? { ...events.data, items: eventPageItems } : undefined}
isLoading={events.isLoading}
isError={events.isError}
page={eventPage}
canGoBack={eventPage > 1}
canGoForward={eventPage * eventPageSize < eventItems.length}
onPreviousPage={() => setEventPage((page) => Math.max(1, page - 1))}
onNextPage={() => setEventPage((page) => page + 1)}
/>
<BrokerDashboardIncomeCard
accountId={accountId}
page={operations.data}
isLoading={operations.isLoading}
isError={operations.isError}
pageNumber={incomePagination.pageNumber}
canGoBack={incomePagination.pageNumber > 1}
canGoForward={operations.data?.hasNext ?? false}
onPreviousPage={incomePagination.handlePrevious}
onNextPage={() => incomePagination.handleNext(nextCursor)}
/>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', lg: '1.2fr .8fr' }, gap: 3 }}>
<BrokerDashboardAnalyticsCard
data={analytics.data}
isLoading={analytics.isLoading}
isError={analytics.isError}
/>
<BrokerDashboardAllocationCard portfolio={portfolio} />
</Box>
<BrokerDashboardEventsCard
accountId={accountId}
data={events.data ? { ...events.data, items: eventPageItems } : undefined}
isLoading={events.isLoading}
isError={events.isError}
selectedTypes={eventFilters.types}
onToggleType={toggleEventType}
page={eventPage}
canGoBack={eventPage > 1}
canGoForward={eventPage * eventPageSize < eventItems.length}
onPreviousPage={() => setEventPage((page) => Math.max(1, page - 1))}
onNextPage={() => setEventPage((page) => page + 1)}
/>
<BrokerDashboardIncomeCard
accountId={accountId}
page={operations.data}
isLoading={operations.isLoading}
isError={operations.isError}
selectedTypes={incomeFilters.types}
onToggleType={toggleIncomeType}
pageNumber={incomePagination.pageNumber}
canGoBack={incomePagination.pageNumber > 1}
canGoForward={operations.data?.hasNext ?? false}
onPreviousPage={incomePagination.handlePrevious}
onNextPage={() => incomePagination.handleNext(nextCursor)}
/>
<BrokerDashboardAnalyticsCard
data={analytics.data}
isLoading={analytics.isLoading}
isError={analytics.isError}
/>
<BrokerDashboardAllocationCard portfolio={portfolio} />
</Box>
)
}

View File

@ -3,14 +3,28 @@ import { Box } from '@mui/material'
import { Link } from '@tanstack/react-router'
import type { BrokerEventItem, BrokerEventsData } from '@/shared/api'
import { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters'
import type { DashboardEventType } from '../lib/dashboardFilters'
import { eventStatusLabel, eventTypeLabel } from '../lib/dashboardFormatters'
import { BrokerDashboardCard } from './BrokerDashboardCard'
const EVENT_FILTERS: Array<{
type: DashboardEventType
label: string
tone: 'success' | 'info' | 'warning' | 'neutral'
}> = [
{ type: 'dividend', label: 'Дивиденды', tone: 'success' },
{ type: 'coupon', label: 'Купоны', tone: 'info' },
{ type: 'maturity', label: 'Погашения', tone: 'warning' },
{ type: 'offer', label: 'Оферты', tone: 'neutral' },
]
type BrokerDashboardEventsCardProps = {
accountId: string
data: BrokerEventsData | undefined
isLoading: boolean
isError: boolean
selectedTypes: DashboardEventType[]
onToggleType: (type: DashboardEventType) => void
page: number
onPreviousPage: () => void
onNextPage: () => void
@ -30,6 +44,8 @@ export function BrokerDashboardEventsCard({
data,
isLoading,
isError,
selectedTypes,
onToggleType,
page,
onPreviousPage,
onNextPage,
@ -44,12 +60,19 @@ export function BrokerDashboardEventsCard({
action={<Link to={`/broker/${encodeURIComponent(accountId)}/events`}>Все события</Link>}
>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 1.5 }}>
<Chip label="Дивиденды" tone="success" />
<Chip label="Купоны" tone="info" />
<Chip label="Погашения" tone="warning" />
<Chip label="Оферты" tone="neutral" />
{EVENT_FILTERS.map((filter) => (
<Chip
key={filter.type}
label={filter.label}
tone={filter.tone}
selected={selectedTypes.includes(filter.type)}
onClick={() => onToggleType(filter.type)}
/>
))}
</Box>
{isError ? (
{selectedTypes.length === 0 ? (
<Text tone="negative">Выберите хотя бы один тип событий</Text>
) : isError ? (
<Text tone="negative">Не удалось загрузить события</Text>
) : isLoading ? (
<Text tone="muted">Загрузка событий</Text>

View File

@ -3,14 +3,26 @@ import { Box } from '@mui/material'
import { Link } from '@tanstack/react-router'
import type { BrokerOperationsPage } from '@/shared/api'
import { formatBrokerCurrencyValue, formatBrokerDate } from '@/shared/lib/formatters'
import type { DashboardIncomeType } from '../lib/dashboardFilters'
import { getDashboardIncomeRows, sumDashboardIncome } from '../lib/dashboardIncome'
import { BrokerDashboardCard } from './BrokerDashboardCard'
const INCOME_FILTERS: Array<{
type: DashboardIncomeType
label: string
tone: 'success' | 'info'
}> = [
{ type: 'dividend', label: 'Дивиденды', tone: 'success' },
{ type: 'coupon', label: 'Купоны', tone: 'info' },
]
type BrokerDashboardIncomeCardProps = {
accountId: string
page: BrokerOperationsPage | undefined
isLoading: boolean
isError: boolean
selectedTypes: DashboardIncomeType[]
onToggleType: (type: DashboardIncomeType) => void
pageNumber: number
canGoBack: boolean
canGoForward: boolean
@ -23,6 +35,8 @@ export function BrokerDashboardIncomeCard({
page,
isLoading,
isError,
selectedTypes,
onToggleType,
pageNumber,
canGoBack,
canGoForward,
@ -38,10 +52,19 @@ export function BrokerDashboardIncomeCard({
action={<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Все операции</Link>}
>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 1.5 }}>
<Chip label="Дивиденды" tone="success" />
<Chip label="Купоны" tone="info" />
{INCOME_FILTERS.map((filter) => (
<Chip
key={filter.type}
label={filter.label}
tone={filter.tone}
selected={selectedTypes.includes(filter.type)}
onClick={() => onToggleType(filter.type)}
/>
))}
</Box>
{isError ? (
{selectedTypes.length === 0 ? (
<Text tone="negative">Выберите хотя бы один тип доходов</Text>
) : isError ? (
<Text tone="negative">Не удалось загрузить доходные операции</Text>
) : isLoading ? (
<Text tone="muted">Загрузка доходов</Text>

View File

@ -5,14 +5,10 @@ export function BrokerDashboardSkeleton() {
return (
<Box sx={{ display: 'grid', gap: 3 }} aria-label="Загрузка брокерского дашборда">
<Skeleton height={120} shape="rounded" />
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', xl: '1fr 1fr' }, gap: 3 }}>
<Skeleton height={300} shape="rounded" />
<Skeleton height={300} shape="rounded" />
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', lg: '1.2fr .8fr' }, gap: 3 }}>
<Skeleton height={260} shape="rounded" />
<Skeleton height={260} shape="rounded" />
</Box>
<Skeleton height={300} shape="rounded" />
<Skeleton height={300} shape="rounded" />
<Skeleton height={260} shape="rounded" />
<Skeleton height={260} shape="rounded" />
</Box>
)
}

View File

@ -53,13 +53,13 @@
- `/broker/:accountId` остаётся overview выбранного брокерского счёта.
- Overview визуально становится dashboard-страницей, а не вертикальным списком независимых секций.
- На desktop первый экран содержит hero KPI и два основных информационных блока рядом: `События` и
`Доходы`.
- Ниже отображаются `Аналитика доходности` и `Аллокация`.
- Существующая навигация счёта отображается горизонтальными вкладками над контентом, чтобы не занимать
левую колонку и оставить больше пространства для таблиц dashboard.
- На desktop и mobile блоки dashboard отображаются по одному блоку на строке: hero KPI, `События`,
`Доходы`, `Аналитика доходности`, `Аллокация`.
- Существующая навигация счёта сохраняет ссылки на `Обзор`, `Акции`, `Облигации`, `Операции`,
`События`, `Аналитика`.
- На мобильном viewport дашборд перестраивается в одну колонку с порядком: hero KPI, события, доходы,
аналитика, аллокация.
- На мобильном viewport сохраняется тот же порядок блоков в одну колонку.
### 2. Визуальный стиль
@ -88,10 +88,12 @@ Hero показывает:
- Блок использует существующий источник `useBrokerEvents(accountId, query)`.
- По умолчанию применяется период `сегодня - 7 дней` / `сегодня + 7 дней` и типы
`dividend,coupon,maturity,offer`, как в существующей вкладке событий.
- Блок содержит фильтр типов событий: `Дивиденды`, `Купоны`, `Погашения`, `Оферты`.
- Блок содержит кликабельные chip-фильтры типов событий: `Дивиденды`, `Купоны`, `Погашения`, `Оферты`.
- Пользователь может выбрать несколько типов событий.
- Если пользователь снимает все типы событий, запрос не выполняется, а блок показывает
валидационное сообщение.
- Изменение chip-фильтров типов событий применяется сразу и возвращает локальную пагинацию на первую
страницу.
- Блок содержит фильтр периода `from` / `to`.
- Изменение черновых фильтров не запускает запрос до нажатия `Показать`.
- Блок содержит быстрые пресеты периода `7д`, `30д`, `90д`, `1г`, `Всё` и действие `Сбросить`.
@ -113,10 +115,11 @@ Hero показывает:
операций.
- В первую версию входят операции с типами дивидендов и купонов, которые уже используются в backend
analytics: `OPERATION_TYPE_DIVIDEND`, `OPERATION_TYPE_DIV_EXT`, `OPERATION_TYPE_COUPON`.
- Блок содержит фильтр типов доходов: `Дивиденды`, `Купоны`.
- Блок содержит кликабельные chip-фильтры типов доходов: `Дивиденды`, `Купоны`.
- Пользователь может выбрать один или оба типа доходов.
- Если пользователь снимает все типы доходов, запрос не выполняется, а блок показывает
валидационное сообщение.
- Изменение chip-фильтров типов доходов применяется сразу и сбрасывает cursor-пагинацию.
- Блок содержит фильтр периода `from` / `to`.
- По умолчанию используется период с начала текущего календарного года до текущей даты, как в разделе
операций.
@ -175,16 +178,16 @@ Hero показывает:
- `/broker/:accountId` показывает dashboard-композицию: hero KPI, `События`, `Доходы`,
`Аналитика доходности`, `Аллокация`.
- На desktop блоки `События` и `Доходы` расположены рядом.
- Навигация счёта отображается горизонтальными вкладками над dashboard-контентом.
- На desktop и mobile блоки `События`, `Доходы`, `Аналитика доходности`, `Аллокация` расположены по
одному блоку на строке.
- На мобильном viewport dashboard читаемо перестраивается в одну колонку.
- Hero показывает стоимость портфеля, доходность или fallback, дневное изменение или fallback, всего
доходов или fallback.
- Блок `События` использует существующие events data и показывает дату, инструмент, тип, сумму и статус.
- Блок `События` поддерживает multi-select фильтр типов, фильтр периода, быстрые пресеты, сброс и
локальную пагинацию по 10 событий.
- Блок `События` поддерживает multi-select chip-фильтр типов и локальную пагинацию по 10 событий.
- Блок `Доходы` показывает доходные операции дивидендов и купонов и итог по отображаемым строкам.
- Блок `Доходы` поддерживает multi-select фильтр типов, фильтр периода, быстрые пресеты, сброс и
cursor-пагинацию по 10 операций.
- Блок `Доходы` поддерживает multi-select chip-фильтр типов и cursor-пагинацию по 10 операций.
- Блок `Аналитика доходности` показывает данные существующего analytics endpoint.
- Блок `Аллокация` показывает donut/легенду существующей структуры портфеля.
- Ошибка одного вторичного блока не скрывает остальные блоки dashboard.

View File

@ -23,9 +23,13 @@
- [x] Добавить `BrokerDashboardHero` с KPI по portfolio и analytics.
- [x] Добавить `BrokerDashboardEventsCard` на основе `useBrokerEvents`.
- [x] Добавить `BrokerDashboardIncomeCard` на основе `useBrokerOperations`.
- [ ] Добавить фильтры типов, фильтры периода, пресеты, reset/apply actions для `События`.
- [x] Перенести навигацию счёта из левой колонки в горизонтальные вкладки над контентом.
- [x] Перестроить dashboard на один блок на строке для `События`, `Доходы`, `Аналитика доходности`, `Аллокация`.
- [x] Добавить кликабельные chip-фильтры типов для `События`.
- [ ] Добавить фильтры периода, пресеты, reset/apply actions для `События`.
- [x] Добавить локальную пагинацию по 10 событий в `События`.
- [ ] Добавить фильтры типов, фильтры периода, пресеты, reset/apply actions для `Доходы`.
- [x] Добавить кликабельные chip-фильтры типов для `Доходы`.
- [ ] Добавить фильтры периода, пресеты, reset/apply actions для `Доходы`.
- [x] Добавить cursor-пагинацию по 10 операций в `Доходы`.
- [x] Добавить `BrokerDashboardAnalyticsCard` на основе `useBrokerAnalytics`.
- [x] Добавить `BrokerDashboardAllocationCard` на основе существующей аллокации.
@ -38,8 +42,8 @@
## Definition of Done
- [x] `rtk npm run test:frontend` проходит (31 files, 133 tests).
- [x] `rtk npm run test:design-system` проходит (28 files, 160 tests).
- [x] `rtk npm run test:frontend` проходит (31 files, 135 tests).
- [x] `rtk npm run test:design-system` проходит (28 files, 162 tests).
- [x] `rtk npm run lint -w apps/frontend` проходит.
- [x] `rtk npm run build:frontend` проходит.
- [x] Dashboard соответствует acceptance criteria из `spec.md`.

View File

@ -59,4 +59,22 @@ describe('Chip', () => {
renderWithTheme(<Chip label="Static" />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('calls onClick when clickable chip clicked', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
renderWithTheme(<Chip label="Clickable" onClick={handleClick} />);
await user.click(screen.getByRole('button', { name: 'Clickable' }));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('renders unselected clickable chip as outlined', () => {
renderWithTheme(<Chip label="Inactive" onClick={() => {}} selected={false} />);
const chip = screen.getByText('Inactive').closest('.MuiChip-root')!;
expect(chip.classList.contains('MuiChip-outlined')).toBe(true);
expect(chip).toHaveAttribute('aria-pressed', 'false');
});
});

View File

@ -1,5 +1,5 @@
import { Chip as MuiChip } from '@mui/material';
import CancelIcon from '@mui/icons-material/Cancel';
import { Chip as MuiChip } from '@mui/material';
type Tone = 'neutral' | 'info' | 'success' | 'warning' | 'error';
@ -15,14 +15,28 @@ export interface ChipProps {
label: string;
tone?: Tone;
onDelete?: () => void;
onClick?: () => void;
selected?: boolean;
disabled?: boolean;
}
export function Chip({ label, tone = 'neutral', onDelete }: ChipProps) {
export function Chip({
label,
tone = 'neutral',
onDelete,
onClick,
selected = true,
disabled,
}: ChipProps) {
return (
<MuiChip
label={label}
color={TONE_MAP[tone]}
variant={selected ? 'filled' : 'outlined'}
onClick={onClick}
onDelete={onDelete}
disabled={disabled}
aria-pressed={onClick ? selected : undefined}
{...(onDelete ? { deleteIcon: <CancelIcon aria-label={`Remove ${label}`} /> } : {})}
/>
);