@proteos/ui 0.61.0 → 0.76.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.
Files changed (43) hide show
  1. package/dist/{chunk-NWGG5WFL.cjs → chunk-4ACELYEP.cjs} +23 -4
  2. package/dist/chunk-4ACELYEP.cjs.map +1 -0
  3. package/dist/{chunk-NSE4VW3Z.js → chunk-XRN5MNDX.js} +24 -5
  4. package/dist/chunk-XRN5MNDX.js.map +1 -0
  5. package/dist/index.cjs +811 -304
  6. package/dist/index.cjs.map +1 -1
  7. package/dist/index.d.cts +365 -15
  8. package/dist/index.d.ts +365 -15
  9. package/dist/index.js +537 -39
  10. package/dist/index.js.map +1 -1
  11. package/dist/{pdf-view-RRMAUK3L.cjs → pdf-view-2DSARHNQ.cjs} +7 -7
  12. package/dist/{pdf-view-RRMAUK3L.cjs.map → pdf-view-2DSARHNQ.cjs.map} +1 -1
  13. package/dist/{pdf-view-RQ2OZYHJ.js → pdf-view-2QU4FI37.js} +3 -3
  14. package/dist/{pdf-view-RQ2OZYHJ.js.map → pdf-view-2QU4FI37.js.map} +1 -1
  15. package/package.json +2 -2
  16. package/src/components/availability-calendar/availability-calendar.stories.tsx +78 -0
  17. package/src/components/availability-calendar/availability-calendar.test.tsx +89 -0
  18. package/src/components/availability-calendar/availability-calendar.tsx +182 -0
  19. package/src/components/availability-calendar/index.ts +5 -0
  20. package/src/components/button/button.stories.tsx +22 -1
  21. package/src/components/button/button.test.tsx +45 -1
  22. package/src/components/button/button.tsx +32 -2
  23. package/src/components/conversation-brief-card/conversation-brief-preview.tsx +176 -47
  24. package/src/components/conversation-brief-card/conversation-brief-review.test.tsx +58 -1
  25. package/src/components/data-table/data-table.test.tsx +65 -0
  26. package/src/components/data-table/data-table.tsx +33 -2
  27. package/src/components/glow-frame/glow-frame.stories.tsx +65 -0
  28. package/src/components/glow-frame/glow-frame.test.tsx +67 -0
  29. package/src/components/glow-frame/glow-frame.tsx +182 -0
  30. package/src/components/glow-frame/index.ts +2 -0
  31. package/src/components/stepper/index.ts +8 -0
  32. package/src/components/stepper/stepper.stories.tsx +84 -0
  33. package/src/components/stepper/stepper.test.tsx +79 -0
  34. package/src/components/stepper/stepper.tsx +127 -0
  35. package/src/components/time-slot-list/index.ts +2 -0
  36. package/src/components/time-slot-list/time-slot-list.stories.tsx +100 -0
  37. package/src/components/time-slot-list/time-slot-list.test.tsx +120 -0
  38. package/src/components/time-slot-list/time-slot-list.tsx +154 -0
  39. package/src/components/tooltip/tooltip.tsx +4 -1
  40. package/src/index.ts +4 -0
  41. package/src/styles.css +216 -0
  42. package/dist/chunk-NSE4VW3Z.js.map +0 -1
  43. package/dist/chunk-NWGG5WFL.cjs.map +0 -1
