@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.
- package/dist/{chunk-NWGG5WFL.cjs → chunk-4ACELYEP.cjs} +23 -4
- package/dist/chunk-4ACELYEP.cjs.map +1 -0
- package/dist/{chunk-NSE4VW3Z.js → chunk-XRN5MNDX.js} +24 -5
- package/dist/chunk-XRN5MNDX.js.map +1 -0
- package/dist/index.cjs +811 -304
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +365 -15
- package/dist/index.d.ts +365 -15
- package/dist/index.js +537 -39
- package/dist/index.js.map +1 -1
- package/dist/{pdf-view-RRMAUK3L.cjs → pdf-view-2DSARHNQ.cjs} +7 -7
- package/dist/{pdf-view-RRMAUK3L.cjs.map → pdf-view-2DSARHNQ.cjs.map} +1 -1
- package/dist/{pdf-view-RQ2OZYHJ.js → pdf-view-2QU4FI37.js} +3 -3
- package/dist/{pdf-view-RQ2OZYHJ.js.map → pdf-view-2QU4FI37.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/button/button.stories.tsx +22 -1
- package/src/components/button/button.test.tsx +45 -1
- package/src/components/button/button.tsx +32 -2
- 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 +65 -0
- package/src/components/data-table/data-table.tsx +33 -2
- package/src/components/glow-frame/glow-frame.stories.tsx +65 -0
- package/src/components/glow-frame/glow-frame.test.tsx +67 -0
- package/src/components/glow-frame/glow-frame.tsx +182 -0
- package/src/components/glow-frame/index.ts +2 -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 +4 -0
- package/src/styles.css +216 -0
- package/dist/chunk-NSE4VW3Z.js.map +0 -1
- 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,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
|
+
}
|