@proteos/ui 0.60.0 → 0.75.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +416 -30
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +308 -13
- package/dist/index.d.ts +308 -13
- package/dist/index.js +414 -35
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/availability-calendar/availability-calendar.stories.tsx +78 -0
- package/src/components/availability-calendar/availability-calendar.test.tsx +89 -0
- package/src/components/availability-calendar/availability-calendar.tsx +182 -0
- package/src/components/availability-calendar/index.ts +5 -0
- package/src/components/conversation-brief-card/conversation-brief-preview.tsx +176 -47
- package/src/components/conversation-brief-card/conversation-brief-review.test.tsx +58 -1
- package/src/components/data-table/data-table.test.tsx +24 -0
- package/src/components/data-table/data-table.tsx +9 -0
- package/src/components/stepper/index.ts +8 -0
- package/src/components/stepper/stepper.stories.tsx +84 -0
- package/src/components/stepper/stepper.test.tsx +79 -0
- package/src/components/stepper/stepper.tsx +127 -0
- package/src/components/time-slot-list/index.ts +2 -0
- package/src/components/time-slot-list/time-slot-list.stories.tsx +100 -0
- package/src/components/time-slot-list/time-slot-list.test.tsx +120 -0
- package/src/components/time-slot-list/time-slot-list.tsx +154 -0
- package/src/components/tooltip/tooltip.tsx +4 -1
- package/src/index.ts +3 -0
|
@@ -172,9 +172,66 @@ describe('ConversationBriefPreview', () => {
|
|
|
172
172
|
onCall={onCall}
|
|
173
173
|
/>,
|
|
174
174
|
)
|
|
175
|
+
// Rests as a labelled row naming the first person + number; the pencil
|
|
176
|
+
// reveals the who-to-call select.
|
|
177
|
+
expect(screen.getByText('Calling')).toBeInTheDocument()
|
|
178
|
+
expect(screen.getByText('Alice Example · +4930123456')).toBeInTheDocument()
|
|
179
|
+
await userEvent.click(screen.getByRole('button', { name: 'Change the number to dial' }))
|
|
175
180
|
expect(screen.getByRole('button', { name: /Who to call/ })).toBeInTheDocument()
|
|
181
|
+
await userEvent.click(screen.getByRole('button', { name: /Who to call/ }))
|
|
182
|
+
await userEvent.click(screen.getByRole('option', { name: /Tom Example/ }))
|
|
183
|
+
expect(screen.getByText('Tom Example · +4915500000')).toBeInTheDocument()
|
|
176
184
|
await userEvent.click(screen.getByRole('button', { name: 'Call' }))
|
|
177
|
-
expect(onCall).toHaveBeenCalledWith(expect.objectContaining({ id: 'contact-
|
|
185
|
+
expect(onCall).toHaveBeenCalledWith(expect.objectContaining({ id: 'contact-2' }), '+4915500000')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('shows a single number without a pencil and hides the meta line on request', () => {
|
|
189
|
+
render(
|
|
190
|
+
<ConversationBriefPreview
|
|
191
|
+
brief={brief()}
|
|
192
|
+
contacts={[
|
|
193
|
+
contact({
|
|
194
|
+
addresses: [{ id: 'a2', kind: 'phone', value: '+4930123456' }],
|
|
195
|
+
} as Partial<Contact>),
|
|
196
|
+
]}
|
|
197
|
+
onCall={vi.fn()}
|
|
198
|
+
isMetaHidden
|
|
199
|
+
/>,
|
|
200
|
+
)
|
|
201
|
+
expect(screen.getByText('+4930123456')).toBeInTheDocument()
|
|
202
|
+
expect(
|
|
203
|
+
screen.queryByRole('button', { name: 'Change the number to dial' }),
|
|
204
|
+
).not.toBeInTheDocument()
|
|
205
|
+
expect(screen.queryByText(/Channel phone/)).not.toBeInTheDocument()
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('lets a contact with several numbers pick which one to dial', async () => {
|
|
209
|
+
const onSelectionChange = vi.fn()
|
|
210
|
+
render(
|
|
211
|
+
<ConversationBriefPreview
|
|
212
|
+
brief={brief()}
|
|
213
|
+
contacts={[contact()]}
|
|
214
|
+
onCall={vi.fn()}
|
|
215
|
+
isCallButtonHidden
|
|
216
|
+
onSelectionChange={onSelectionChange}
|
|
217
|
+
/>,
|
|
218
|
+
)
|
|
219
|
+
// First number auto-selected; the pencil reveals the number select, the
|
|
220
|
+
// label stays, and the x folds it back without a change.
|
|
221
|
+
expect(screen.getByText('+4930123456')).toBeInTheDocument()
|
|
222
|
+
await userEvent.click(screen.getByRole('button', { name: 'Change the number to dial' }))
|
|
223
|
+
expect(screen.getByText('Calling')).toBeInTheDocument()
|
|
224
|
+
await userEvent.click(screen.getByRole('button', { name: 'Keep the current number' }))
|
|
225
|
+
expect(screen.getByText('+4930123456')).toBeInTheDocument()
|
|
226
|
+
expect(screen.queryByRole('button', { name: /Number to dial/ })).not.toBeInTheDocument()
|
|
227
|
+
await userEvent.click(screen.getByRole('button', { name: 'Change the number to dial' }))
|
|
228
|
+
await userEvent.click(screen.getByRole('button', { name: /Number to dial/ }))
|
|
229
|
+
await userEvent.click(screen.getByRole('option', { name: '+4917612345' }))
|
|
230
|
+
expect(screen.getByText('+4917612345')).toBeInTheDocument()
|
|
231
|
+
expect(onSelectionChange).toHaveBeenLastCalledWith(
|
|
232
|
+
expect.objectContaining({ id: 'contact-1' }),
|
|
233
|
+
'+4917612345',
|
|
234
|
+
)
|
|
178
235
|
})
|
|
179
236
|
|
|
180
237
|
it('can hide its Call button and report the target to the host', () => {
|
|
@@ -172,3 +172,27 @@ describe('DataTable grouping', () => {
|
|
|
172
172
|
expect(screen.queryByText('crm')).toBeNull()
|
|
173
173
|
})
|
|
174
174
|
})
|
|
175
|
+
|
|
176
|
+
/* `placeholderData: keepPreviousData` keeps the previous rows on screen while
|
|
177
|
+
a changed filter / sort loads: no skeleton, the body just dims. */
|
|
178
|
+
describe('DataTable placeholder rows', () => {
|
|
179
|
+
it('keeps the rows and dims the body while the query shows placeholder data', () => {
|
|
180
|
+
render(
|
|
181
|
+
<DataTable<Row>
|
|
182
|
+
query={{ ...query(ROWS), isPlaceholderData: true }}
|
|
183
|
+
columns={columns}
|
|
184
|
+
ariaLabel="Records"
|
|
185
|
+
/>,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
|
189
|
+
expect(screen.queryByRole('status')).toBeNull()
|
|
190
|
+
expect(screen.getByText('Alpha').closest('[aria-busy]')).toHaveAttribute('aria-busy', 'true')
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('renders the body at full opacity once the rows are its own', () => {
|
|
194
|
+
render(<DataTable<Row> query={query(ROWS)} columns={columns} ariaLabel="Records" />)
|
|
195
|
+
|
|
196
|
+
expect(screen.getByText('Alpha').closest('[aria-busy]')).toBeNull()
|
|
197
|
+
})
|
|
198
|
+
})
|
|
@@ -59,6 +59,10 @@ type InfinitePage<TRow> = { data: TRow[]; meta?: { items_total?: number } }
|
|
|
59
59
|
export type InfiniteQueryLike<TRow> = {
|
|
60
60
|
data?: { pages: InfinitePage<TRow>[] } | undefined
|
|
61
61
|
isLoading: boolean
|
|
62
|
+
/** The rows on screen belong to the PREVIOUS query (a filter or sort just
|
|
63
|
+
* changed and the new rows are still loading — `placeholderData:
|
|
64
|
+
* keepPreviousData`). The body dims until the fresh rows land. */
|
|
65
|
+
isPlaceholderData?: boolean
|
|
62
66
|
isFetchingNextPage: boolean
|
|
63
67
|
hasNextPage: boolean | undefined
|
|
64
68
|
error: Error | null
|
|
@@ -572,6 +576,11 @@ export function DataTable<TRow>({
|
|
|
572
576
|
<div
|
|
573
577
|
ref={listRef}
|
|
574
578
|
role="presentation"
|
|
579
|
+
aria-busy={query.isPlaceholderData ? true : undefined}
|
|
580
|
+
className={cn(
|
|
581
|
+
'transition-opacity duration-150 motion-reduce:transition-none',
|
|
582
|
+
query.isPlaceholderData && 'opacity-50',
|
|
583
|
+
)}
|
|
575
584
|
style={{ height: totalSize, position: 'relative', width: '100%' }}
|
|
576
585
|
>
|
|
577
586
|
{virtualItems.map((vRow) => {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { Route } from 'lucide-react'
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { Stepper, StepperStep, type StepState } from './stepper'
|
|
6
|
+
|
|
7
|
+
const meta: Meta<typeof Stepper> = {
|
|
8
|
+
title: 'Components/Stepper',
|
|
9
|
+
component: Stepper,
|
|
10
|
+
tags: ['autodocs'],
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default meta
|
|
14
|
+
type Story = StoryObj<typeof Stepper>
|
|
15
|
+
|
|
16
|
+
const STEPS = ['Definition', 'Sourcing', 'Audience review', 'Journey building', 'Schedule', 'Run']
|
|
17
|
+
|
|
18
|
+
function stateAt(index: number, current: number): StepState {
|
|
19
|
+
if (index < current) return 'completed'
|
|
20
|
+
if (index === current) return 'current'
|
|
21
|
+
return 'upcoming'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* Current step in the middle: completed ones carry a check, the current one
|
|
25
|
+
is filled, the rest are outlined. */
|
|
26
|
+
export const Default: Story = {
|
|
27
|
+
render: () => (
|
|
28
|
+
<Stepper aria-label="Campaign setup">
|
|
29
|
+
{STEPS.map((label, i) => (
|
|
30
|
+
<StepperStep key={label} index={i} state={stateAt(i, 3)} isLast={i === STEPS.length - 1}>
|
|
31
|
+
{label}
|
|
32
|
+
</StepperStep>
|
|
33
|
+
))}
|
|
34
|
+
</Stepper>
|
|
35
|
+
),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* Clicking a step selects it for viewing without moving the current one —
|
|
39
|
+
the page renderer's preview behaviour. */
|
|
40
|
+
export const Preview: Story = {
|
|
41
|
+
render: function PreviewStory() {
|
|
42
|
+
const current = 2
|
|
43
|
+
const [selected, setSelected] = useState(current)
|
|
44
|
+
return (
|
|
45
|
+
<div className="flex flex-col gap-4">
|
|
46
|
+
<Stepper aria-label="Campaign setup">
|
|
47
|
+
{STEPS.map((label, i) => (
|
|
48
|
+
<StepperStep
|
|
49
|
+
key={label}
|
|
50
|
+
index={i}
|
|
51
|
+
state={stateAt(i, current)}
|
|
52
|
+
isSelected={i === selected}
|
|
53
|
+
isLast={i === STEPS.length - 1}
|
|
54
|
+
onPress={() => setSelected(i)}
|
|
55
|
+
>
|
|
56
|
+
{label}
|
|
57
|
+
</StepperStep>
|
|
58
|
+
))}
|
|
59
|
+
</Stepper>
|
|
60
|
+
<div className="text-[13px] text-ink-3">
|
|
61
|
+
Viewing <span className="text-ink">{STEPS[selected]}</span>; current step is{' '}
|
|
62
|
+
<span className="text-ink">{STEPS[current]}</span>.
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* An icon replaces the number on upcoming / current markers. */
|
|
70
|
+
export const WithIcons: Story = {
|
|
71
|
+
render: () => (
|
|
72
|
+
<Stepper aria-label="Campaign setup">
|
|
73
|
+
<StepperStep index={0} state="completed">
|
|
74
|
+
Definition
|
|
75
|
+
</StepperStep>
|
|
76
|
+
<StepperStep index={1} state="current" icon={<Route />}>
|
|
77
|
+
Journey building
|
|
78
|
+
</StepperStep>
|
|
79
|
+
<StepperStep index={2} state="upcoming" icon={<Route />} isLast>
|
|
80
|
+
Run
|
|
81
|
+
</StepperStep>
|
|
82
|
+
</Stepper>
|
|
83
|
+
),
|
|
84
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { Stepper, StepperStep } from './stepper'
|
|
6
|
+
|
|
7
|
+
function renderRail(onPress?: (index: number) => void) {
|
|
8
|
+
return render(
|
|
9
|
+
<Stepper aria-label="Setup">
|
|
10
|
+
<StepperStep index={0} state="completed" onPress={() => onPress?.(0)}>
|
|
11
|
+
Define
|
|
12
|
+
</StepperStep>
|
|
13
|
+
<StepperStep index={1} state="current" onPress={() => onPress?.(1)}>
|
|
14
|
+
Source
|
|
15
|
+
</StepperStep>
|
|
16
|
+
<StepperStep index={2} state="upcoming" isLast onPress={() => onPress?.(2)}>
|
|
17
|
+
Run
|
|
18
|
+
</StepperStep>
|
|
19
|
+
</Stepper>,
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('Stepper', () => {
|
|
24
|
+
it('is an ordered list of buttons', () => {
|
|
25
|
+
renderRail()
|
|
26
|
+
expect(screen.getByRole('list', { name: 'Setup' })).toBeInTheDocument()
|
|
27
|
+
expect(screen.getAllByRole('listitem')).toHaveLength(3)
|
|
28
|
+
expect(screen.getAllByRole('button').map((b) => b.textContent)).toEqual([
|
|
29
|
+
'Define',
|
|
30
|
+
'2Source',
|
|
31
|
+
'3Run',
|
|
32
|
+
])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('marks only the current step with aria-current="step"', () => {
|
|
36
|
+
renderRail()
|
|
37
|
+
const items = screen.getAllByRole('listitem')
|
|
38
|
+
expect(items[0]).not.toHaveAttribute('aria-current')
|
|
39
|
+
expect(items[1]).toHaveAttribute('aria-current', 'step')
|
|
40
|
+
expect(items[2]).not.toHaveAttribute('aria-current')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('reports presses per step', async () => {
|
|
44
|
+
const onPress = vi.fn()
|
|
45
|
+
renderRail(onPress)
|
|
46
|
+
await userEvent.click(screen.getByRole('button', { name: 'Run' }))
|
|
47
|
+
expect(onPress).toHaveBeenCalledWith(2)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('shows an icon instead of the number on a step that is not completed', () => {
|
|
51
|
+
render(
|
|
52
|
+
<Stepper>
|
|
53
|
+
<StepperStep index={0} state="upcoming" icon={<svg data-testid="step-icon" />} isLast>
|
|
54
|
+
Run
|
|
55
|
+
</StepperStep>
|
|
56
|
+
</Stepper>,
|
|
57
|
+
)
|
|
58
|
+
expect(screen.getByTestId('step-icon')).toBeInTheDocument()
|
|
59
|
+
expect(screen.getByRole('button', { name: 'Run' }).textContent).toBe('Run')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('stacks vertically on request, with connectors between the steps', () => {
|
|
63
|
+
const { container } = render(
|
|
64
|
+
<Stepper aria-label="Wrap-up" orientation="vertical">
|
|
65
|
+
<StepperStep index={0} state="completed" orientation="vertical" isDisabled>
|
|
66
|
+
Transcript
|
|
67
|
+
</StepperStep>
|
|
68
|
+
<StepperStep index={1} state="current" orientation="vertical" isLast isDisabled>
|
|
69
|
+
Summary
|
|
70
|
+
</StepperStep>
|
|
71
|
+
</Stepper>,
|
|
72
|
+
)
|
|
73
|
+
expect(screen.getByRole('list', { name: 'Wrap-up' })).toBeInTheDocument()
|
|
74
|
+
expect(screen.getAllByRole('listitem')).toHaveLength(2)
|
|
75
|
+
// One connector between two steps (the last step draws none).
|
|
76
|
+
expect(container.querySelectorAll('li > span[aria-hidden]').length).toBe(1)
|
|
77
|
+
expect(screen.getByRole('button', { name: 'Transcript' })).toBeDisabled()
|
|
78
|
+
})
|
|
79
|
+
})
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { Check } from 'lucide-react'
|
|
2
|
+
import type { HTMLAttributes, ReactNode } from 'react'
|
|
3
|
+
import { Button as AriaButton, type ButtonProps as AriaButtonProps } from 'react-aria-components'
|
|
4
|
+
import { tv, type VariantProps } from 'tailwind-variants'
|
|
5
|
+
|
|
6
|
+
import { cn } from '../../lib/cn'
|
|
7
|
+
|
|
8
|
+
/* =========================================================================
|
|
9
|
+
Stepper — an ordered rail of steps: a numbered marker per step, a check
|
|
10
|
+
on the completed ones, the current one marked (`aria-current="step"`),
|
|
11
|
+
connectors in between. Chrome only — which step is current, and what a
|
|
12
|
+
click does, is the consumer's business (the page renderer's `stepper`
|
|
13
|
+
element previews a step on click; a wizard would navigate).
|
|
14
|
+
|
|
15
|
+
`<Stepper>` is the `<ol>`; each `<StepperStep>` is an `<li>` wrapping a
|
|
16
|
+
RAC Button so steps are keyboard-reachable and announce their state.
|
|
17
|
+
`orientation="vertical"` stacks the steps with a short vertical connector
|
|
18
|
+
under each marker (a progress checklist, a wizard's side rail).
|
|
19
|
+
========================================================================= */
|
|
20
|
+
|
|
21
|
+
export const stepperStyles = tv({
|
|
22
|
+
slots: {
|
|
23
|
+
root: 'flex min-w-0 flex-wrap items-center gap-y-2 m-0 list-none p-0',
|
|
24
|
+
item: 'flex items-center',
|
|
25
|
+
trigger: [
|
|
26
|
+
'inline-flex items-center gap-[7px] rounded-sm border-0 bg-transparent p-0',
|
|
27
|
+
'cursor-pointer select-none outline-none',
|
|
28
|
+
'transition-colors duration-150',
|
|
29
|
+
'data-[focus-visible]:shadow-[var(--shadow-focus)]',
|
|
30
|
+
'data-[disabled]:cursor-default',
|
|
31
|
+
],
|
|
32
|
+
marker: [
|
|
33
|
+
'grid size-[18px] shrink-0 place-items-center rounded-pill border-[1.5px]',
|
|
34
|
+
'font-mono text-[10px] font-medium leading-none',
|
|
35
|
+
'transition-[background-color,border-color,color] duration-150',
|
|
36
|
+
'[&_svg]:size-[11px]',
|
|
37
|
+
],
|
|
38
|
+
label: 'whitespace-nowrap text-[13px]',
|
|
39
|
+
connector: 'mx-2.5 h-px w-6 shrink-0 bg-rule',
|
|
40
|
+
},
|
|
41
|
+
variants: {
|
|
42
|
+
orientation: {
|
|
43
|
+
horizontal: {},
|
|
44
|
+
vertical: {
|
|
45
|
+
root: 'flex-col flex-nowrap items-stretch gap-y-0',
|
|
46
|
+
item: 'flex-col items-start',
|
|
47
|
+
// Centered under the 18px marker (9px in, minus half the line).
|
|
48
|
+
connector: 'mx-0 my-1 ml-[8.5px] h-4 w-px',
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
state: {
|
|
52
|
+
completed: {
|
|
53
|
+
marker: 'border-accent bg-accent text-surface',
|
|
54
|
+
label: 'text-ink-2 group-data-[hovered]:text-ink',
|
|
55
|
+
},
|
|
56
|
+
current: {
|
|
57
|
+
marker: 'border-accent bg-accent text-surface',
|
|
58
|
+
label: 'font-medium text-accent',
|
|
59
|
+
},
|
|
60
|
+
upcoming: {
|
|
61
|
+
marker: 'border-rule-strong bg-transparent text-ink-3',
|
|
62
|
+
label: 'text-ink-3 group-data-[hovered]:text-ink',
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
isSelected: {
|
|
66
|
+
true: { label: 'underline decoration-accent/40 underline-offset-4' },
|
|
67
|
+
false: {},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
defaultVariants: { orientation: 'horizontal', state: 'upcoming', isSelected: false },
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
export type StepperVariants = VariantProps<typeof stepperStyles>
|
|
74
|
+
export type StepState = NonNullable<StepperVariants['state']>
|
|
75
|
+
export type StepperOrientation = NonNullable<StepperVariants['orientation']>
|
|
76
|
+
|
|
77
|
+
export type StepperProps = HTMLAttributes<HTMLOListElement> & {
|
|
78
|
+
/** Default horizontal. Pass the same value to each step. */
|
|
79
|
+
orientation?: StepperOrientation
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function Stepper({ orientation, className, ...props }: StepperProps) {
|
|
83
|
+
const { root } = stepperStyles({ orientation })
|
|
84
|
+
return <ol {...props} className={cn(root(), className)} />
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type StepperStepProps = Omit<AriaButtonProps, 'children' | 'className'> & {
|
|
88
|
+
/** Where the step sits relative to the current one. */
|
|
89
|
+
state: StepState
|
|
90
|
+
/** Mirrors the parent `<Stepper>`'s orientation (the connector's direction). */
|
|
91
|
+
orientation?: StepperOrientation
|
|
92
|
+
/** Zero-based position; drawn as `index + 1` unless `icon` is given. */
|
|
93
|
+
index: number
|
|
94
|
+
/** Replaces the number in the marker (the check still wins when completed). */
|
|
95
|
+
icon?: ReactNode
|
|
96
|
+
/** The step the consumer is showing — may differ from the current one. */
|
|
97
|
+
isSelected?: boolean
|
|
98
|
+
/** Hides the trailing connector. */
|
|
99
|
+
isLast?: boolean
|
|
100
|
+
children: ReactNode
|
|
101
|
+
className?: string
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function StepperStep({
|
|
105
|
+
state,
|
|
106
|
+
orientation,
|
|
107
|
+
index,
|
|
108
|
+
icon,
|
|
109
|
+
isSelected = false,
|
|
110
|
+
isLast = false,
|
|
111
|
+
children,
|
|
112
|
+
className,
|
|
113
|
+
...props
|
|
114
|
+
}: StepperStepProps) {
|
|
115
|
+
const styles = stepperStyles({ orientation, state, isSelected })
|
|
116
|
+
return (
|
|
117
|
+
<li aria-current={state === 'current' ? 'step' : undefined} className={styles.item()}>
|
|
118
|
+
<AriaButton {...props} className={cn('group', styles.trigger(), className)}>
|
|
119
|
+
<span aria-hidden="true" className={styles.marker()}>
|
|
120
|
+
{state === 'completed' ? <Check strokeWidth={2.5} /> : (icon ?? index + 1)}
|
|
121
|
+
</span>
|
|
122
|
+
<span className={styles.label()}>{children}</span>
|
|
123
|
+
</AriaButton>
|
|
124
|
+
{!isLast && <span aria-hidden="true" className={styles.connector()} />}
|
|
125
|
+
</li>
|
|
126
|
+
)
|
|
127
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
|
|
4
|
+
import { TimeSlotList } from './time-slot-list'
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof TimeSlotList> = {
|
|
7
|
+
title: 'Components/TimeSlotList',
|
|
8
|
+
component: TimeSlotList,
|
|
9
|
+
tags: ['autodocs'],
|
|
10
|
+
decorators: [
|
|
11
|
+
(Story) => (
|
|
12
|
+
<div className="w-[240px] p-5 bg-surface border border-rule rounded-md">
|
|
13
|
+
<Story />
|
|
14
|
+
</div>
|
|
15
|
+
),
|
|
16
|
+
],
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default meta
|
|
20
|
+
type Story = StoryObj<typeof TimeSlotList>
|
|
21
|
+
|
|
22
|
+
const TIMES = ['09:00', '09:30', '10:00', '10:30', '11:00', '13:00', '13:30', '14:00'].map(
|
|
23
|
+
(label) => ({
|
|
24
|
+
id: label,
|
|
25
|
+
label,
|
|
26
|
+
}),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
export const Default: Story = {
|
|
30
|
+
render: () => {
|
|
31
|
+
const [selected, setSelected] = useState<string | null>('10:30')
|
|
32
|
+
return (
|
|
33
|
+
<TimeSlotList aria-label="Times" items={TIMES} selectedId={selected} onSelect={setSelected} />
|
|
34
|
+
)
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const WithInlineConfirm: Story = {
|
|
39
|
+
render: () => {
|
|
40
|
+
const [selected, setSelected] = useState<string | null>('10:30')
|
|
41
|
+
return (
|
|
42
|
+
<TimeSlotList
|
|
43
|
+
aria-label="Times"
|
|
44
|
+
items={TIMES}
|
|
45
|
+
selectedId={selected}
|
|
46
|
+
onSelect={setSelected}
|
|
47
|
+
confirmLabel="Confirm"
|
|
48
|
+
onConfirm={(id) => alert(`Booked ${id}`)}
|
|
49
|
+
/>
|
|
50
|
+
)
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const TakenSlot: Story = {
|
|
55
|
+
render: () => {
|
|
56
|
+
const [selected, setSelected] = useState<string | null>(null)
|
|
57
|
+
return (
|
|
58
|
+
<TimeSlotList
|
|
59
|
+
aria-label="Times"
|
|
60
|
+
items={TIMES.map((item) => (item.id === '10:30' ? { ...item, isDisabled: true } : item))}
|
|
61
|
+
selectedId={selected}
|
|
62
|
+
onSelect={setSelected}
|
|
63
|
+
/>
|
|
64
|
+
)
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const TwoColumns: Story = {
|
|
69
|
+
decorators: [
|
|
70
|
+
(Story) => (
|
|
71
|
+
<div className="w-[360px]">
|
|
72
|
+
<Story />
|
|
73
|
+
</div>
|
|
74
|
+
),
|
|
75
|
+
],
|
|
76
|
+
render: () => {
|
|
77
|
+
const [selected, setSelected] = useState<string | null>(null)
|
|
78
|
+
return (
|
|
79
|
+
<TimeSlotList
|
|
80
|
+
aria-label="Times"
|
|
81
|
+
items={TIMES}
|
|
82
|
+
selectedId={selected}
|
|
83
|
+
onSelect={setSelected}
|
|
84
|
+
columns={2}
|
|
85
|
+
/>
|
|
86
|
+
)
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const Empty: Story = {
|
|
91
|
+
render: () => (
|
|
92
|
+
<TimeSlotList
|
|
93
|
+
aria-label="Times"
|
|
94
|
+
items={[]}
|
|
95
|
+
selectedId={null}
|
|
96
|
+
onSelect={() => {}}
|
|
97
|
+
emptyMessage="All booked today — pick another day."
|
|
98
|
+
/>
|
|
99
|
+
),
|
|
100
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import userEvent from '@testing-library/user-event'
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { TimeSlotList } from './time-slot-list'
|
|
7
|
+
|
|
8
|
+
const TIMES = ['09:00', '09:30', '10:00'].map((label) => ({ id: label, label }))
|
|
9
|
+
|
|
10
|
+
function Harness({
|
|
11
|
+
initial = null,
|
|
12
|
+
onSelect,
|
|
13
|
+
onConfirm,
|
|
14
|
+
confirmLabel,
|
|
15
|
+
items = TIMES,
|
|
16
|
+
}: {
|
|
17
|
+
initial?: string | null
|
|
18
|
+
onSelect?: (id: string) => void
|
|
19
|
+
onConfirm?: (id: string) => void
|
|
20
|
+
confirmLabel?: string
|
|
21
|
+
items?: ReadonlyArray<{ id: string; label: string; isDisabled?: boolean }>
|
|
22
|
+
}) {
|
|
23
|
+
const [selected, setSelected] = useState<string | null>(initial)
|
|
24
|
+
return (
|
|
25
|
+
<TimeSlotList
|
|
26
|
+
aria-label="Times"
|
|
27
|
+
items={items}
|
|
28
|
+
selectedId={selected}
|
|
29
|
+
onSelect={(id) => {
|
|
30
|
+
setSelected(id)
|
|
31
|
+
onSelect?.(id)
|
|
32
|
+
}}
|
|
33
|
+
{...(confirmLabel ? { confirmLabel } : {})}
|
|
34
|
+
{...(onConfirm ? { onConfirm } : {})}
|
|
35
|
+
/>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('TimeSlotList', () => {
|
|
40
|
+
it('renders one exclusive option per item', () => {
|
|
41
|
+
render(<Harness />)
|
|
42
|
+
expect(screen.getByRole('radiogroup', { name: 'Times' })).toBeInTheDocument()
|
|
43
|
+
expect(screen.getAllByRole('radio')).toHaveLength(3)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('selects on click and reports the id', async () => {
|
|
47
|
+
const user = userEvent.setup()
|
|
48
|
+
const onSelect = vi.fn()
|
|
49
|
+
render(<Harness onSelect={onSelect} />)
|
|
50
|
+
await user.click(screen.getByRole('radio', { name: '09:30' }))
|
|
51
|
+
expect(onSelect).toHaveBeenLastCalledWith('09:30')
|
|
52
|
+
expect(screen.getByRole('radio', { name: '09:30' })).toBeChecked()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('keeps the selection when the selected item is clicked again', async () => {
|
|
56
|
+
const user = userEvent.setup()
|
|
57
|
+
const onSelect = vi.fn()
|
|
58
|
+
render(<Harness initial="09:00" onSelect={onSelect} />)
|
|
59
|
+
await user.click(screen.getByRole('radio', { name: '09:00' }))
|
|
60
|
+
expect(screen.getByRole('radio', { name: '09:00' })).toBeChecked()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('does not select a disabled item', async () => {
|
|
64
|
+
const user = userEvent.setup()
|
|
65
|
+
const onSelect = vi.fn()
|
|
66
|
+
render(
|
|
67
|
+
<Harness
|
|
68
|
+
onSelect={onSelect}
|
|
69
|
+
items={[{ id: '09:00', label: '09:00', isDisabled: true }, ...TIMES.slice(1)]}
|
|
70
|
+
/>,
|
|
71
|
+
)
|
|
72
|
+
await user.click(screen.getByRole('radio', { name: '09:00' }))
|
|
73
|
+
expect(onSelect).not.toHaveBeenCalled()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('shows the inline confirm beside the selected item only', async () => {
|
|
77
|
+
const user = userEvent.setup()
|
|
78
|
+
const onConfirm = vi.fn()
|
|
79
|
+
render(<Harness confirmLabel="Confirm" onConfirm={onConfirm} />)
|
|
80
|
+
expect(screen.queryByRole('button', { name: 'Confirm' })).not.toBeInTheDocument()
|
|
81
|
+
await user.click(screen.getByRole('radio', { name: '10:00' }))
|
|
82
|
+
await user.click(screen.getByRole('button', { name: 'Confirm' }))
|
|
83
|
+
expect(onConfirm).toHaveBeenCalledWith('10:00')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('freezes every pick and the confirm while disabled', async () => {
|
|
87
|
+
const user = userEvent.setup()
|
|
88
|
+
const onSelect = vi.fn()
|
|
89
|
+
const onConfirm = vi.fn()
|
|
90
|
+
render(
|
|
91
|
+
<TimeSlotList
|
|
92
|
+
aria-label="Times"
|
|
93
|
+
items={TIMES}
|
|
94
|
+
selectedId="09:00"
|
|
95
|
+
onSelect={onSelect}
|
|
96
|
+
confirmLabel="Confirm"
|
|
97
|
+
onConfirm={onConfirm}
|
|
98
|
+
isDisabled
|
|
99
|
+
/>,
|
|
100
|
+
)
|
|
101
|
+
await user.click(screen.getByRole('radio', { name: '09:30' }))
|
|
102
|
+
await user.click(screen.getByRole('button', { name: 'Confirm' }))
|
|
103
|
+
expect(onSelect).not.toHaveBeenCalled()
|
|
104
|
+
expect(onConfirm).not.toHaveBeenCalled()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('renders the empty message when there are no items', () => {
|
|
108
|
+
render(
|
|
109
|
+
<TimeSlotList
|
|
110
|
+
aria-label="Times"
|
|
111
|
+
items={[]}
|
|
112
|
+
selectedId={null}
|
|
113
|
+
onSelect={() => {}}
|
|
114
|
+
emptyMessage="All booked today"
|
|
115
|
+
/>,
|
|
116
|
+
)
|
|
117
|
+
expect(screen.getByRole('status')).toHaveTextContent('All booked today')
|
|
118
|
+
expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument()
|
|
119
|
+
})
|
|
120
|
+
})
|