@@ -0,0 +1,67 @@
1
+ import { act, render, screen } from '@testing-library/react'
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
+
4
+ import { GlowFrame, type GlowFrameStatus } from './glow-frame'
5
+
6
+ function frame(status: GlowFrameStatus) {
7
+ return (
8
+ <GlowFrame status={status}>
9
+ <p>Output</p>
10
+ </GlowFrame>
11
+ )
12
+ }
13
+
14
+ const beams = () => document.querySelector('[data-glow="beams"]')
15
+ const flash = () => document.querySelector('[data-glow="flash"]')
16
+
17
+ describe('GlowFrame', () => {
18
+ beforeEach(() => vi.useFakeTimers())
19
+ afterEach(() => vi.useRealTimers())
20
+
21
+ it('renders its children', () => {
22
+ render(frame('idle'))
23
+ expect(screen.getByText('Output')).toBeInTheDocument()
24
+ })
25
+
26
+ it('shows the beams while working and stops them after the fade', () => {
27
+ const { rerender } = render(frame('working'))
28
+ expect(beams()).not.toBeNull()
29
+
30
+ rerender(frame('idle'))
31
+ expect(beams()).not.toBeNull()
32
+ act(() => vi.advanceTimersByTime(800))
33
+ expect(beams()).toBeNull()
34
+ })
35
+
36
+ it('flashes once when the output lands', () => {
37
+ const { rerender } = render(frame('working'))
38
+ expect(flash()).toBeNull()
39
+
40
+ rerender(frame('ready'))
41
+ expect(flash()).not.toBeNull()
42
+ act(() => vi.advanceTimersByTime(900))
43
+ expect(flash()).toBeNull()
44
+ })
45
+
46
+ it('does not flash when mounted with the output already there', () => {
47
+ render(frame('ready'))
48
+ expect(flash()).toBeNull()
49
+ expect(beams()).toBeNull()
50
+ })
51
+
52
+ it('does not flash when the work ends without output', () => {
53
+ const { rerender } = render(frame('working'))
54
+ rerender(frame('idle'))
55
+ expect(flash()).toBeNull()
56
+ })
57
+
58
+ it('flashes again for a second run', () => {
59
+ const { rerender } = render(frame('working'))
60
+ rerender(frame('ready'))
61
+ act(() => vi.advanceTimersByTime(900))
62
+ rerender(frame('working'))
63
+ expect(beams()).not.toBeNull()
64
+ rerender(frame('ready'))
65
+ expect(flash()).not.toBeNull()
66
+ })
67
+ })
@@ -0,0 +1,182 @@
1
+ import { type ReactNode, type RefObject, useEffect, useRef, useState } from 'react'
2
+ import { tv, type VariantProps } from 'tailwind-variants'
3
+
4
+ import { cn } from '../../lib/cn'
5
+
6
+ /* =========================================================================
7
+ GlowFrame — the light round a model's output. Wrap the box the output
8
+ appears in and drive `status`:
9
+
10
+ - `working` beams travel the box's edge while the model works.
11
+ - leaving `working`, the beams fade out (FADE_MS) and stop painting.
12
+ - `working` → `ready` plays one flash: a short burst of colour inside the
13
+ edge, the "here it is". Mounting straight into `ready` (a remount, a tab
14
+ switch) plays nothing, nor does `working` → `idle` (error, cancel).
15
+
16
+ Give it the box's radius (`rounded-*`) via `className`; both layers
17
+ inherit it. The frame spills 4px of fading light round the box (the
18
+ `.glow-room` in styles.css) — leave that much room before any overflow
19
+ clip, or accept that the outer spill is cut. Styles: styles.css, GlowFrame.
20
+ ========================================================================= */
21
+
22
+ /** `room` spans the 4px of fading light round the box; `frame` is the box's
23
+ * own outline, the one the beams ride and the flash fills. The light itself
24
+ * is the `.glow-*` CSS in styles.css. */
25
+ export const glowFrameStyles = tv({
26
+ slots: {
27
+ room: 'glow-room flex min-h-0 flex-col',
28
+ frame: 'glow-frame flex min-h-0 flex-1 flex-col',
29
+ },
30
+ })
31
+
32
+ export type GlowFrameVariants = VariantProps<typeof glowFrameStyles>
33
+
34
+ export type GlowFrameStatus = 'idle' | 'working' | 'ready'
35
+
36
+ export type GlowFrameProps = {
37
+ status: GlowFrameStatus
38
+ className?: string
39
+ children?: ReactNode
40
+ }
41
+
42
+ /** How long the beams take to fade in / out, in ms. */
43
+ const FADE_MS = 700
44
+ /** One flash, in ms (matches `glow-flash` in styles.css). */
45
+ const FLASH_MS = 900
46
+ /** How often the flash's colours get new places round the edge, in ms. */
47
+ const REROLL_MS = 250
48
+ const HUES = [1, 2, 3, 4, 5, 6] as const
49
+
50
+ export function GlowFrame({ status, className, children }: GlowFrameProps) {
51
+ const isWorking = status === 'working'
52
+ const { run, isResting } = useBeams(isWorking)
53
+ const flash = useFlash(status)
54
+ const strokesRef = useReroll(flash)
55
+ const styles = glowFrameStyles()
56
+
57
+ return (
58
+ <div className={cn(styles.room(), className)}>
59
+ <div
60
+ className={styles.frame()}
61
+ data-status={status}
62
+ data-working={isWorking ? 'true' : 'false'}
63
+ data-flashing={flash !== 0 ? 'true' : 'false'}
64
+ >
65
+ {children}
66
+ {isResting ? null : (
67
+ <div key={`beams-${run}`} className="glow-fx" aria-hidden="true" data-glow="beams">
68
+ <div className="glow-orb">
69
+ <div className="glow-dot glow-a" />
70
+ </div>
71
+ <div className="glow-orb">
72
+ <div className="glow-dot glow-b" />
73
+ </div>
74
+ <div className="glow-beams">
75
+ <div className="glow-blob glow-tail glow-a" />
76
+ <div className="glow-blob glow-lead glow-a" />
77
+ <div className="glow-blob glow-tail glow-b" />
78
+ <div className="glow-blob glow-lead glow-b" />
79
+ </div>
80
+ </div>
81
+ )}
82
+ {flash !== 0 ? (
83
+ <div
84
+ key={`flash-${flash}`}
85
+ ref={strokesRef}
86
+ className="glow-flash"
87
+ aria-hidden="true"
88
+ data-glow="flash"
89
+ >
90
+ <div className="glow-ring glow-ring-1" />
91
+ <div className="glow-stroke glow-stroke-2">
92
+ <div className="glow-ring glow-ring-2" />
93
+ </div>
94
+ <div className="glow-stroke glow-stroke-3">
95
+ <div className="glow-ring glow-ring-3" />
96
+ </div>
97
+ <div className="glow-stroke glow-stroke-4">
98
+ <div className="glow-ring glow-ring-4" />
99
+ </div>
100
+ </div>
101
+ ) : null}
102
+ </div>
103
+ </div>
104
+ )
105
+ }
106
+
107
+ /** The beams' lifecycle: mounted while working; once off they fade for
108
+ * FADE_MS, then `isResting` stops them painting. Each new start bumps `run`,
109
+ * which keys the layer so the entrance plays again. */
110
+ function useBeams(isWorking: boolean): { run: number; isResting: boolean } {
111
+ const [run, setRun] = useState(0)
112
+ const [isResting, setIsResting] = useState(!isWorking)
113
+ const wasWorking = useRef(isWorking)
114
+
115
+ useEffect(() => {
116
+ if (isWorking) {
117
+ if (!wasWorking.current) setRun((n) => n + 1)
118
+ wasWorking.current = true
119
+ setIsResting(false)
120
+ return
121
+ }
122
+ wasWorking.current = false
123
+ const timer = window.setTimeout(() => setIsResting(true), FADE_MS + 50)
124
+ return () => window.clearTimeout(timer)
125
+ }, [isWorking])
126
+
127
+ return { run, isResting }
128
+ }
129
+
130
+ /** The flash being played (a counter, so a second working → ready replays
131
+ * it), 0 while dark. Only a working → ready seen by this instance lights it. */
132
+ function useFlash(status: GlowFrameStatus): number {
133
+ const [flash, setFlash] = useState(0)
134
+ const previous = useRef(status)
135
+
136
+ useEffect(() => {
137
+ const was = previous.current
138
+ previous.current = status
139
+ if (!(was === 'working' && status === 'ready')) return
140
+ setFlash((n) => n + 1)
141
+ }, [status])
142
+
143
+ useEffect(() => {
144
+ if (flash === 0) return
145
+ const timer = window.setTimeout(() => setFlash(0), FLASH_MS)
146
+ return () => window.clearTimeout(timer)
147
+ }, [flash])
148
+
149
+ return flash
150
+ }
151
+
152
+ /** While a flash plays (and motion is allowed), gives the six hues new
153
+ * random places round the edge every REROLL_MS by writing the targets onto
154
+ * the rings; their CSS transitions carry each ring there at its own pace. */
155
+ function useReroll(flash: number): RefObject<HTMLDivElement | null> {
156
+ const ref = useRef<HTMLDivElement>(null)
157
+
158
+ useEffect(() => {
159
+ if (flash === 0) return
160
+ if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return
161
+ const reroll = () => {
162
+ const rings = ref.current?.querySelectorAll<HTMLElement>('.glow-ring')
163
+ if (!rings) return
164
+ // Random places ARE the animation: each hue somewhere round the edge,
165
+ // sorted by place they become the gradient's six stops.
166
+ const stops = HUES.map((hue) => ({ hue, at: Math.random() * 100 })).sort(
167
+ (a, b) => a.at - b.at,
168
+ )
169
+ for (const ring of rings) {
170
+ stops.forEach((stop, i) => {
171
+ ring.style.setProperty(`--glow-p${i + 1}`, `${stop.at.toFixed(2)}%`)
172
+ ring.style.setProperty(`--glow-k${i + 1}`, `var(--color-glow-${stop.hue})`)
173
+ })
174
+ }
175
+ }
176
+ reroll()
177
+ const timer = window.setInterval(reroll, REROLL_MS)
178
+ return () => window.clearInterval(timer)
179
+ }, [flash])
180
+
181
+ return ref
182
+ }
@@ -0,0 +1,2 @@
1
+ export type { GlowFrameProps, GlowFrameStatus, GlowFrameVariants } from './glow-frame'
2
+ export { GlowFrame, glowFrameStyles } from './glow-frame'
@@ -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
+ }