@proteos/ui 0.61.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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proteos/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.75.0",
|
|
4
4
|
"license": "FSL-1.1-Apache-2.0",
|
|
5
5
|
"description": "Proteos React component library (React Aria Components + Tailwind v4).",
|
|
6
6
|
"repository": {
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"tailwind-variants": "^3.2.2",
|
|
57
57
|
"tailwindcss": "^4.2.4",
|
|
58
58
|
"thinking-orbs": "^0.3.1",
|
|
59
|
-
"@proteos/sdk": "0.
|
|
59
|
+
"@proteos/sdk": "0.75.0"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@biomejs/biome": "^2.4.13",
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type CalendarDate, getLocalTimeZone, today } from '@internationalized/date'
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { AvailabilityCalendar } from './availability-calendar'
|
|
6
|
+
|
|
7
|
+
const meta: Meta<typeof AvailabilityCalendar> = {
|
|
8
|
+
title: 'Components/AvailabilityCalendar',
|
|
9
|
+
component: AvailabilityCalendar,
|
|
10
|
+
tags: ['autodocs'],
|
|
11
|
+
decorators: [
|
|
12
|
+
(Story) => (
|
|
13
|
+
<div className="w-[480px] p-6 bg-surface border border-rule rounded-md">
|
|
14
|
+
<Story />
|
|
15
|
+
</div>
|
|
16
|
+
),
|
|
17
|
+
],
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default meta
|
|
21
|
+
type Story = StoryObj<typeof AvailabilityCalendar>
|
|
22
|
+
|
|
23
|
+
const now = today(getLocalTimeZone())
|
|
24
|
+
|
|
25
|
+
/** Weekdays from today on, for the next six weeks. */
|
|
26
|
+
function isWeekdayAhead(date: CalendarDate): boolean {
|
|
27
|
+
const offset = date.compare(now)
|
|
28
|
+
if (offset < 0 || offset > 42) return false
|
|
29
|
+
const weekday = date.toDate(getLocalTimeZone()).getDay()
|
|
30
|
+
return weekday !== 0 && weekday !== 6
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const WeekdaysAvailable: Story = {
|
|
34
|
+
render: () => {
|
|
35
|
+
const [value, setValue] = useState<CalendarDate | null>(null)
|
|
36
|
+
return (
|
|
37
|
+
<AvailabilityCalendar
|
|
38
|
+
aria-label="Pick a day"
|
|
39
|
+
value={value}
|
|
40
|
+
onChange={setValue}
|
|
41
|
+
minValue={now}
|
|
42
|
+
isDateUnavailable={(date) => !isWeekdayAhead(date)}
|
|
43
|
+
/>
|
|
44
|
+
)
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const Selected: Story = {
|
|
49
|
+
render: () => {
|
|
50
|
+
const [value, setValue] = useState<CalendarDate | null>(now.add({ days: 3 }))
|
|
51
|
+
return (
|
|
52
|
+
<AvailabilityCalendar
|
|
53
|
+
aria-label="Pick a day"
|
|
54
|
+
value={value}
|
|
55
|
+
onChange={setValue}
|
|
56
|
+
minValue={now}
|
|
57
|
+
isDateUnavailable={(date) => !isWeekdayAhead(date)}
|
|
58
|
+
/>
|
|
59
|
+
)
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const Loading: Story = {
|
|
64
|
+
render: () => (
|
|
65
|
+
<AvailabilityCalendar aria-label="Pick a day" value={null} onChange={() => {}} isLoading />
|
|
66
|
+
),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const NothingAvailable: Story = {
|
|
70
|
+
render: () => (
|
|
71
|
+
<AvailabilityCalendar
|
|
72
|
+
aria-label="Pick a day"
|
|
73
|
+
value={null}
|
|
74
|
+
onChange={() => {}}
|
|
75
|
+
isDateUnavailable={() => true}
|
|
76
|
+
/>
|
|
77
|
+
),
|
|
78
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { CalendarDate } from '@internationalized/date'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import userEvent from '@testing-library/user-event'
|
|
4
|
+
import { useState } from 'react'
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
6
|
+
|
|
7
|
+
import { AvailabilityCalendar } from './availability-calendar'
|
|
8
|
+
|
|
9
|
+
const OCTOBER_FIRST = new CalendarDate(2026, 10, 1)
|
|
10
|
+
const AVAILABLE = new Set(['2026-10-20', '2026-10-21', '2026-10-23'])
|
|
11
|
+
|
|
12
|
+
function Harness({
|
|
13
|
+
initial = null,
|
|
14
|
+
onChange,
|
|
15
|
+
isLoading = false,
|
|
16
|
+
}: {
|
|
17
|
+
initial?: CalendarDate | null
|
|
18
|
+
onChange?: (date: CalendarDate) => void
|
|
19
|
+
isLoading?: boolean
|
|
20
|
+
}) {
|
|
21
|
+
const [value, setValue] = useState<CalendarDate | null>(initial)
|
|
22
|
+
return (
|
|
23
|
+
<AvailabilityCalendar
|
|
24
|
+
aria-label="Pick a day"
|
|
25
|
+
value={value}
|
|
26
|
+
onChange={(next) => {
|
|
27
|
+
setValue(next)
|
|
28
|
+
onChange?.(next)
|
|
29
|
+
}}
|
|
30
|
+
defaultFocusedValue={OCTOBER_FIRST}
|
|
31
|
+
minValue={new CalendarDate(2026, 10, 15)}
|
|
32
|
+
isDateUnavailable={(date) => !AVAILABLE.has(date.toString())}
|
|
33
|
+
isLoading={isLoading}
|
|
34
|
+
/>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('AvailabilityCalendar', () => {
|
|
39
|
+
it('renders a labelled grid for the focused month', () => {
|
|
40
|
+
render(<Harness />)
|
|
41
|
+
expect(screen.getByRole('grid', { name: /Pick a day/ })).toBeInTheDocument()
|
|
42
|
+
expect(screen.getByText('October')).toBeInTheDocument()
|
|
43
|
+
expect(screen.getByText('2026')).toBeInTheDocument()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('marks days without availability as unavailable but keeps them in the grid', () => {
|
|
47
|
+
render(<Harness />)
|
|
48
|
+
const available = screen.getByRole('button', { name: /October 20/ })
|
|
49
|
+
const unavailable = screen.getByRole('button', { name: /October 22/ })
|
|
50
|
+
expect(available).not.toHaveAttribute('aria-disabled', 'true')
|
|
51
|
+
expect(unavailable).toHaveAttribute('aria-disabled', 'true')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('selects an available day on click and reports it', async () => {
|
|
55
|
+
const user = userEvent.setup()
|
|
56
|
+
const onChange = vi.fn()
|
|
57
|
+
render(<Harness onChange={onChange} />)
|
|
58
|
+
await user.click(screen.getByRole('button', { name: /October 23/ }))
|
|
59
|
+
expect(onChange).toHaveBeenLastCalledWith(new CalendarDate(2026, 10, 23))
|
|
60
|
+
expect(screen.getByRole('gridcell', { selected: true })).toHaveTextContent('23')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('ignores clicks on unavailable days', async () => {
|
|
64
|
+
const user = userEvent.setup()
|
|
65
|
+
const onChange = vi.fn()
|
|
66
|
+
render(<Harness onChange={onChange} />)
|
|
67
|
+
await user.click(screen.getByRole('button', { name: /October 22/ }))
|
|
68
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('refuses selection while loading and flags the grid busy', async () => {
|
|
72
|
+
const user = userEvent.setup()
|
|
73
|
+
const onChange = vi.fn()
|
|
74
|
+
render(<Harness onChange={onChange} isLoading />)
|
|
75
|
+
expect(screen.getByRole('grid', { name: /Pick a day/ }).closest('[aria-busy]')).toHaveAttribute(
|
|
76
|
+
'aria-busy',
|
|
77
|
+
'true',
|
|
78
|
+
)
|
|
79
|
+
await user.click(screen.getByRole('button', { name: /October 20/ }))
|
|
80
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('moves to the next month with the nav button', async () => {
|
|
84
|
+
const user = userEvent.setup()
|
|
85
|
+
render(<Harness />)
|
|
86
|
+
await user.click(screen.getByRole('button', { name: 'Next month' }))
|
|
87
|
+
expect(screen.getByText('November')).toBeInTheDocument()
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { CalendarDate, DateValue } from '@internationalized/date'
|
|
2
|
+
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
|
3
|
+
import { useContext } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
Button as AriaButton,
|
|
6
|
+
Calendar as AriaCalendar,
|
|
7
|
+
type CalendarProps as AriaCalendarProps,
|
|
8
|
+
CalendarCell,
|
|
9
|
+
CalendarGrid,
|
|
10
|
+
CalendarGridBody,
|
|
11
|
+
CalendarGridHeader,
|
|
12
|
+
CalendarHeaderCell,
|
|
13
|
+
CalendarStateContext,
|
|
14
|
+
Heading,
|
|
15
|
+
} from 'react-aria-components'
|
|
16
|
+
import { tv, type VariantProps } from 'tailwind-variants'
|
|
17
|
+
|
|
18
|
+
import { cn } from '../../lib/cn'
|
|
19
|
+
import { Skeleton } from '../skeleton/skeleton'
|
|
20
|
+
|
|
21
|
+
/* =========================================================================
|
|
22
|
+
AvailabilityCalendar — a page-level month grid that marks which days have
|
|
23
|
+
something to offer (free times, open seats, stock). Distinct from the
|
|
24
|
+
popover calendar inside DatePicker: square cells, the available days
|
|
25
|
+
carry a tint, the selected day is the one ink fill, unavailable days are
|
|
26
|
+
quiet text, today is a dot. Wraps RAC Calendar so the grid is a real
|
|
27
|
+
`grid` with roving focus, arrow / PageUp / Home keys and announced cells.
|
|
28
|
+
|
|
29
|
+
"Available" is the consumer's call through `isDateUnavailable` — the
|
|
30
|
+
component only paints the answer. Loading paints every cell as a
|
|
31
|
+
skeleton and refuses selection until the consumer knows.
|
|
32
|
+
========================================================================= */
|
|
33
|
+
|
|
34
|
+
export const availabilityCalendarStyles = tv({
|
|
35
|
+
slots: {
|
|
36
|
+
root: 'flex flex-col',
|
|
37
|
+
header: 'flex items-center justify-between mb-3.5',
|
|
38
|
+
heading: 'font-sans text-[15px] text-ink tracking-[-0.01em]',
|
|
39
|
+
headingYear: 'ml-1 text-ink-3',
|
|
40
|
+
nav: 'flex gap-1',
|
|
41
|
+
navButton: [
|
|
42
|
+
'size-7 rounded-md inline-flex items-center justify-center cursor-pointer outline-none',
|
|
43
|
+
'text-ink-3 hovered:text-ink hovered:bg-surface-3 pressed:bg-surface-4',
|
|
44
|
+
'data-[focus-visible]:shadow-[var(--shadow-focus)]',
|
|
45
|
+
'data-[disabled]:opacity-40 data-[disabled]:cursor-default data-[disabled]:hover:bg-transparent',
|
|
46
|
+
],
|
|
47
|
+
grid: 'w-full border-separate border-spacing-1 table-fixed -m-1',
|
|
48
|
+
weekday:
|
|
49
|
+
'font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-ink-3 pb-2 text-center',
|
|
50
|
+
cell: [
|
|
51
|
+
'relative aspect-square flex items-center justify-center cursor-pointer outline-none',
|
|
52
|
+
'font-sans text-[14px] font-medium rounded-sm border border-transparent',
|
|
53
|
+
'transition-[background-color,color,border-color] duration-150',
|
|
54
|
+
// Available (the default when neither unavailable nor disabled).
|
|
55
|
+
'bg-surface-2 text-ink',
|
|
56
|
+
'data-[hovered]:bg-surface-3 data-[hovered]:border-rule-strong',
|
|
57
|
+
'data-[focus-visible]:shadow-[var(--shadow-focus)]',
|
|
58
|
+
// Selected: the one ink fill on the page.
|
|
59
|
+
'data-[selected]:bg-ink data-[selected]:text-surface data-[selected]:border-ink',
|
|
60
|
+
'data-[selected]:data-[hovered]:bg-ink data-[selected]:data-[hovered]:border-ink',
|
|
61
|
+
// Unavailable / past: quiet text, no fill, no hover.
|
|
62
|
+
'data-[unavailable]:bg-transparent data-[unavailable]:text-ink-4 data-[unavailable]:font-normal data-[unavailable]:cursor-default',
|
|
63
|
+
'data-[unavailable]:data-[hovered]:bg-transparent data-[unavailable]:data-[hovered]:border-transparent',
|
|
64
|
+
'data-[disabled]:bg-transparent data-[disabled]:text-ink-4 data-[disabled]:font-normal data-[disabled]:cursor-default',
|
|
65
|
+
'data-[disabled]:data-[hovered]:bg-transparent data-[disabled]:data-[hovered]:border-transparent',
|
|
66
|
+
// Days of the neighbouring months are blank in a month-aligned grid.
|
|
67
|
+
'data-[outside-month]:invisible',
|
|
68
|
+
],
|
|
69
|
+
dot: 'absolute bottom-1.5 left-1/2 -translate-x-1/2 size-1 rounded-full bg-current',
|
|
70
|
+
cellSkeleton: 'absolute inset-0 rounded-sm',
|
|
71
|
+
},
|
|
72
|
+
variants: {
|
|
73
|
+
isLoading: {
|
|
74
|
+
true: { cell: 'bg-transparent cursor-default' },
|
|
75
|
+
false: {},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
defaultVariants: { isLoading: false },
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
export type AvailabilityCalendarVariants = VariantProps<typeof availabilityCalendarStyles>
|
|
82
|
+
|
|
83
|
+
export type AvailabilityCalendarProps = Omit<
|
|
84
|
+
AriaCalendarProps<CalendarDate>,
|
|
85
|
+
'children' | 'value' | 'onChange' | 'className' | 'style' | 'visibleDuration'
|
|
86
|
+
> & {
|
|
87
|
+
/** The selected day; `null` = none. */
|
|
88
|
+
value: CalendarDate | null
|
|
89
|
+
onChange: (date: CalendarDate) => void
|
|
90
|
+
/** Paint every cell as a skeleton and refuse selection. */
|
|
91
|
+
isLoading?: boolean
|
|
92
|
+
/** Accessible name of the grid, e.g. "Pick a day". */
|
|
93
|
+
'aria-label': string
|
|
94
|
+
/** Weekday header style; `short` = "Sun". */
|
|
95
|
+
weekdayStyle?: 'narrow' | 'short' | 'long'
|
|
96
|
+
className?: string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function AvailabilityCalendar({
|
|
100
|
+
value,
|
|
101
|
+
onChange,
|
|
102
|
+
isLoading = false,
|
|
103
|
+
className,
|
|
104
|
+
isDateUnavailable,
|
|
105
|
+
weekdayStyle = 'short',
|
|
106
|
+
...props
|
|
107
|
+
}: AvailabilityCalendarProps) {
|
|
108
|
+
const styles = availabilityCalendarStyles({ isLoading })
|
|
109
|
+
const unavailable = isLoading ? () => true : isDateUnavailable
|
|
110
|
+
return (
|
|
111
|
+
<AriaCalendar
|
|
112
|
+
{...props}
|
|
113
|
+
value={value}
|
|
114
|
+
onChange={(next) => onChange(next)}
|
|
115
|
+
{...(unavailable ? { isDateUnavailable: unavailable } : {})}
|
|
116
|
+
visibleDuration={{ months: 1 }}
|
|
117
|
+
className={cn(styles.root(), className)}
|
|
118
|
+
>
|
|
119
|
+
<header className={styles.header()}>
|
|
120
|
+
<Heading className={styles.heading()}>
|
|
121
|
+
<MonthHeading yearClassName={styles.headingYear()} />
|
|
122
|
+
</Heading>
|
|
123
|
+
<div className={styles.nav()}>
|
|
124
|
+
<AriaButton slot="previous" aria-label="Previous month" className={styles.navButton()}>
|
|
125
|
+
<ChevronLeft className="size-4" aria-hidden="true" />
|
|
126
|
+
</AriaButton>
|
|
127
|
+
<AriaButton slot="next" aria-label="Next month" className={styles.navButton()}>
|
|
128
|
+
<ChevronRight className="size-4" aria-hidden="true" />
|
|
129
|
+
</AriaButton>
|
|
130
|
+
</div>
|
|
131
|
+
</header>
|
|
132
|
+
{/* RAC filters aria-busy off the grid; the wrapper carries it. */}
|
|
133
|
+
<div aria-busy={isLoading}>
|
|
134
|
+
<CalendarGrid weekdayStyle={weekdayStyle} className={styles.grid()}>
|
|
135
|
+
<CalendarGridHeader>
|
|
136
|
+
{(day) => <CalendarHeaderCell className={styles.weekday()}>{day}</CalendarHeaderCell>}
|
|
137
|
+
</CalendarGridHeader>
|
|
138
|
+
<CalendarGridBody>
|
|
139
|
+
{(date) => (
|
|
140
|
+
<CalendarCell date={date} className={styles.cell()}>
|
|
141
|
+
{({ formattedDate, isToday, isOutsideMonth }) =>
|
|
142
|
+
isLoading && !isOutsideMonth ? (
|
|
143
|
+
<Skeleton className={styles.cellSkeleton()} />
|
|
144
|
+
) : (
|
|
145
|
+
<>
|
|
146
|
+
{formattedDate}
|
|
147
|
+
{isToday && <span className={styles.dot()} aria-hidden="true" />}
|
|
148
|
+
</>
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
</CalendarCell>
|
|
152
|
+
)}
|
|
153
|
+
</CalendarGridBody>
|
|
154
|
+
</CalendarGrid>
|
|
155
|
+
</div>
|
|
156
|
+
</AriaCalendar>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** "October 2026" as "October" + a muted year, read off the calendar state so
|
|
161
|
+
* it follows the visible month. RAC's Heading context supplies the plain
|
|
162
|
+
* title; our children override it, the accessible name stays on the grid. */
|
|
163
|
+
function MonthHeading({ yearClassName }: { yearClassName: string }) {
|
|
164
|
+
const state = useContext(CalendarStateContext)
|
|
165
|
+
if (!state) return null
|
|
166
|
+
const start: DateValue = state.visibleRange.start
|
|
167
|
+
const date = start.toDate(state.timeZone)
|
|
168
|
+
const month = new Intl.DateTimeFormat(undefined, {
|
|
169
|
+
month: 'long',
|
|
170
|
+
timeZone: state.timeZone,
|
|
171
|
+
}).format(date)
|
|
172
|
+
const year = new Intl.DateTimeFormat(undefined, {
|
|
173
|
+
year: 'numeric',
|
|
174
|
+
timeZone: state.timeZone,
|
|
175
|
+
}).format(date)
|
|
176
|
+
return (
|
|
177
|
+
<>
|
|
178
|
+
<span className="font-medium">{month}</span>
|
|
179
|
+
<span className={yearClassName}>{year}</span>
|
|
180
|
+
</>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Contact, ConversationBrief } from '@proteos/sdk'
|
|
2
|
-
import { Pencil, Phone } from 'lucide-react'
|
|
2
|
+
import { Pencil, Phone, X } from 'lucide-react'
|
|
3
3
|
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
|
|
4
4
|
import { Button as AriaButton } from 'react-aria-components'
|
|
5
5
|
|
|
@@ -58,6 +58,16 @@ export interface ConversationBriefPreviewProps {
|
|
|
58
58
|
onEdit?: () => void
|
|
59
59
|
/** Replaces the guidance box wholesale — the host's editing surface. */
|
|
60
60
|
editor?: ReactNode
|
|
61
|
+
/** A right-hand column next to the dial rows (the records the call
|
|
62
|
+
* relates to). Owns its own scrolling. Absent ⇒ single column. */
|
|
63
|
+
aside?: ReactNode
|
|
64
|
+
/** Hides the people · channel · status line under the subject — for a
|
|
65
|
+
* host whose surrounding chrome already says all of that (the call
|
|
66
|
+
* window, where the dial row names the person). */
|
|
67
|
+
isMetaHidden?: boolean
|
|
68
|
+
/** Hides the subject line too — for a host whose window title IS the
|
|
69
|
+
* subject (the call window). Implies `isMetaHidden`. */
|
|
70
|
+
isSubjectHidden?: boolean
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
export function ConversationBriefPreview({
|
|
@@ -75,8 +85,15 @@ export function ConversationBriefPreview({
|
|
|
75
85
|
renderContent,
|
|
76
86
|
onEdit,
|
|
77
87
|
editor,
|
|
88
|
+
aside,
|
|
89
|
+
isMetaHidden = false,
|
|
90
|
+
isSubjectHidden = false,
|
|
78
91
|
}: ConversationBriefPreviewProps) {
|
|
79
92
|
const [pickedContactId, setPickedContactId] = useState('')
|
|
93
|
+
// The dial row rests as "Calling <value>"; a pencil (only when
|
|
94
|
+
// there is a choice — several people or several numbers) swaps it for
|
|
95
|
+
// the selects, which fold back once picked. Mirrors the caller-id row.
|
|
96
|
+
const [isPickingTarget, setIsPickingTarget] = useState(false)
|
|
80
97
|
const contact = contacts.find((entry) => entry.id === pickedContactId) ?? contacts[0] ?? null
|
|
81
98
|
const numbers = useMemo(() => contactPhoneNumbers(contact), [contact])
|
|
82
99
|
const [pickedNumber, setPickedNumber] = useState('')
|
|
@@ -97,31 +114,38 @@ export function ConversationBriefPreview({
|
|
|
97
114
|
|
|
98
115
|
return (
|
|
99
116
|
<div className={cn('flex flex-col gap-3', className)}>
|
|
100
|
-
|
|
101
|
-
<
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
{
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
{isSubjectHidden ? null : (
|
|
118
|
+
<div className="min-w-0">
|
|
119
|
+
<p className="truncate text-[14px] font-semibold text-ink">
|
|
120
|
+
{brief.subject || names || 'Prepared conversation'}
|
|
121
|
+
</p>
|
|
122
|
+
{isMetaHidden ? null : (
|
|
123
|
+
<p className="font-mono text-[10.5px] text-ink-4">
|
|
124
|
+
{[
|
|
125
|
+
brief.subject ? names : '',
|
|
126
|
+
brief.channel ? `Channel ${brief.channel}` : 'Any channel',
|
|
127
|
+
brief.type_key ? `Type ${brief.type_key}` : '',
|
|
128
|
+
brief.status === 'prepared'
|
|
129
|
+
? 'Prepared'
|
|
130
|
+
: brief.status === 'used'
|
|
131
|
+
? 'Call held'
|
|
132
|
+
: 'Discarded',
|
|
133
|
+
]
|
|
134
|
+
.filter(Boolean)
|
|
135
|
+
.join(' · ')}
|
|
136
|
+
</p>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
119
140
|
{editor ?? (
|
|
120
|
-
|
|
141
|
+
// A flex column of its own so a host that gives the box `flex-1`
|
|
142
|
+
// (the call window) gets a scrolling box that fills the remaining
|
|
143
|
+
// height; in an unconstrained host (a modal) it just wraps.
|
|
144
|
+
<div className="group/brief-content relative flex min-h-0 flex-1 flex-col">
|
|
121
145
|
<div
|
|
122
146
|
className={cn(
|
|
123
147
|
'rounded-md border border-rule-2 bg-surface-2/60 px-3 py-2.5',
|
|
124
|
-
'text-[13px] leading-relaxed text-ink-2 overflow-y-auto [scrollbar-width:thin]',
|
|
148
|
+
'min-h-0 text-[13px] leading-relaxed text-ink-2 overflow-y-auto [scrollbar-width:thin]',
|
|
125
149
|
renderContent ? null : 'whitespace-pre-wrap',
|
|
126
150
|
contentClassName,
|
|
127
151
|
)}
|
|
@@ -147,31 +171,136 @@ export function ConversationBriefPreview({
|
|
|
147
171
|
)}
|
|
148
172
|
{onCall ? (
|
|
149
173
|
<div className="flex flex-col gap-2 border-t border-rule-2 pt-3">
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
174
|
+
<div
|
|
175
|
+
className={cn(
|
|
176
|
+
'grid gap-3',
|
|
177
|
+
aside ? 'grid-cols-[minmax(0,1fr)_minmax(0,1fr)]' : 'grid-cols-1',
|
|
178
|
+
)}
|
|
179
|
+
>
|
|
180
|
+
<div className="flex min-w-0 flex-col gap-2">
|
|
181
|
+
{isPickingTarget && (contacts.length > 1 || numbers.length > 1) ? (
|
|
182
|
+
<div className="flex min-w-0 flex-col gap-1">
|
|
183
|
+
<p className="font-mono text-[10px] uppercase tracking-[0.08em] text-ink-4">
|
|
184
|
+
Calling
|
|
185
|
+
</p>
|
|
186
|
+
<div className="flex min-w-0 items-start gap-1.5">
|
|
187
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
|
188
|
+
{contacts.length > 1 ? (
|
|
189
|
+
<Select
|
|
190
|
+
aria-label="Who to call"
|
|
191
|
+
size="sm"
|
|
192
|
+
autoFocus
|
|
193
|
+
selectedKey={contact?.id ?? ''}
|
|
194
|
+
onSelectionChange={(key) => {
|
|
195
|
+
setPickedContactId(String(key))
|
|
196
|
+
setPickedNumber('')
|
|
197
|
+
// Fold once the pick is complete: the chosen person has
|
|
198
|
+
// at most one number, so there is nothing left to choose.
|
|
199
|
+
const picked = contacts.find((entry) => entry.id === String(key))
|
|
200
|
+
if (contactPhoneNumbers(picked).length <= 1) setIsPickingTarget(false)
|
|
201
|
+
}}
|
|
202
|
+
options={contacts.map((entry) => ({
|
|
203
|
+
id: entry.id,
|
|
204
|
+
label: entry.name || entry.id,
|
|
205
|
+
}))}
|
|
206
|
+
/>
|
|
207
|
+
) : null}
|
|
208
|
+
{numbers.length > 1 ? (
|
|
209
|
+
<Select
|
|
210
|
+
aria-label="Number to dial"
|
|
211
|
+
size="sm"
|
|
212
|
+
{...(contacts.length > 1 ? {} : { autoFocus: true })}
|
|
213
|
+
selectedKey={phoneNumber}
|
|
214
|
+
onSelectionChange={(key) => {
|
|
215
|
+
if (key !== null) setPickedNumber(String(key))
|
|
216
|
+
setIsPickingTarget(false)
|
|
217
|
+
}}
|
|
218
|
+
options={numbers.map((number) => ({ id: number, label: number }))}
|
|
219
|
+
/>
|
|
220
|
+
) : null}
|
|
221
|
+
</div>
|
|
222
|
+
<Button
|
|
223
|
+
variant="ghost"
|
|
224
|
+
size="icon-sm"
|
|
225
|
+
aria-label="Keep the current number"
|
|
226
|
+
onPress={() => setIsPickingTarget(false)}
|
|
227
|
+
>
|
|
228
|
+
<X aria-hidden />
|
|
229
|
+
</Button>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
) : (
|
|
233
|
+
<div className="group/dial flex min-w-0 flex-col gap-1">
|
|
234
|
+
<p className="font-mono text-[10px] uppercase tracking-[0.08em] text-ink-4">
|
|
235
|
+
Calling
|
|
236
|
+
</p>
|
|
237
|
+
<div className="flex min-h-9 min-w-0 items-center gap-2">
|
|
238
|
+
{(() => {
|
|
239
|
+
const hasChoice = contacts.length > 1 || numbers.length > 1
|
|
240
|
+
const valueText = phoneNumber
|
|
241
|
+
? contacts.length > 1
|
|
242
|
+
? `${contact?.name || contact?.id} · ${phoneNumber}`
|
|
243
|
+
: phoneNumber
|
|
244
|
+
: contact
|
|
245
|
+
? `${contacts.length > 1 ? `${contact.name || contact.id} · ` : ''}no phone number on file`
|
|
246
|
+
: 'No contact to call.'
|
|
247
|
+
const valueClassName = cn(
|
|
248
|
+
'min-w-0 truncate text-[12px] tabular-nums',
|
|
249
|
+
phoneNumber ? 'text-ink-2' : 'text-warning',
|
|
250
|
+
)
|
|
251
|
+
// Same weight as a related-record row: a small muted
|
|
252
|
+
// glyph in front, 12px text, mid-row.
|
|
253
|
+
const icon = (
|
|
254
|
+
<Phone
|
|
255
|
+
className={cn(
|
|
256
|
+
'size-3 shrink-0',
|
|
257
|
+
phoneNumber ? 'text-ink-4' : 'text-warning',
|
|
258
|
+
)}
|
|
259
|
+
aria-hidden
|
|
260
|
+
/>
|
|
261
|
+
)
|
|
262
|
+
// With something to choose, the value itself opens the pick
|
|
263
|
+
// (the pencil is the hint, not the only handle).
|
|
264
|
+
return hasChoice ? (
|
|
265
|
+
<AriaButton
|
|
266
|
+
className={cn(
|
|
267
|
+
'flex min-w-0 cursor-pointer items-center gap-2 rounded-sm text-left outline-none',
|
|
268
|
+
'hover:text-ink focus-visible:shadow-[var(--shadow-focus)]',
|
|
269
|
+
)}
|
|
270
|
+
onPress={() => setIsPickingTarget(true)}
|
|
271
|
+
>
|
|
272
|
+
{icon}
|
|
273
|
+
<span className={valueClassName} title={phoneNumber}>
|
|
274
|
+
{valueText}
|
|
275
|
+
</span>
|
|
276
|
+
</AriaButton>
|
|
277
|
+
) : (
|
|
278
|
+
<>
|
|
279
|
+
{icon}
|
|
280
|
+
<span className={valueClassName} title={phoneNumber}>
|
|
281
|
+
{valueText}
|
|
282
|
+
</span>
|
|
283
|
+
</>
|
|
284
|
+
)
|
|
285
|
+
})()}
|
|
286
|
+
{contacts.length > 1 || numbers.length > 1 ? (
|
|
287
|
+
<Button
|
|
288
|
+
variant="ghost"
|
|
289
|
+
size="icon-sm"
|
|
290
|
+
aria-label="Change the number to dial"
|
|
291
|
+
className="opacity-0 transition-opacity group-hover/dial:opacity-100 focus-visible:opacity-100"
|
|
292
|
+
onPress={() => setIsPickingTarget(true)}
|
|
293
|
+
>
|
|
294
|
+
<Pencil aria-hidden />
|
|
295
|
+
</Button>
|
|
296
|
+
) : null}
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
)}
|
|
300
|
+
{controls}
|
|
301
|
+
</div>
|
|
302
|
+
{aside ? <div className="min-h-0 min-w-0">{aside}</div> : null}
|
|
303
|
+
</div>
|
|
175
304
|
{callDisabledReason ? (
|
|
176
305
|
<p className="text-[12px] text-warning">{callDisabledReason}</p>
|
|
177
306
|
) : null}
|