@weft-js/warp 0.0.0-stage → 0.1.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 (180) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +55 -2
  3. package/package.json +43 -4
  4. package/plugin.d.mts +30 -0
  5. package/plugin.mjs +358 -0
  6. package/src/Accordion.tsx +58 -0
  7. package/src/Alert.tsx +25 -0
  8. package/src/Badge.tsx +17 -0
  9. package/src/Button.tsx +100 -0
  10. package/src/Calendar.tsx +162 -0
  11. package/src/Canvas.tsx +110 -0
  12. package/src/Card.tsx +29 -0
  13. package/src/Chart.tsx +133 -0
  14. package/src/Charts.tsx +69 -0
  15. package/src/Chip.tsx +27 -0
  16. package/src/Choice.tsx +105 -0
  17. package/src/CodeBlock.tsx +65 -0
  18. package/src/CodeInput.tsx +130 -0
  19. package/src/Combobox.tsx +113 -0
  20. package/src/CommandPalette.tsx +348 -0
  21. package/src/DataGrid.tsx +561 -0
  22. package/src/DateField.tsx +221 -0
  23. package/src/Dither.tsx +129 -0
  24. package/src/Drawer.tsx +41 -0
  25. package/src/DurationField.tsx +98 -0
  26. package/src/Feed.tsx +74 -0
  27. package/src/Field.tsx +147 -0
  28. package/src/Icon.tsx +8 -0
  29. package/src/Kbd.tsx +6 -0
  30. package/src/KeyValueEditor.tsx +114 -0
  31. package/src/Layout.tsx +109 -0
  32. package/src/Markdown.tsx +63 -0
  33. package/src/MentionsInput.tsx +150 -0
  34. package/src/Menu.tsx +111 -0
  35. package/src/Modal.tsx +40 -0
  36. package/src/MonthPicker.tsx +190 -0
  37. package/src/Nav.tsx +20 -0
  38. package/src/NumberField.tsx +78 -0
  39. package/src/Pagination.tsx +122 -0
  40. package/src/PaymentCard.tsx +63 -0
  41. package/src/Picker.tsx +120 -0
  42. package/src/PlaceField.tsx +126 -0
  43. package/src/Popover.tsx +34 -0
  44. package/src/Progress.tsx +26 -0
  45. package/src/RecurrenceField.tsx +126 -0
  46. package/src/RichSelect.tsx +243 -0
  47. package/src/RichTextEditor.tsx +207 -0
  48. package/src/ScrollGrid.tsx +190 -0
  49. package/src/Segments.tsx +70 -0
  50. package/src/Select.tsx +192 -0
  51. package/src/Shape.tsx +20 -0
  52. package/src/Shell.tsx +308 -0
  53. package/src/SideNav.tsx +147 -0
  54. package/src/SignaturePad.tsx +120 -0
  55. package/src/Skeleton.tsx +14 -0
  56. package/src/Slider.tsx +42 -0
  57. package/src/Spinner.tsx +7 -0
  58. package/src/Table.tsx +528 -0
  59. package/src/Tabs.tsx +67 -0
  60. package/src/Tag.tsx +15 -0
  61. package/src/Ticker.tsx +36 -0
  62. package/src/TimeField.tsx +237 -0
  63. package/src/Toast.tsx +56 -0
  64. package/src/Toggle.tsx +42 -0
  65. package/src/Tooltip.tsx +32 -0
  66. package/src/Type.tsx +46 -0
  67. package/src/VirtualList.tsx +386 -0
  68. package/src/admin/ApiKeys.tsx +136 -0
  69. package/src/admin/Audit.tsx +93 -0
  70. package/src/admin/Billing.tsx +123 -0
  71. package/src/admin/Cart.tsx +96 -0
  72. package/src/admin/Checkout.tsx +300 -0
  73. package/src/admin/CsvImport.tsx +174 -0
  74. package/src/admin/Danger.tsx +110 -0
  75. package/src/admin/Integrations.tsx +200 -0
  76. package/src/admin/InviteDialog.tsx +64 -0
  77. package/src/admin/Members.tsx +186 -0
  78. package/src/admin/Order.tsx +63 -0
  79. package/src/admin/PlainTable.tsx +22 -0
  80. package/src/admin/Plans.tsx +167 -0
  81. package/src/admin/SaveBar.tsx +112 -0
  82. package/src/admin/Secret.tsx +37 -0
  83. package/src/admin/Settings.tsx +76 -0
  84. package/src/admin/StepBar.tsx +35 -0
  85. package/src/admin/TagsInput.tsx +79 -0
  86. package/src/admin/atoms.tsx +33 -0
  87. package/src/admin/index.ts +16 -0
  88. package/src/agent/Actions.tsx +85 -0
  89. package/src/agent/AgentPlan.tsx +49 -0
  90. package/src/agent/ArtifactPanel.tsx +80 -0
  91. package/src/agent/Assistant.tsx +96 -0
  92. package/src/agent/Checkpoint.tsx +14 -0
  93. package/src/agent/Cite.tsx +39 -0
  94. package/src/agent/ContextMeter.tsx +31 -0
  95. package/src/agent/Conversation.tsx +34 -0
  96. package/src/agent/Message.tsx +53 -0
  97. package/src/agent/ModelPicker.tsx +65 -0
  98. package/src/agent/Prompt.tsx +197 -0
  99. package/src/agent/Prose.tsx +54 -0
  100. package/src/agent/Question.tsx +52 -0
  101. package/src/agent/Reasoning.tsx +19 -0
  102. package/src/agent/Shimmer.tsx +7 -0
  103. package/src/agent/Sources.tsx +23 -0
  104. package/src/agent/SpeechInput.tsx +102 -0
  105. package/src/agent/Suggestions.tsx +22 -0
  106. package/src/agent/ToolCall.tsx +91 -0
  107. package/src/agent/TraceViewer.tsx +55 -0
  108. package/src/agent/bits.tsx +40 -0
  109. package/src/agent/index.ts +22 -0
  110. package/src/bind.ts +42 -0
  111. package/src/collab/AutoTrack.tsx +42 -0
  112. package/src/collab/Comments.tsx +94 -0
  113. package/src/collab/Composer.tsx +173 -0
  114. package/src/collab/ConversationList.tsx +73 -0
  115. package/src/collab/Cursors.tsx +35 -0
  116. package/src/collab/DataIntegrity.tsx +45 -0
  117. package/src/collab/Kanban.tsx +185 -0
  118. package/src/collab/Lock.tsx +23 -0
  119. package/src/collab/PresenceStack.tsx +67 -0
  120. package/src/collab/ShareDialog.tsx +118 -0
  121. package/src/collab/Status.tsx +42 -0
  122. package/src/collab/SyncedForm.tsx +19 -0
  123. package/src/collab/Thread.tsx +273 -0
  124. package/src/collab/VersionHistory.tsx +49 -0
  125. package/src/collab/ViewportRail.tsx +49 -0
  126. package/src/collab/index.ts +16 -0
  127. package/src/collab/peer.tsx +16 -0
  128. package/src/fields.ts +5 -0
  129. package/src/flow/AuthScreen.tsx +40 -0
  130. package/src/flow/AuthSteps.tsx +120 -0
  131. package/src/flow/Checklist.tsx +84 -0
  132. package/src/flow/ConnBanner.tsx +147 -0
  133. package/src/flow/Flow.tsx +63 -0
  134. package/src/flow/MfaSetup.tsx +103 -0
  135. package/src/flow/Otp.tsx +114 -0
  136. package/src/flow/ReauthDialog.tsx +47 -0
  137. package/src/flow/ShortcutsOverlay.tsx +60 -0
  138. package/src/flow/SignGate.tsx +98 -0
  139. package/src/flow/SignIn.tsx +80 -0
  140. package/src/flow/SignUp.tsx +71 -0
  141. package/src/flow/Sso.tsx +38 -0
  142. package/src/flow/SystemPage.tsx +247 -0
  143. package/src/flow/Tour.tsx +108 -0
  144. package/src/flow/index.ts +17 -0
  145. package/src/flow/kit.tsx +170 -0
  146. package/src/index.ts +74 -0
  147. package/src/layout.css +4 -0
  148. package/src/links.css +4 -0
  149. package/src/live/ActivityFeed.tsx +49 -0
  150. package/src/live/Bell.tsx +62 -0
  151. package/src/live/Connection.tsx +49 -0
  152. package/src/live/Inbox.tsx +220 -0
  153. package/src/live/MeetingRoom.tsx +298 -0
  154. package/src/live/NotificationPreferences.tsx +193 -0
  155. package/src/live/Prejoin.tsx +140 -0
  156. package/src/live/PushSoftAsk.tsx +37 -0
  157. package/src/live/TransportPicker.tsx +163 -0
  158. package/src/live/index.ts +12 -0
  159. package/src/live/notify.tsx +16 -0
  160. package/src/live/shared.tsx +39 -0
  161. package/src/parts.tsx +93 -0
  162. package/src/schedule/Agenda.tsx +173 -0
  163. package/src/schedule/AvailabilityEditor.tsx +164 -0
  164. package/src/schedule/BookingCalendar.tsx +132 -0
  165. package/src/schedule/Calendar.tsx +746 -0
  166. package/src/schedule/EventEditor.tsx +87 -0
  167. package/src/schedule/EventPopover.tsx +65 -0
  168. package/src/schedule/MeetingPlanner.tsx +214 -0
  169. package/src/schedule/MoveSheet.tsx +78 -0
  170. package/src/schedule/YearView.tsx +163 -0
  171. package/src/schedule/bits.tsx +51 -0
  172. package/src/schedule/index.ts +10 -0
  173. package/src/skeleton-css.ts +4 -0
  174. package/src/styles.ts +10 -0
  175. package/src/tokens.d.ts +2 -0
  176. package/src/type.css +15 -0
  177. package/src/ui.ts +26 -0
  178. package/src/use-ui.ts +10 -0
  179. package/tailwind.css +38 -0
  180. package/tokens/color.mjs +66 -0
