@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.
@@ -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-1' }), '+4930123456')
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,8 @@
1
+ export type {
2
+ StepperOrientation,
3
+ StepperProps,
4
+ StepperStepProps,
5
+ StepperVariants,
6
+ StepState,
7
+ } from './stepper'
8
+ export { Stepper, StepperStep, stepperStyles } from './stepper'
@@ -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,2 @@
1
+ export type { TimeSlotListItem, TimeSlotListProps, TimeSlotListVariants } from './time-slot-list'
2
+ export { TimeSlotList, timeSlotListStyles } from './time-slot-list'
@@ -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
+ })