@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteos/ui",
3
- "version": "0.61.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.61.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
+ }
@@ -0,0 +1,5 @@
1
+ export type {
2
+ AvailabilityCalendarProps,
3
+ AvailabilityCalendarVariants,
4
+ } from './availability-calendar'
5
+ export { AvailabilityCalendar, availabilityCalendarStyles } from './availability-calendar'
@@ -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
- <div className="min-w-0">
101
- <p className="truncate text-[14px] font-semibold text-ink">
102
- {brief.subject || names || 'Prepared conversation'}
103
- </p>
104
- <p className="font-mono text-[10.5px] text-ink-4">
105
- {[
106
- brief.subject ? names : '',
107
- brief.channel ? `Channel ${brief.channel}` : 'Any channel',
108
- brief.type_key ? `Type ${brief.type_key}` : '',
109
- brief.status === 'prepared'
110
- ? 'Prepared'
111
- : brief.status === 'used'
112
- ? 'Call held'
113
- : 'Discarded',
114
- ]
115
- .filter(Boolean)
116
- .join(' · ')}
117
- </p>
118
- </div>
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
- <div className="group/brief-content relative">
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
- {contacts.length > 1 ? (
151
- <Select
152
- aria-label="Who to call"
153
- selectedKey={contact?.id ?? ''}
154
- onSelectionChange={(key) => {
155
- setPickedContactId(String(key))
156
- setPickedNumber('')
157
- }}
158
- options={contacts.map((entry) => ({ id: entry.id, label: entry.name || entry.id }))}
159
- />
160
- ) : null}
161
- {numbers.length > 1 ? (
162
- <Select
163
- aria-label="Number to call"
164
- selectedKey={phoneNumber}
165
- onSelectionChange={(key) => setPickedNumber(String(key))}
166
- options={numbers.map((number) => ({ id: number, label: number }))}
167
- />
168
- ) : (
169
- <p className="font-mono text-[12px] text-ink-3">
170
- {phoneNumber ||
171
- (contact ? 'This contact has no phone number on file.' : 'No contact to call.')}
172
- </p>
173
- )}
174
- {controls}
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}