@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.
- package/LICENSE +16 -0
- package/README.md +55 -2
- package/package.json +43 -4
- package/plugin.d.mts +30 -0
- package/plugin.mjs +358 -0
- package/src/Accordion.tsx +58 -0
- package/src/Alert.tsx +25 -0
- package/src/Badge.tsx +17 -0
- package/src/Button.tsx +100 -0
- package/src/Calendar.tsx +162 -0
- package/src/Canvas.tsx +110 -0
- package/src/Card.tsx +29 -0
- package/src/Chart.tsx +133 -0
- package/src/Charts.tsx +69 -0
- package/src/Chip.tsx +27 -0
- package/src/Choice.tsx +105 -0
- package/src/CodeBlock.tsx +65 -0
- package/src/CodeInput.tsx +130 -0
- package/src/Combobox.tsx +113 -0
- package/src/CommandPalette.tsx +348 -0
- package/src/DataGrid.tsx +561 -0
- package/src/DateField.tsx +221 -0
- package/src/Dither.tsx +129 -0
- package/src/Drawer.tsx +41 -0
- package/src/DurationField.tsx +98 -0
- package/src/Feed.tsx +74 -0
- package/src/Field.tsx +147 -0
- package/src/Icon.tsx +8 -0
- package/src/Kbd.tsx +6 -0
- package/src/KeyValueEditor.tsx +114 -0
- package/src/Layout.tsx +109 -0
- package/src/Markdown.tsx +63 -0
- package/src/MentionsInput.tsx +150 -0
- package/src/Menu.tsx +111 -0
- package/src/Modal.tsx +40 -0
- package/src/MonthPicker.tsx +190 -0
- package/src/Nav.tsx +20 -0
- package/src/NumberField.tsx +78 -0
- package/src/Pagination.tsx +122 -0
- package/src/PaymentCard.tsx +63 -0
- package/src/Picker.tsx +120 -0
- package/src/PlaceField.tsx +126 -0
- package/src/Popover.tsx +34 -0
- package/src/Progress.tsx +26 -0
- package/src/RecurrenceField.tsx +126 -0
- package/src/RichSelect.tsx +243 -0
- package/src/RichTextEditor.tsx +207 -0
- package/src/ScrollGrid.tsx +190 -0
- package/src/Segments.tsx +70 -0
- package/src/Select.tsx +192 -0
- package/src/Shape.tsx +20 -0
- package/src/Shell.tsx +308 -0
- package/src/SideNav.tsx +147 -0
- package/src/SignaturePad.tsx +120 -0
- package/src/Skeleton.tsx +14 -0
- package/src/Slider.tsx +42 -0
- package/src/Spinner.tsx +7 -0
- package/src/Table.tsx +528 -0
- package/src/Tabs.tsx +67 -0
- package/src/Tag.tsx +15 -0
- package/src/Ticker.tsx +36 -0
- package/src/TimeField.tsx +237 -0
- package/src/Toast.tsx +56 -0
- package/src/Toggle.tsx +42 -0
- package/src/Tooltip.tsx +32 -0
- package/src/Type.tsx +46 -0
- package/src/VirtualList.tsx +386 -0
- package/src/admin/ApiKeys.tsx +136 -0
- package/src/admin/Audit.tsx +93 -0
- package/src/admin/Billing.tsx +123 -0
- package/src/admin/Cart.tsx +96 -0
- package/src/admin/Checkout.tsx +300 -0
- package/src/admin/CsvImport.tsx +174 -0
- package/src/admin/Danger.tsx +110 -0
- package/src/admin/Integrations.tsx +200 -0
- package/src/admin/InviteDialog.tsx +64 -0
- package/src/admin/Members.tsx +186 -0
- package/src/admin/Order.tsx +63 -0
- package/src/admin/PlainTable.tsx +22 -0
- package/src/admin/Plans.tsx +167 -0
- package/src/admin/SaveBar.tsx +112 -0
- package/src/admin/Secret.tsx +37 -0
- package/src/admin/Settings.tsx +76 -0
- package/src/admin/StepBar.tsx +35 -0
- package/src/admin/TagsInput.tsx +79 -0
- package/src/admin/atoms.tsx +33 -0
- package/src/admin/index.ts +16 -0
- package/src/agent/Actions.tsx +85 -0
- package/src/agent/AgentPlan.tsx +49 -0
- package/src/agent/ArtifactPanel.tsx +80 -0
- package/src/agent/Assistant.tsx +96 -0
- package/src/agent/Checkpoint.tsx +14 -0
- package/src/agent/Cite.tsx +39 -0
- package/src/agent/ContextMeter.tsx +31 -0
- package/src/agent/Conversation.tsx +34 -0
- package/src/agent/Message.tsx +53 -0
- package/src/agent/ModelPicker.tsx +65 -0
- package/src/agent/Prompt.tsx +197 -0
- package/src/agent/Prose.tsx +54 -0
- package/src/agent/Question.tsx +52 -0
- package/src/agent/Reasoning.tsx +19 -0
- package/src/agent/Shimmer.tsx +7 -0
- package/src/agent/Sources.tsx +23 -0
- package/src/agent/SpeechInput.tsx +102 -0
- package/src/agent/Suggestions.tsx +22 -0
- package/src/agent/ToolCall.tsx +91 -0
- package/src/agent/TraceViewer.tsx +55 -0
- package/src/agent/bits.tsx +40 -0
- package/src/agent/index.ts +22 -0
- package/src/bind.ts +42 -0
- package/src/collab/AutoTrack.tsx +42 -0
- package/src/collab/Comments.tsx +94 -0
- package/src/collab/Composer.tsx +173 -0
- package/src/collab/ConversationList.tsx +73 -0
- package/src/collab/Cursors.tsx +35 -0
- package/src/collab/DataIntegrity.tsx +45 -0
- package/src/collab/Kanban.tsx +185 -0
- package/src/collab/Lock.tsx +23 -0
- package/src/collab/PresenceStack.tsx +67 -0
- package/src/collab/ShareDialog.tsx +118 -0
- package/src/collab/Status.tsx +42 -0
- package/src/collab/SyncedForm.tsx +19 -0
- package/src/collab/Thread.tsx +273 -0
- package/src/collab/VersionHistory.tsx +49 -0
- package/src/collab/ViewportRail.tsx +49 -0
- package/src/collab/index.ts +16 -0
- package/src/collab/peer.tsx +16 -0
- package/src/fields.ts +5 -0
- package/src/flow/AuthScreen.tsx +40 -0
- package/src/flow/AuthSteps.tsx +120 -0
- package/src/flow/Checklist.tsx +84 -0
- package/src/flow/ConnBanner.tsx +147 -0
- package/src/flow/Flow.tsx +63 -0
- package/src/flow/MfaSetup.tsx +103 -0
- package/src/flow/Otp.tsx +114 -0
- package/src/flow/ReauthDialog.tsx +47 -0
- package/src/flow/ShortcutsOverlay.tsx +60 -0
- package/src/flow/SignGate.tsx +98 -0
- package/src/flow/SignIn.tsx +80 -0
- package/src/flow/SignUp.tsx +71 -0
- package/src/flow/Sso.tsx +38 -0
- package/src/flow/SystemPage.tsx +247 -0
- package/src/flow/Tour.tsx +108 -0
- package/src/flow/index.ts +17 -0
- package/src/flow/kit.tsx +170 -0
- package/src/index.ts +74 -0
- package/src/layout.css +4 -0
- package/src/links.css +4 -0
- package/src/live/ActivityFeed.tsx +49 -0
- package/src/live/Bell.tsx +62 -0
- package/src/live/Connection.tsx +49 -0
- package/src/live/Inbox.tsx +220 -0
- package/src/live/MeetingRoom.tsx +298 -0
- package/src/live/NotificationPreferences.tsx +193 -0
- package/src/live/Prejoin.tsx +140 -0
- package/src/live/PushSoftAsk.tsx +37 -0
- package/src/live/TransportPicker.tsx +163 -0
- package/src/live/index.ts +12 -0
- package/src/live/notify.tsx +16 -0
- package/src/live/shared.tsx +39 -0
- package/src/parts.tsx +93 -0
- package/src/schedule/Agenda.tsx +173 -0
- package/src/schedule/AvailabilityEditor.tsx +164 -0
- package/src/schedule/BookingCalendar.tsx +132 -0
- package/src/schedule/Calendar.tsx +746 -0
- package/src/schedule/EventEditor.tsx +87 -0
- package/src/schedule/EventPopover.tsx +65 -0
- package/src/schedule/MeetingPlanner.tsx +214 -0
- package/src/schedule/MoveSheet.tsx +78 -0
- package/src/schedule/YearView.tsx +163 -0
- package/src/schedule/bits.tsx +51 -0
- package/src/schedule/index.ts +10 -0
- package/src/skeleton-css.ts +4 -0
- package/src/styles.ts +10 -0
- package/src/tokens.d.ts +2 -0
- package/src/type.css +15 -0
- package/src/ui.ts +26 -0
- package/src/use-ui.ts +10 -0
- package/tailwind.css +38 -0
- 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
|
+
}
|