@@ -0,0 +1,220 @@
1
+ import { announce, arrivals, feed, filterNotes, groupByDay, indicator, nextIndex, readOnView, uid, unreadCount, type InboxTab } from '@weft-js/warp-core'
2
+ import { derived, state, untrack } from '@weft-js/core'
3
+ import { Avatar, type LiveState } from '../parts'
4
+ import { Badge } from '../Badge'
5
+ import { Button } from '../Button'
6
+ import { Skeleton } from '../Skeleton'
7
+ import { toast } from '../Toast'
8
+ import { use } from '@weft-js/warp-core/weft'
9
+ import { avatarTint, G, val, type GlyphName } from './shared'
10
+ import '../styles'
11
+ import '@weft-js/warp-core/styles/components/tabs.css'
12
+ import '@weft-js/warp-core/styles/components/link.css'
13
+ import '@weft-js/warp-core/styles/app/inbox.css'
14
+ import '@weft-js/warp-core/styles/app/notification.css'
15
+
16
+ /** What a notification is about, which picks its glyph. */
17
+ export type NoteKind = 'mention' | 'comment' | 'assign' | 'approve' | 'system' | 'invite'
18
+ /** A button on a notification. */
19
+ export interface NoteAction { id: string; label: string; emphasis?: 'solid' | 'outline' }
20
+ /** One notification. */
21
+ export interface Note {
22
+ id: string
23
+ actor: string
24
+ kind: NoteKind
25
+ verb: string
26
+ object?: string | undefined
27
+ detail?: string | undefined
28
+ /** Already formatted: the caller owns the clock. */
29
+ time: string
30
+ /** "Today", "Yesterday": see `dayLabel`. */
31
+ day?: string | undefined
32
+ source?: string | undefined
33
+ quote?: string | undefined
34
+ read?: boolean | undefined
35
+ actions?: NoteAction[] | undefined
36
+ }
37
+
38
+ const KIND: Record<NoteKind, [GlyphName, string]> = { mention: ['at', 'accent'], comment: ['comment', 'info'], assign: ['flag', 'caution'], approve: ['check', 'positive'], system: ['alert', 'critical'], invite: ['cal', 'accent'] }
39
+ const TABS: Record<InboxTab, string> = { all: 'All', unread: 'Unread', mentions: 'Mentions' }
40
+ const sentence = (n: Note) => [n.actor, n.verb, n.object, n.detail].filter(Boolean).join(' ')
41
+ const label = (n: Note) => `${sentence(n)}${n.read ? '' : ', unread'}, ${n.time}`
42
+
43
+ /** Props for Inbox. */
44
+ export interface InboxProps {
45
+ /** Newest first. Undefined while the first load runs: skeletons, once. */
46
+ items: Note[] | undefined | { value: Note[] | undefined } | (() => Note[] | undefined)
47
+ /** The connection's state, shown in the foot as "Up to date" or "Connecting…". */
48
+ live?: (LiveState | (() => LiveState)) | undefined
49
+ /** The filter tabs, in order. Default all, unread and mentions. */
50
+ tabs?: InboxTab[] | undefined
51
+ title?: string | undefined
52
+ variant?: 'popover' | undefined
53
+ /** Is the inbox on screen? Rows are read after 2 s visible only while it is. */
54
+ open?: boolean | undefined
55
+ /** Updates waiting to sync (offline, reconnecting). */
56
+ queued?: number | undefined
57
+ viewAll?: string | undefined
58
+ /** Adds a settings button to the header, and is called when it is pressed. */
59
+ settings?: (() => void) | undefined
60
+ /** Called with a row's id once it has been seen or opened. */
61
+ onRead?: ((id: string) => void) | undefined
62
+ /** Called when Mark all as read is pressed. */
63
+ onReadAll?: (() => void) | undefined
64
+ /** Called with a row's id when it is dismissed. */
65
+ onDismiss?: ((id: string) => void) | undefined
66
+ /** Undo a dismiss: put the row back. */
67
+ onRestore?: ((note: Note) => void) | undefined
68
+ onAction?: ((note: Note, action: string) => void) | undefined
69
+ /** The row is a link to its object. */
70
+ onOpen?: ((note: Note) => void) | undefined
71
+ }
72
+
73
+ /**
74
+ * Inbox (07 §4.1, §4.2, §4.5): tabs filter one panel; a role=feed list grouped by day, rendered by key, so a read or an
75
+ * approval patches the row in place. New rows arrive; with the list scrolled, an "n new" pill shows instead of moving
76
+ * it. A dismissed row slides out and Undo is in the toast. Needs a mounted Toaster for Undo and announcements.
77
+ */
78
+ export function Inbox({ items, live, tabs = ['all', 'unread', 'mentions'], title = 'Notifications', variant, open = true, queued = 0, viewAll, settings, onRead, onReadAll, onDismiss, onRestore, onAction, onOpen }: InboxProps) {
79
+ const id = uid('inbox')
80
+ const tab = state<InboxTab>(untrack(() => tabs)[0]!)
81
+ const data = derived(() => val<Note[] | undefined>(items))
82
+ const all = derived(() => data.value ?? [])
83
+ const leaving = state<string[]>([])
84
+ const fresh = state(0)
85
+ const focused = state<string | undefined>(undefined)
86
+ const shown = derived(() => filterNotes(all.value, tab.value))
87
+ const unread = derived(() => unreadCount(all.value))
88
+ const days = derived(() => groupByDay(shown.value))
89
+ const stop = derived(() => (shown.value.some(n => n.id === focused.value) ? focused.value : shown.value[0]?.id))
90
+ const tabId = (t: InboxTab) => `${id}-tab-${t}`
91
+ let list: HTMLDivElement | undefined
92
+ let tablist: HTMLDivElement | undefined
93
+ let root: HTMLElement | undefined
94
+ const doc = () => root!.ownerDocument
95
+
96
+ use(indicator, () => tablist, () => ({ selected: '[aria-selected="true"]', x: '--_ix', w: '--_iw', at: tab.value }))
97
+ use(feed, () => list, () => ({ items: '[data-scope="notification"]' }))
98
+ use(readOnView, () => list, () => ({ items: '[data-scope="notification"]', active: !!(open as unknown), onRead: (rid: string) => onRead?.(rid) }))
99
+ use(arrivals, () => list, () => ({
100
+ items: '[data-scope="notification"]',
101
+ ids: all.value.map(n => n.id),
102
+ onArrive: (ids: string[]) => { if (list && list.scrollTop > 0) fresh.value += ids.length },
103
+ }))
104
+
105
+ const pick = (t: InboxTab) => { tab.value = t; fresh.value = 0 }
106
+ const onTabKey = (e: KeyboardEvent) => {
107
+ const at = tabs.indexOf(tab.value)
108
+ const rtl = getComputedStyle(e.currentTarget as Element).direction === 'rtl'
109
+ const to = nextIndex(e.key, at, tabs.length, { rtl })
110
+ if (to < 0) return
111
+ e.preventDefault()
112
+ pick(tabs[to]!)
113
+ tablist!.querySelectorAll<HTMLElement>('[role="tab"]')[to]?.focus()
114
+ }
115
+ const dismiss = (n: Note, row: HTMLElement) => {
116
+ const rest = [...list!.querySelectorAll<HTMLElement>('[data-scope="notification"]')].filter(r => r !== row && r.dataset.state !== 'dismissed')
117
+ const at = [...list!.querySelectorAll('[data-scope="notification"]')].indexOf(row)
118
+ leaving.value = [...leaving.value, n.id]
119
+ ;(rest[at] ?? rest[at - 1])?.focus()
120
+ setTimeout(() => {
121
+ onDismiss?.(n.id)
122
+ leaving.value = leaving.value.filter(x => x !== n.id)
123
+ }, 240)
124
+ toast.show({ title: 'Notification dismissed', description: sentence(n), action: { label: 'Undo', onClick: () => onRestore?.(n) } })
125
+ }
126
+ const readAll = () => { onReadAll?.(); announce(doc(), 'All notifications marked as read') }
127
+ const open1 = (n: Note) => { if (!n.read) onRead?.(n.id); onOpen?.(n) }
128
+ const onRowKey = (e: KeyboardEvent, n: Note) => {
129
+ if (e.target !== e.currentTarget) return
130
+ if (e.key === 'Enter') { e.preventDefault(); open1(n) }
131
+ if (e.key === 'Delete') { e.preventDefault(); dismiss(n, e.currentTarget as HTMLElement) }
132
+ }
133
+ const pressRow = (e: MouseEvent, n: Note) => { if (!(e.target as Element).closest?.('button, a')) open1(n) }
134
+ const toTop = () => { list?.scrollTo?.({ top: 0 }); if (list) list.scrollTop = 0; fresh.value = 0 }
135
+ const onScroll = () => { if (list!.scrollTop <= 0) fresh.value = 0 }
136
+ const sync = derived(() => {
137
+ const q = val<number>(queued)
138
+ if (q) return `${q} update${q > 1 ? 's' : ''} waiting to sync`
139
+ return (val<LiveState | undefined>(live) ?? 'live') === 'connecting' ? 'Connecting…' : 'Up to date'
140
+ })
141
+ const page = derived(() => viewAll)
142
+
143
+ return (
144
+ <section ref={n => (root = n)} data-scope="inbox" data-part="root" data-variant={variant} aria-labelledby={`${id}-t`}>
145
+ <div data-part="header">
146
+ <h3 id={`${id}-t`}>{title}</h3>
147
+ <Button emphasis="ghost" tone="neutral" size="sm" onClick={readAll}>Mark all as read</Button>
148
+ {settings && <Button emphasis="ghost" tone="neutral" size="sm" data-icon-only="" aria-label="Notification settings" onClick={settings}><G name="more" /></Button>}
149
+ </div>
150
+ <div data-scope="tabs" data-part="root">
151
+ <div ref={n => (tablist = n)} data-part="list" role="tablist" aria-label="Filter" onKeyDown={onTabKey}>
152
+ {tabs.map(t => (
153
+ <button key={t} data-part="trigger" role="tab" type="button" id={tabId(t)} aria-controls={`${id}-panel`} aria-selected={t === tab.value ? 'true' : 'false'} tabindex={t === tab.value ? undefined : '-1'} onClick={() => pick(t)}>
154
+ {TABS[t]}
155
+ {t === 'unread' && <> <Badge count>{unread.value}</Badge></>}
156
+ </button>
157
+ ))}
158
+ <span data-part="indicator" aria-hidden="true" data-instant=""></span>
159
+ </div>
160
+ </div>
161
+ {fresh.value > 0 && <Button size="sm" data-part="new-pill" onClick={toTop}>{fresh.value} new</Button>}
162
+ <div role="tabpanel" id={`${id}-panel`} aria-labelledby={tabId(tab.value)} data-part="panel">
163
+ <div ref={n => (list = n)} data-part="list" role="feed" aria-label={title} aria-busy={data.value === undefined ? 'true' : 'false'} tabindex={shown.value.length ? undefined : '0'} onScroll={onScroll}>
164
+ {data.value === undefined && <div data-part="group" aria-hidden="true"><Skeleton lines={3} /></div>}
165
+ {days.value.map(g => (
166
+ <div key={g.day} role="none">
167
+ <div data-part="group" aria-hidden="true">{g.day}</div>
168
+ {g.rows.map(n => <Row key={n.id} n={n} at={shown.value.indexOf(n) + 1} size={shown.value.length} leaving={leaving.value.includes(n.id)} stop={stop.value === n.id} onKey={onRowKey} onPress={pressRow} onFocus={() => (focused.value = n.id)} onDismiss={dismiss} onAction={(x: Note, a: string) => onAction?.(x, a)} />)}
169
+ </div>
170
+ ))}
171
+ </div>
172
+ {data.value !== undefined && shown.value.length === 0 && <div data-scope="empty-state"><p data-part="title">You're all caught up</p><p data-part="description">New mentions, invites and approvals show up here.</p></div>}
173
+ </div>
174
+ <div data-part="footer">
175
+ <span>{sync.value}</span>
176
+ {page.value && <a href={page.value} data-scope="link">View all</a>}
177
+ </div>
178
+ </section>
179
+ )
180
+ }
181
+
182
+ function Row({ n, at, size, leaving, stop, onKey, onPress, onFocus, onDismiss, onAction }: { n: Note; at: number; size: number; leaving: boolean; stop: boolean; onKey: (e: KeyboardEvent, n: Note) => void; onPress: (e: MouseEvent, n: Note) => void; onFocus: () => void; onDismiss: (n: Note, row: HTMLElement) => void; onAction: (n: Note, id: string) => void }) {
183
+ const icon = derived(() => KIND[n.kind] ?? KIND.system)
184
+ const name = derived(() => label(n))
185
+ const glyph = derived(() => icon.value[0])
186
+ return (
187
+ <article
188
+ data-scope="notification"
189
+ data-sweep=""
190
+
191
+ data-id={n.id}
192
+ data-read={n.read ? '' : undefined}
193
+ data-state={leaving ? 'dismissed' : undefined}
194
+ tabindex={stop ? '0' : '-1'}
195
+ data-initial={stop ? '' : undefined}
196
+ aria-label={name.value}
197
+ aria-posinset={at}
198
+ aria-setsize={size}
199
+ onKeyDown={(e: KeyboardEvent) => onKey(e, n)}
200
+ onClick={(e: MouseEvent) => onPress(e, n)}
201
+ onFocusIn={onFocus}
202
+ >
203
+ <span data-part="sweep-base" aria-hidden="true"></span><span data-part="sweep" aria-hidden="true"></span>
204
+ <span data-part="icon">
205
+ <Avatar name={n.actor} style={avatarTint(n.actor)} />
206
+ <span data-part="kind" data-tone={icon.value[1]}><G name={glyph.value} /></span>
207
+ </span>
208
+ <p data-part="text"><b>{n.actor}</b> {n.verb}{n.object ? ' ' : ''}{n.object && <b>{n.object}</b>}{n.detail ? ` ${n.detail}` : ''}</p>
209
+ <span data-part="unread" aria-hidden="true"></span>
210
+ {n.quote && <div data-part="quote">{n.quote}</div>}
211
+ <span data-part="meta">{n.time}{n.source ? ` · ${n.source}` : ''}</span>
212
+ <Button emphasis="ghost" tone="neutral" size="sm" data-icon-only="" data-part="dismiss" aria-label={`Dismiss: ${n.actor} ${n.verb}`} tabindex="-1" onClick={(e: MouseEvent) => { e.stopPropagation(); onDismiss(n, (e.currentTarget as HTMLElement).closest('article')!) }}><G name="x" /></Button>
213
+ {n.actions?.length ? (
214
+ <div data-part="actions">
215
+ {n.actions.map((a: NoteAction) => <Button key={a.id} size="sm" emphasis={a.emphasis ?? 'solid'} tone={a.emphasis === 'outline' ? 'neutral' : undefined} onClick={() => onAction(n, a.id)}>{a.label}</Button>)}
216
+ </div>
217
+ ) : null}
218
+ </article>
219
+ )
220
+ }
@@ -0,0 +1,298 @@
1
+ import { ChatIcon, HandIcon, ScreenIcon, SmileIcon, UsersIcon } from '@weft-js/warp-icons'
2
+ import { announce, floatReaction, galleryCols, hueOf, newHands, pauseOffscreen, pip, speakerFollow, speakingOut, tileLabel, toolbar, type LiveState, type ParticipantLike, type PipCorner } from '@weft-js/warp-core'
3
+ import { derived, onCleanup, onMount, state, untrack, watch, type State } from '@weft-js/core'
4
+ import { Avatar, LiveStatus } from '../parts'
5
+ import { Button } from '../Button'
6
+ import { Segmented } from '../Choice'
7
+ import { use } from '@weft-js/warp-core/weft'
8
+ import { avatarTint, G, held, val, type GlyphName } from './shared'
9
+ import '../styles'
10
+ import '@weft-js/warp-core/styles/app/base.css'
11
+ import '@weft-js/warp-core/styles/app/room.css'
12
+ import '@weft-js/warp-core/styles/app/tile.css'
13
+ import '@weft-js/warp-core/styles/app/pip.css'
14
+ import '@weft-js/warp-core/styles/app/call-controls.css'
15
+
16
+ /** How the room arranges its tiles. */
17
+ export type MeetingLayout = 'gallery' | 'speaker' | 'presentation' | 'board'
18
+ /** Someone in the room. */
19
+ export interface Participant extends ParticipantLike { isCamOn?: boolean }
20
+ type Video = (p: Participant) => unknown
21
+
22
+ const LAYOUTS: [MeetingLayout, string, GlyphName][] = [['gallery', 'Gallery', 'grid'], ['speaker', 'Speaker', 'speaker'], ['presentation', 'Presentation', 'present'], ['board', 'Board', 'board']]
23
+
24
+ /** Built once a mount: an option's label is a node, and a read of the prop would otherwise make it again. */
25
+ function LayoutPicker(props: { value: MeetingLayout; onChange: (l: MeetingLayout) => void }) {
26
+ const options = LAYOUTS.map(l => ({ value: l[0], label: <><G name={l[2]} />{l[1]}</> }))
27
+ return <Segmented label="Layout" size="sm" options={options} {...props} />
28
+ }
29
+
30
+ /**
31
+ * A tile (07 §3.1): video slot or avatar, name chip with level bars, flags with words in the name. The speaking ring is
32
+ * the sweep: `data-speaking`, then `data-speaking-out` until it has left. The bars read `--level`: set it on the tile
33
+ * (`[data-pid]`) at whatever rate the audio arrives; nothing re-renders.
34
+ */
35
+ export function Tile({ p, self, video, role = 'group' }: { p: Participant; self?: boolean; video?: Video | undefined; role?: string }) {
36
+ let el: HTMLDivElement | undefined
37
+ use(speakingOut, () => el, () => !!p.isSpeaking)
38
+ const label = derived(() => tileLabel(self ? { ...p, name: `${p.name}, you` } : p))
39
+ const slot = untrack(() => video)
40
+ const camera = derived(() => !!slot && p.isCamOn !== false)
41
+ return (
42
+ <div
43
+ ref={n => (el = n)}
44
+ data-scope="tile"
45
+ role={role}
46
+ tabindex="-1"
47
+ aria-label={label.value}
48
+ data-pid={p.id}
49
+ data-speaking={p.isSpeaking ? '' : undefined}
50
+ data-hand={p.handRaised ? '' : undefined}
51
+ data-network={p.network === 'poor' ? 'poor' : undefined}
52
+ data-cam={p.isCamOn === false ? 'off' : undefined}
53
+ style={`--_hue:var(--warp-color-data-${hueOf(p.id)})`}
54
+ >
55
+ {camera.value ? <div data-part="video">{slot!(p)}</div> : <Avatar name={p.name} size="xl" style={avatarTint(p.id)} />}
56
+ <span data-part="flags">
57
+ {p.handRaised && <span data-part="flag" data-kind="hand" role="img" aria-label="Hand raised"><G name="hand" /></span>}
58
+ {p.network === 'poor' && <span data-part="flag" data-tone="caution" role="img" aria-label="Poor connection"><G name="signal" /></span>}
59
+ {p.isMuted && <span data-part="flag" data-tone="critical" role="img" aria-label="Muted"><G name="micoff" /></span>}
60
+ </span>
61
+ <span data-part="name">
62
+ {!p.isMuted && <span data-part="level"><i></i><i></i><i></i></span>}
63
+ {self ? 'You' : p.name}
64
+ {p.role ? <small> · {p.role}</small> : null}
65
+ </span>
66
+ </div>
67
+ )
68
+ }
69
+
70
+ /** Props for ControlBar. */
71
+ export interface ControlBarProps {
72
+ mic: boolean
73
+ camera: boolean
74
+ sharing?: boolean | undefined
75
+ hand?: boolean | undefined
76
+ side?: 'people' | 'chat' | null | undefined
77
+ /** Icons only, no Chat or People (they go in More), and Leave icon-only (07 §3.3). */
78
+ compact?: boolean | undefined
79
+ onToggle: (control: 'mic' | 'camera' | 'share' | 'hand' | 'react' | 'chat' | 'people' | 'more') => void
80
+ onLeave?: (() => void) | undefined
81
+ /** Stands in for the control's name while a control has an open menu of its own. */
82
+ more?: string | undefined
83
+ }
84
+
85
+ // the visible label is inside the name (WCAG 2.5.3), and the name stays when the label hides
86
+ function Control({ c, name, icon, pressed, device, label, first, onToggle }: { c: Parameters<ControlBarProps['onToggle']>[0]; name: string; icon: Parameters<typeof G>[0]['name']; pressed?: boolean | undefined; device?: boolean; label: string; first?: boolean; onToggle: ControlBarProps['onToggle'] }) {
87
+ return (
88
+ <button type="button" data-part="control" data-kind={device ? 'device' : undefined} aria-pressed={pressed ? 'true' : 'false'} aria-label={name} tabindex={first ? '0' : '-1'} onClick={() => onToggle(c)}>
89
+ <G name={icon} /><span data-part="label">{label}</span>
90
+ </button>
91
+ )
92
+ }
93
+
94
+ /** ControlBar (07 §3.2, §3.4): an APG toolbar. One tab stop, arrows between controls, Tab leaves; Leave is the only solid. */
95
+ export function ControlBar({ mic, camera, sharing, hand, side, compact, onToggle, onLeave, more = 'More options' }: ControlBarProps) {
96
+ let bar: HTMLDivElement | undefined
97
+ use(toolbar, () => bar, () => ({ items: '[data-part="control"], [data-part="leave"]' }))
98
+ return (
99
+ <div ref={n => (bar = n)} data-scope="call-controls" role="toolbar" aria-label="Meeting controls">
100
+ <div data-part="group">
101
+ <Control onToggle={onToggle} c="mic" name={mic ? 'Microphone, mute' : 'Microphone, unmute'} icon={mic ? 'mic' : 'micoff'} pressed={mic} device label={mic ? 'Mute' : 'Unmute'} first />
102
+ <Control onToggle={onToggle} c="camera" name={camera ? 'Camera, stop video' : 'Camera, start video'} icon={camera ? 'cam' : 'camoff'} pressed={camera} device label={camera ? 'Stop video' : 'Start video'} />
103
+ </div>
104
+ <div data-part="group">
105
+ <Control onToggle={onToggle} c="share" name="Share screen" icon={ScreenIcon} pressed={sharing} label="Share" />
106
+ <Control onToggle={onToggle} c="hand" name={hand ? 'Lower hand' : 'Raise hand'} icon={HandIcon} pressed={hand} label={hand ? 'Lower hand' : 'Raise hand'} />
107
+ <Control onToggle={onToggle} c="react" name="React" icon={SmileIcon} label="React" />
108
+ {!compact && <Control onToggle={onToggle} c="chat" name="Chat" icon={ChatIcon} pressed={side === 'chat'} label="Chat" />}
109
+ {!compact && <Control onToggle={onToggle} c="people" name="People" icon={UsersIcon} pressed={side === 'people'} label="People" />}
110
+ <button type="button" data-part="control" aria-label={more} tabindex="-1" onClick={() => onToggle('more')}><G name="more" /></button>
111
+ </div>
112
+ <Button tone="critical" data-part="leave" aria-label="Leave meeting" data-icon-only={compact ? '' : undefined} tabindex="-1" onClick={() => onLeave?.()}><G name="leave" />{compact ? null : 'Leave'}</Button>
113
+ </div>
114
+ )
115
+ }
116
+
117
+ /** Props for MeetingRoom. */
118
+ export interface MeetingRoomProps {
119
+ title: string
120
+ participants: Participant[] | { value: Participant[] } | (() => Participant[])
121
+ self: Participant
122
+ layout?: State<MeetingLayout> | MeetingLayout | undefined
123
+ /** Called with the layout picked in the switcher. */
124
+ onLayoutChange?: ((l: MeetingLayout) => void) | undefined
125
+ /** Elapsed time, already formatted (the caller owns the clock). */
126
+ timer?: unknown
127
+ /** Shows a Rec badge in the header. */
128
+ recording?: boolean | undefined
129
+ /** The room's connection: `room.status`, mapped. */
130
+ live?: string | (() => string) | undefined
131
+ /** Phone: speaker layout, no switcher, icon-only Leave. Unset: decided by the room's own width after mount. */
132
+ compact?: boolean | undefined
133
+ /** Dark island by default (07 §7). */
134
+ theme?: 'dark' | 'light' | undefined
135
+ /** Media is the caller's: warp never touches getUserMedia. Return the <video> for a participant. */
136
+ video?: Video | undefined
137
+ /** The shared screen, for presentation layout. */
138
+ share?: (() => unknown) | undefined
139
+ presenter?: string | undefined
140
+ /** What is being shared, in the presentation header. Default "Shared screen". */
141
+ shareTitle?: string | undefined
142
+ board?: (() => unknown) | undefined
143
+ /** The Chat panel's body. */
144
+ chat?: (() => unknown) | undefined
145
+ /** Below People: "Invite people". */
146
+ invite?: (() => unknown) | undefined
147
+ /** Whether your mic is on: a state the room writes, or a value with `onMic`. */
148
+ mic?: State<boolean> | boolean | undefined
149
+ /** Whether your camera is on: a state the room writes, or a value with `onCamera`. */
150
+ camera?: State<boolean> | boolean | undefined
151
+ /** Whether your hand is raised: a state the room writes, or a value with `onHand`. */
152
+ hand?: State<boolean> | boolean | undefined
153
+ /** Whether you are sharing your screen: a state the room writes, or a value with `onShare`. */
154
+ sharing?: State<boolean> | boolean | undefined
155
+ /** The open side panel, `people` or `chat`, or null for none. */
156
+ sidebar?: State<'people' | 'chat' | null> | 'people' | 'chat' | null | undefined
157
+ /** The emoji the React button sends. Default 🎉. */
158
+ reaction?: string | undefined
159
+ /** Called with the mic's new state. Start or stop the track here. */
160
+ onMic?: ((on: boolean) => void) | undefined
161
+ /** Called with the camera's new state. Start or stop the track here. */
162
+ onCamera?: ((on: boolean) => void) | undefined
163
+ /** Called with `true` when you raise your hand and `false` when you lower it. */
164
+ onHand?: ((on: boolean) => void) | undefined
165
+ /** Called with `true` to start sharing your screen and `false` to stop. */
166
+ onShare?: ((on: boolean) => void) | undefined
167
+ /** Called with the emoji when you react. */
168
+ onReact?: ((emoji: string) => void) | undefined
169
+ /** Called when Leave is pressed. */
170
+ onLeave?: (() => void) | undefined
171
+ /** More: replaces the phone's default, which opens the People and Chat panel. */
172
+ onMore?: (() => void) | undefined
173
+ }
174
+
175
+ /**
176
+ * MeetingRoom (07 §3): header, stage in four layouts, sidebar, ControlBar. A dark island unless told otherwise. Speaker
177
+ * follows the active speaker with a 1.5 s hold, off-screen video pauses, hands are announced and speakers aren't.
178
+ */
179
+ export function MeetingRoom({ title, participants, self, layout, onLayoutChange, timer, recording, live = 'live', compact, theme = 'dark', video, share, presenter, shareTitle = 'Shared screen', board, chat, invite, mic, camera, hand, sharing, sidebar, reaction = '🎉', onMic, onCamera, onHand, onShare, onReact, onLeave, onMore }: MeetingRoomProps) {
180
+ const people = derived(() => val<Participant[]>(participants) ?? [])
181
+ const narrow = state(false)
182
+ const phone = derived(() => compact ?? narrow.value)
183
+ const chosen = held<MeetingLayout>(() => layout, untrack(() => compact) ? 'speaker' : 'gallery', () => onLayoutChange)
184
+ const micOn = held<boolean>(() => mic, true, () => onMic)
185
+ const camOn = held<boolean>(() => camera, true, () => onCamera)
186
+ const handUp = held<boolean>(() => hand, false, () => onHand)
187
+ const sharingOn = held<boolean>(() => sharing, false, () => onShare)
188
+ const side = held<'people' | 'chat' | null>(() => sidebar, null)
189
+ const L = derived<MeetingLayout>(() => (sharingOn.value ? 'presentation' : phone.value && chosen.value === 'gallery' ? 'speaker' : chosen.value))
190
+ const speaking = derived(() => people.value.find(p => p.isSpeaking)?.id)
191
+ const speaker = state<string | undefined>(untrack(() => speaking.value ?? people.value[0]?.id))
192
+ const main = derived(() => people.value.find(p => p.id === speaker.value) ?? people.value[0])
193
+ const others = derived(() => people.value.filter(p => p.id !== main.value?.id))
194
+ const me = derived<Participant>(() => ({ ...self, isMuted: !micOn.value, handRaised: handUp.value, isCamOn: camOn.value }))
195
+ const cols = derived(() => galleryCols(people.value.length))
196
+ const network = derived<LiveState>(() => { const s = val<string>(live); return s === 'open' || s === 'live' ? 'live' : s === 'reconnecting' ? 'reconnecting' : s === 'connecting' ? 'connecting' : 'offline' })
197
+ const netWords = derived(() => (network.value === 'live' ? 'Good connection' : network.value === 'reconnecting' ? 'Reconnecting…' : network.value === 'connecting' ? 'Connecting…' : 'Offline'))
198
+ const presenterText = derived(() => (sharingOn.value ? 'You are presenting' : `${presenter ?? 'Someone'} is presenting`))
199
+ const v = untrack(() => video)
200
+ let root: HTMLDivElement | undefined
201
+ let stage: HTMLDivElement | undefined
202
+
203
+ const say = (text: string) => announce(root!.ownerDocument, text)
204
+ onMount(() => {
205
+ const follow = speakerFollow(id => (speaker.value = id))
206
+ follow.update(untrack(() => speaker.value))
207
+ watch(() => speaking.value, s => follow.update(s))
208
+ watch(() => people.value, (next, prev) => { if (prev) for (const p of newHands(prev, next)) say(`${p.name} raised a hand`) })
209
+ onCleanup(() => follow.destroy())
210
+ const RO = (root!.ownerDocument.defaultView as Window & typeof globalThis).ResizeObserver
211
+ if (RO && untrack(() => compact) === undefined) {
212
+ const ro = new RO(es => { narrow.value = es[0]!.contentRect.width < 600 })
213
+ ro.observe(root!)
214
+ onCleanup(() => ro.disconnect())
215
+ }
216
+ })
217
+ use(pauseOffscreen, () => stage)
218
+ const toggle = (c: string) => {
219
+ if (c === 'mic') { micOn.set(!micOn.value); say(micOn.value ? 'Microphone on' : 'Microphone off') }
220
+ if (c === 'camera') { camOn.set(!camOn.value); say(camOn.value ? 'Camera on' : 'Camera off') }
221
+ if (c === 'hand') { handUp.set(!handUp.value); say(handUp.value ? 'Hand raised' : 'Hand lowered') }
222
+ if (c === 'share') { sharingOn.set(!sharingOn.value); say(sharingOn.value ? 'You are presenting' : 'Stopped presenting') }
223
+ if (c === 'chat' || c === 'people') side.set(side.value === c ? null : c)
224
+ if (c === 'react') { floatReaction(stage!, reaction); onReact?.(reaction); say('You reacted') }
225
+ if (c === 'more') { if (onMore) onMore(); else if (phone.value) side.set(side.value ? null : 'people') }
226
+ }
227
+ const closeSide = () => {
228
+ side.set(null)
229
+ root?.querySelector<HTMLElement>('[data-scope="call-controls"] [data-part="control"][tabindex="0"]')?.focus()
230
+ }
231
+ const sideKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !root!.matches(':has([data-scope="call-controls"] :focus)')) closeSide() }
232
+ const pipStart = derived<PipCorner>(() => (phone.value ? 'top-right' : 'bottom-right'))
233
+ let pipEl: HTMLDivElement | undefined
234
+ use(pip, () => pipEl, () => ({ corner: pipStart.value, onMove: (c: PipCorner) => say(`Self view moved to ${c.replace('-', ' ')}`) }))
235
+
236
+ return (
237
+ <div ref={n => (root = n)} data-scope="room" data-part="root" data-theme={theme} role="region" aria-label={`${title} meeting`}>
238
+ <div data-part="header">
239
+ <b>{title}</b>
240
+ {timer !== undefined && <span data-numeric="">{timer}</span>}
241
+ {recording && <span data-part="rec"><span data-scope="status-dot" data-tone="critical"></span>Rec</span>}
242
+ <span data-part="spacer"></span>
243
+ {!phone.value && <LayoutPicker value={L.value} onChange={(l: MeetingLayout) => { chosen.set(l); if (sharingOn.value) sharingOn.set(false); say(`${l[0]!.toUpperCase()}${l.slice(1)} layout`) }} />}
244
+ <LiveStatus state={network.value} label="Connection">{phone.value ? '' : netWords.value}</LiveStatus>
245
+ </div>
246
+ <div ref={n => (stage = n)} data-part="stage" data-layout={L.value} style={`--_cols:${cols.value}`} role="group" aria-label="Participants">
247
+ {L.value === 'gallery' && <div data-part="tiles" role="list" aria-label="Participants">{people.value.map(p => <Tile key={p.id} p={p} video={v} role="listitem" />)}</div>}
248
+ {L.value === 'speaker' && main.value && <Tile key={main.value.id} p={main.value} video={v} />}
249
+ {L.value === 'speaker' && <div data-part="strip" role="list" aria-label="Other participants">{others.value.map(p => <Tile key={p.id} p={p} video={v} role="listitem" />)}</div>}
250
+ {L.value === 'presentation' && (
251
+ <div data-part="share">
252
+ <header><G name="screen" /> {presenterText.value} · {shareTitle}{sharingOn.value && <Button tone="critical" size="sm" onClick={() => toggle('share')}>Stop sharing</Button>}</header>
253
+ {share ? share() : <div></div>}
254
+ </div>
255
+ )}
256
+ {L.value === 'board' && (
257
+ <div data-part="share">
258
+ <header><G name="board" /> Whiteboard · everyone can draw</header>
259
+ {board ? board() : <div></div>}
260
+ </div>
261
+ )}
262
+ {(L.value === 'presentation' || L.value === 'board') && <div data-part="rail" role="list" aria-label="Participants">{people.value.map(p => <Tile key={p.id} p={p} video={v} role="listitem" />)}</div>}
263
+ <div ref={n => (pipEl = n)} data-scope="pip" role="group" aria-roledescription="draggable self view" aria-label="Self view. Arrow keys move it to a corner." tabindex="0">
264
+ <Tile p={me.value} self video={v} />
265
+ </div>
266
+ </div>
267
+ <aside data-part="sidebar" hidden={!side.value} aria-label={side.value === 'chat' ? 'Chat' : 'People'} onKeyDown={sideKey}>
268
+ <div data-part="app-bar">
269
+ <b>{side.value === 'chat' ? 'Chat' : `People · ${people.value.length + 1}`}</b>
270
+ <span data-part="spacer"></span>
271
+ {phone.value && chat && <Button emphasis="ghost" tone="neutral" size="sm" onClick={() => side.set(side.value === 'chat' ? 'people' : 'chat')}>{side.value === 'chat' ? 'People' : 'Chat'}</Button>}
272
+ <Button emphasis="ghost" tone="neutral" size="sm" data-icon-only="" aria-label="Close panel" onClick={closeSide}><G name="x" /></Button>
273
+ </div>
274
+ {side.value === 'chat'
275
+ ? (chat ? chat() : <div></div>)
276
+ : (
277
+ <ul data-part="people">
278
+ <Person p={me.value} sub="You" />
279
+ {people.value.map(p => <Person key={p.id} p={p} sub={p.role || (p.handRaised ? 'Hand raised' : p.network === 'poor' ? 'Poor connection' : 'Participant')} />)}
280
+ </ul>
281
+ )}
282
+ {side.value !== 'chat' && invite ? <div>{invite()}</div> : <div hidden></div>}
283
+ </aside>
284
+ <ControlBar mic={micOn.value} camera={camOn.value} sharing={sharingOn.value} hand={handUp.value} side={side.value} compact={phone.value} onToggle={toggle} onLeave={() => onLeave?.()} />
285
+ </div>
286
+ )
287
+ }
288
+
289
+ function Person({ p, sub }: { p: Participant; sub: string }) {
290
+ return (
291
+ <li>
292
+ <Avatar name={p.name} size="sm" style={avatarTint(p.id)} />
293
+ <span>{p.name}<small>{sub}</small></span>
294
+ {p.handRaised && <span role="img" aria-label="Hand raised"><G name="hand" /></span>}
295
+ {p.isMuted ? <G name="micoff" /> : <G name="mic" />}
296
+ </li>
297
+ )
298
+ }