@weft-js/warp 0.0.0-stage → 0.1.1
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,173 @@
|
|
|
1
|
+
import { AtIcon, CheckIcon, PaperclipIcon, SendIcon, SmileIcon, XIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { announce, insertMention, mentionQuery, nextIndex, uid, type Peer } from '@weft-js/warp-core'
|
|
3
|
+
import { derived, state, untrack, type State } from '@weft-js/core'
|
|
4
|
+
import { Button } from '../Button'
|
|
5
|
+
import { PeerAvatar, SWEEP } from './peer'
|
|
6
|
+
import '../styles'
|
|
7
|
+
import '@weft-js/warp-core/styles/components/button.css'
|
|
8
|
+
import '@weft-js/warp-core/styles/app/tokens.css'
|
|
9
|
+
import '@weft-js/warp-core/styles/app/base.css'
|
|
10
|
+
import '@weft-js/warp-core/styles/app/composer.css'
|
|
11
|
+
import '@weft-js/warp-core/styles/app/suggest.css'
|
|
12
|
+
import '@weft-js/warp-core/styles/app/typing.css'
|
|
13
|
+
|
|
14
|
+
const HINT = 'Enter sends, Shift+Enter adds a line'
|
|
15
|
+
|
|
16
|
+
/** The message being quoted in a reply. */
|
|
17
|
+
export interface QuoteDraftValue { ref: string; author: string; time: string; text: string }
|
|
18
|
+
/** A file attached to a draft. */
|
|
19
|
+
export interface ComposerFile { id: string; name: string }
|
|
20
|
+
/** Someone an `@` can name. */
|
|
21
|
+
export type Mentionable = Peer & { status?: string | undefined }
|
|
22
|
+
/** What the composer sends. */
|
|
23
|
+
export interface ComposerSend { text: string; quote?: QuoteDraftValue }
|
|
24
|
+
|
|
25
|
+
/** Props for Composer. */
|
|
26
|
+
export interface ComposerProps {
|
|
27
|
+
/** The textarea's name: "Message #calendar-engine". */
|
|
28
|
+
label: string
|
|
29
|
+
placeholder?: string
|
|
30
|
+
onSend: (m: ComposerSend) => void
|
|
31
|
+
/** The quote being drafted (§5.5), for a Thread to fill. */
|
|
32
|
+
quote?: State<QuoteDraftValue | null>
|
|
33
|
+
mentions?: Mentionable[]
|
|
34
|
+
files?: ComposerFile[]
|
|
35
|
+
onRemoveFile?: (f: ComposerFile) => void
|
|
36
|
+
onAttach?: () => void
|
|
37
|
+
/** The emoji slot: open a picker, then call `insert` with what was picked. */
|
|
38
|
+
onEmoji?: (insert: (text: string) => void) => void
|
|
39
|
+
onTyping?: () => void
|
|
40
|
+
/** Hide the keyboard hint (phone). */
|
|
41
|
+
compact?: boolean | undefined
|
|
42
|
+
/** Reaches the textarea, for focus. */
|
|
43
|
+
input?: (el: HTMLTextAreaElement) => void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The composer (07 §5.1, §5.5): QuoteDraft, textarea, attach and emoji slots, @mention suggest listbox, Send. Enter
|
|
48
|
+
* sends, Shift+Enter adds a line. A quote alone never sends: the hint says why. Escape closes the list, then the quote.
|
|
49
|
+
*/
|
|
50
|
+
export function Composer({ label, placeholder, onSend, quote, mentions, files, onRemoveFile, onAttach, onEmoji, onTyping, compact, input: giveInput }: ComposerProps) {
|
|
51
|
+
const id = uid('composer')
|
|
52
|
+
const hint = state<string | null>(null)
|
|
53
|
+
const query = state<string | null>(null)
|
|
54
|
+
const active = state(0)
|
|
55
|
+
const hasPeople = derived(() => (mentions ?? []).length > 0)
|
|
56
|
+
const canAttach = derived(() => onAttach !== undefined)
|
|
57
|
+
const canEmoji = derived(() => onEmoji !== undefined)
|
|
58
|
+
const shown = derived(() => {
|
|
59
|
+
const q = query.value?.toLowerCase()
|
|
60
|
+
return q === undefined ? [] : (mentions ?? []).filter(p => p.name.toLowerCase().includes(q)).slice(0, 8)
|
|
61
|
+
})
|
|
62
|
+
const open = derived(() => query.value !== null && shown.value.length > 0)
|
|
63
|
+
const draft = derived(() => quote?.value ?? null)
|
|
64
|
+
let ta: HTMLTextAreaElement | undefined
|
|
65
|
+
|
|
66
|
+
const removeQuote = () => {
|
|
67
|
+
if (quote) quote.value = null
|
|
68
|
+
hint.value = null
|
|
69
|
+
}
|
|
70
|
+
const closeList = () => { query.value = null; active.value = 0 }
|
|
71
|
+
const scan = () => {
|
|
72
|
+
const q = ta ? mentionQuery(ta.value, ta.selectionStart) : null
|
|
73
|
+
query.value = q && hasPeople.value ? q.query : null
|
|
74
|
+
active.value = 0
|
|
75
|
+
}
|
|
76
|
+
const pick = (p: Mentionable) => {
|
|
77
|
+
const next = insertMention(ta!.value, ta!.selectionStart, p.name.split(/\s+/)[0]!)
|
|
78
|
+
ta!.value = next.value
|
|
79
|
+
ta!.setSelectionRange(next.caret, next.caret)
|
|
80
|
+
closeList()
|
|
81
|
+
ta!.focus()
|
|
82
|
+
}
|
|
83
|
+
const send = () => {
|
|
84
|
+
const text = ta!.value.trim()
|
|
85
|
+
const q = draft.value
|
|
86
|
+
if (!text) {
|
|
87
|
+
if (!q) return
|
|
88
|
+
hint.value = 'Add a reply to the quote'
|
|
89
|
+
announce(ta!.ownerDocument, 'Add a reply to the quote', true)
|
|
90
|
+
return ta!.focus()
|
|
91
|
+
}
|
|
92
|
+
ta!.value = ''
|
|
93
|
+
closeList()
|
|
94
|
+
removeQuote()
|
|
95
|
+
onSend(q ? { text, quote: q } : { text })
|
|
96
|
+
}
|
|
97
|
+
const key = (e: KeyboardEvent) => {
|
|
98
|
+
if (open.value) {
|
|
99
|
+
const to = nextIndex(e.key, active.value, shown.value.length, { orientation: 'vertical' })
|
|
100
|
+
if (to >= 0 && e.key !== 'Home' && e.key !== 'End') { e.preventDefault(); active.value = to; return }
|
|
101
|
+
if (e.key === 'Escape') { e.preventDefault(); return closeList() }
|
|
102
|
+
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); return pick(shown.value[active.value]!) }
|
|
103
|
+
}
|
|
104
|
+
if (e.key === 'Escape' && draft.value) { e.preventDefault(); removeQuote(); return announce(ta!.ownerDocument, 'Quote removed') }
|
|
105
|
+
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); send() }
|
|
106
|
+
}
|
|
107
|
+
const insert = (text: string) => {
|
|
108
|
+
ta!.setRangeText(text, ta!.selectionStart, ta!.selectionEnd, 'end')
|
|
109
|
+
ta!.focus()
|
|
110
|
+
}
|
|
111
|
+
return (
|
|
112
|
+
<div data-scope="composer" data-sweep="">
|
|
113
|
+
<SWEEP />
|
|
114
|
+
{(files ?? []).length > 0 && (
|
|
115
|
+
<div data-part="files">
|
|
116
|
+
{(files ?? []).map(f => (
|
|
117
|
+
<span key={f.id} data-part="file" data-arrive="">
|
|
118
|
+
<CheckIcon /><span>{f.name}</span>
|
|
119
|
+
<Button emphasis="ghost" tone="neutral" size="sm" icon={XIcon} label={`Remove ${f.name}`} onClick={() => onRemoveFile?.(f)} />
|
|
120
|
+
</span>
|
|
121
|
+
))}
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
{draft.value && (
|
|
125
|
+
<div data-part="quote-draft" role="group" aria-label={`Quoting ${draft.value.author}`} data-arrive="">
|
|
126
|
+
<span data-part="quote-by">{draft.value.author} · {draft.value.time}</span>
|
|
127
|
+
<span data-part="quote-text">{draft.value.text}</span>
|
|
128
|
+
<Button emphasis="ghost" tone="neutral" size="sm" icon={XIcon} label="Remove quote" onClick={() => { removeQuote(); ta!.focus(); announce(ta!.ownerDocument, 'Quote removed') }} />
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
<label data-part="sr-only" for={id}>{label}</label>
|
|
132
|
+
<textarea
|
|
133
|
+
ref={n => { ta = n; if (n) untrack(() => giveInput)?.(n) }}
|
|
134
|
+
id={id}
|
|
135
|
+
rows={1}
|
|
136
|
+
placeholder={placeholder ?? label}
|
|
137
|
+
aria-describedby={`${id}-h`}
|
|
138
|
+
aria-autocomplete={hasPeople.value ? 'list' : undefined}
|
|
139
|
+
aria-controls={open.value ? `${id}-l` : undefined}
|
|
140
|
+
aria-activedescendant={open.value ? `${id}-o${active.value}` : undefined}
|
|
141
|
+
onKeyDown={key}
|
|
142
|
+
onInput={() => { hint.value = null; scan(); onTyping?.() }}
|
|
143
|
+
onBlur={closeList}
|
|
144
|
+
></textarea>
|
|
145
|
+
<div data-part="row">
|
|
146
|
+
{canAttach.value && <Button emphasis="ghost" tone="neutral" size="sm" label="Attach" data-icon-only="" onClick={() => onAttach?.()}><PaperclipIcon /></Button>}
|
|
147
|
+
{canEmoji.value && <Button emphasis="ghost" tone="neutral" size="sm" label="Emoji" data-icon-only="" onClick={() => onEmoji?.(insert)}><SmileIcon /></Button>}
|
|
148
|
+
{hasPeople.value && <Button emphasis="ghost" tone="neutral" size="sm" label="Mention someone" data-icon-only="" onClick={() => { insert(`${ta!.value && !/\s$/.test(ta!.value) ? ' ' : ''}@`); scan() }}><AtIcon /></Button>}
|
|
149
|
+
<span data-part="spacer"></span>
|
|
150
|
+
<span data-part="hint" id={`${id}-h`} data-state={hint.value ? 'error' : undefined}>{hint.value ?? (compact ? '' : HINT)}</span>
|
|
151
|
+
<button type="button" data-scope="button" data-part="send" data-icon-only="" aria-label="Send" onClick={send}><SendIcon /></button>
|
|
152
|
+
</div>
|
|
153
|
+
{open.value && (
|
|
154
|
+
<div data-scope="suggest" role="listbox" id={`${id}-l`} aria-label="Mention">
|
|
155
|
+
{shown.value.map(p => (
|
|
156
|
+
<div key={p.id} role="option" id={`${id}-o${shown.value.findIndex(x => x.id === p.id)}`} aria-selected={shown.value[active.value]?.id === p.id ? 'true' : 'false'} onMouseDown={(e: MouseEvent) => { e.preventDefault(); pick(p) }}>
|
|
157
|
+
<PeerAvatar peer={p} size="sm" /><span>{p.name}</span><small>{p.status}</small>
|
|
158
|
+
</div>
|
|
159
|
+
))}
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** "Mei is typing…" with three dots (07 §5.1): real data, so it may animate; it holds still under reduced motion. */
|
|
167
|
+
export function TypingIndicator({ text }: { text: string }) {
|
|
168
|
+
return (
|
|
169
|
+
<div data-scope="typing" aria-live="polite">
|
|
170
|
+
{text ? <><span data-part="dots" aria-hidden="true"><i></i><i></i><i></i></span><span>{text}</span></> : ' '}
|
|
171
|
+
</div>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { PinIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { peerColor, roving, type Peer } from '@weft-js/warp-core'
|
|
3
|
+
import { Box, derived, state, untrack, type State } from '@weft-js/core'
|
|
4
|
+
import { Badge } from '../Badge'
|
|
5
|
+
import { Avatar } from '../parts'
|
|
6
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
7
|
+
import { tint } from './peer'
|
|
8
|
+
import '../styles'
|
|
9
|
+
import '@weft-js/warp-core/styles/app/tokens.css'
|
|
10
|
+
import '@weft-js/warp-core/styles/app/conversations.css'
|
|
11
|
+
|
|
12
|
+
/** One conversation in the list. */
|
|
13
|
+
export type ChatConversation = Peer & {
|
|
14
|
+
/** Last message. */
|
|
15
|
+
preview: string
|
|
16
|
+
time: string
|
|
17
|
+
unread?: number
|
|
18
|
+
muted?: boolean
|
|
19
|
+
pinned?: boolean
|
|
20
|
+
/** A channel draws "#"; a person draws initials. */
|
|
21
|
+
channel?: boolean
|
|
22
|
+
online?: boolean
|
|
23
|
+
href?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The conversation list (07 §5.1, §5.4): a listbox with `aria-selected` on the open channel. One tab stop, ↑/↓ and
|
|
28
|
+
* Home/End rove, Enter or a press opens. `value` is a box or a plain id.
|
|
29
|
+
*/
|
|
30
|
+
export function ConversationList({ label = 'Conversations', title, actions, items, value, onSelect }: { label?: string | undefined; title?: unknown; actions?: unknown; items: ChatConversation[]; value?: State<string> | string; onSelect?: (c: ChatConversation) => void }) {
|
|
31
|
+
const own = state(untrack(() => (value instanceof Box ? '' : value ?? '')))
|
|
32
|
+
const current = derived(() => (value instanceof Box ? value.value : value ?? own.value))
|
|
33
|
+
const heading = derived(() => title)
|
|
34
|
+
const tools = derived(() => actions)
|
|
35
|
+
let box: HTMLDivElement | undefined
|
|
36
|
+
use(roving, () => box, () => ({ items: '[role="option"]', orientation: 'vertical' as const }))
|
|
37
|
+
const pick = (e: Event, c: ChatConversation) => {
|
|
38
|
+
if (!c.href) e.preventDefault()
|
|
39
|
+
if (value instanceof Box) value.value = c.id
|
|
40
|
+
else own.value = c.id
|
|
41
|
+
onSelect?.(c)
|
|
42
|
+
}
|
|
43
|
+
const stop = derived(() => (items.some(c => c.id === current.value) ? current.value : items[0]?.id))
|
|
44
|
+
return (
|
|
45
|
+
<nav data-scope="conversations" aria-label={label}>
|
|
46
|
+
{(heading.value !== undefined || tools.value !== undefined) && <div style="display:flex;justify-content:space-between;align-items:center;padding:8px 8px 6px">{heading.value}{tools.value}</div>}
|
|
47
|
+
<div ref={n => (box = n)} role="listbox" aria-label={label} style="display:contents">
|
|
48
|
+
{items.map(c => (
|
|
49
|
+
<a
|
|
50
|
+
key={c.id}
|
|
51
|
+
href={c.href ?? '#'}
|
|
52
|
+
role="option"
|
|
53
|
+
aria-selected={current.value === c.id ? 'true' : 'false'}
|
|
54
|
+
aria-current={current.value === c.id ? 'page' : undefined}
|
|
55
|
+
tabindex={stop.value === c.id ? 0 : -1}
|
|
56
|
+
data-unread={c.unread ? '' : undefined}
|
|
57
|
+
data-muted={c.muted ? '' : undefined}
|
|
58
|
+
onClick={(e: MouseEvent) => pick(e, c)}
|
|
59
|
+
onKeyDown={(e: KeyboardEvent) => { if (e.key === ' ') { e.preventDefault(); pick(e, c) } }}
|
|
60
|
+
>
|
|
61
|
+
<Avatar name={c.channel ? '#' : c.name} size="sm" status={c.online ? 'positive' : undefined} style={tint(peerColor(c))} />
|
|
62
|
+
<b>{c.name}</b>
|
|
63
|
+
<small>{c.preview}</small>
|
|
64
|
+
<span data-part="side">
|
|
65
|
+
<span>{c.time}</span>
|
|
66
|
+
{c.unread ? <Badge emphasis="solid" count srText={`${c.unread} unread`}>{c.unread}</Badge> : c.pinned ? <PinIcon /> : null}
|
|
67
|
+
</span>
|
|
68
|
+
</a>
|
|
69
|
+
))}
|
|
70
|
+
</div>
|
|
71
|
+
</nav>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { cursorsLayer, firstName, peerColor, type CursorPeer, type Peer } from '@weft-js/warp-core'
|
|
2
|
+
import { derived } from '@weft-js/core'
|
|
3
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
4
|
+
import { read, type Live } from './peer'
|
|
5
|
+
import '../styles'
|
|
6
|
+
import '@weft-js/warp-core/styles/app/tokens.css'
|
|
7
|
+
import '@weft-js/warp-core/styles/app/base.css'
|
|
8
|
+
import '@weft-js/warp-core/styles/app/cursors.css'
|
|
9
|
+
import '@weft-js/warp-core/styles/app/cursor.css'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Peer cursors (07 §6.1): arrow and name chip in the peer's colour, anchored to `[data-anchor]` elements in the
|
|
13
|
+
* positioned parent. Glide 120 ms, fade after `idle` ms, an edge chevron when the anchor is off-screen. aria-hidden.
|
|
14
|
+
*/
|
|
15
|
+
export function Cursors({ peers, anchor, idle = 5000 }: { peers: Live<CursorPeer[]>; anchor?: string | undefined; idle?: number }) {
|
|
16
|
+
const list = derived(() => read(peers))
|
|
17
|
+
let layer: HTMLDivElement | undefined
|
|
18
|
+
use(cursorsLayer, () => layer, () => ({ peers: list.value, anchor, idle }))
|
|
19
|
+
return (
|
|
20
|
+
<div ref={n => (layer = n)} data-scope="cursors" aria-hidden="true">
|
|
21
|
+
{list.value.map(p => (
|
|
22
|
+
<div key={p.id} data-scope="cursor" data-presence-color={peerColor(p)} data-peer={p.id} style="--_x:0px;--_y:0px">
|
|
23
|
+
<svg viewBox="0 0 16 16"><path d="M1 1l5.5 13 2-5.5L14 6.5z" /></svg>
|
|
24
|
+
<span><b>{firstName(p.name)}</b></span>
|
|
25
|
+
</div>
|
|
26
|
+
))}
|
|
27
|
+
</div>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A peer's selected text (07 §6.1): a tint in their colour and a caret on the focus end. Decorative. */
|
|
32
|
+
export const PeerMark = ({ peer, children }: { peer: Peer; children?: unknown }) => <mark data-peer-text="" data-presence-color={peerColor(peer)}>{children}</mark>
|
|
33
|
+
|
|
34
|
+
/** A peer's selection on an object: an outline with their name flag. */
|
|
35
|
+
export const PeerSelection = ({ peer, children }: { peer: Peer; children?: unknown }) => <span data-peer-selection={firstName(peer.name)} data-presence-color={peerColor(peer)}>{children}</span>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { uid, type IntegrityStats } from '@weft-js/warp-core'
|
|
2
|
+
import { derived } from '@weft-js/core'
|
|
3
|
+
import { Button } from '../Button'
|
|
4
|
+
import { LiveStatus, type LiveState } from '../parts'
|
|
5
|
+
import { read, type Live } from './peer'
|
|
6
|
+
import '../styles'
|
|
7
|
+
import '@weft-js/warp-core/styles/app/tokens.css'
|
|
8
|
+
import '@weft-js/warp-core/styles/app/integrity.css'
|
|
9
|
+
|
|
10
|
+
/** One event of a replication trace. */
|
|
11
|
+
export interface IntegrityEvent { id: string; seq: number; kind: string; route: string; note?: string; lag: number }
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The dev-tools panel on a channel (07 §6.4): Sent, Received, Duplicate (caution), Out of order (critical), Lag p95, a
|
|
15
|
+
* recent-events log, and inject and reset controls for testing a client's dedupe. `integrityCounter` keeps the counts.
|
|
16
|
+
*/
|
|
17
|
+
export function DataIntegrity({ channel, stats, events, live, onInjectDuplicate, onInjectReorder, onReset }: { channel: string; stats: Live<IntegrityStats>; events: Live<IntegrityEvent[]>; live?: LiveState | undefined; onInjectDuplicate?: () => void; onInjectReorder?: () => void; onReset?: () => void }) {
|
|
18
|
+
const id = uid('integrity')
|
|
19
|
+
const s = derived(() => read(stats))
|
|
20
|
+
const log = derived(() => read(events))
|
|
21
|
+
const hasLive = derived(() => live !== undefined)
|
|
22
|
+
return (
|
|
23
|
+
<section data-scope="integrity" aria-labelledby={id}>
|
|
24
|
+
<div style="display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:8px">
|
|
25
|
+
<h3 id={id}>Data integrity · channel “{channel}”</h3>
|
|
26
|
+
{hasLive.value && <LiveStatus state={live!} />}
|
|
27
|
+
</div>
|
|
28
|
+
<dl>
|
|
29
|
+
<div><dt>Sent</dt><dd data-numeric="">{s.value.sent}</dd></div>
|
|
30
|
+
<div><dt>Received</dt><dd data-numeric="">{s.value.received}</dd></div>
|
|
31
|
+
<div><dt>Duplicate</dt><dd data-numeric="" data-tone={s.value.duplicate ? 'caution' : undefined}>{s.value.duplicate}</dd></div>
|
|
32
|
+
<div><dt>Out of order</dt><dd data-numeric="" data-tone={s.value.outOfOrder ? 'critical' : undefined}>{s.value.outOfOrder}</dd></div>
|
|
33
|
+
<div><dt>Lag p95</dt><dd data-numeric="">{s.value.lagP95} ms</dd></div>
|
|
34
|
+
</dl>
|
|
35
|
+
<ol aria-label="Recent events" tabindex="0">
|
|
36
|
+
{log.value.map(e => <li key={e.id}><b>#{e.seq}</b><span>{e.kind}</span><span>{e.route}{e.note ? ` · ${e.note}` : ''}</span><span>{e.lag} ms</span></li>)}
|
|
37
|
+
</ol>
|
|
38
|
+
<div>
|
|
39
|
+
<Button emphasis="outline" tone="neutral" size="sm" onClick={() => onInjectDuplicate?.()}>Inject a duplicate</Button>{' '}
|
|
40
|
+
<Button emphasis="outline" tone="neutral" size="sm" onClick={() => onInjectReorder?.()}>Inject a reorder</Button>{' '}
|
|
41
|
+
<Button emphasis="ghost" tone="neutral" size="sm" onClick={() => onReset?.()}>Reset counts</Button>
|
|
42
|
+
</div>
|
|
43
|
+
</section>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { MoreIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { announce, edges, findCard, isSheet, kanban, menu, moveCard, moveLine, uid, type BoardCard, type BoardColumn } from '@weft-js/warp-core'
|
|
3
|
+
import { Box, derived, flush, state, untrack, watch, type State } from '@weft-js/core'
|
|
4
|
+
import { Button } from '../Button'
|
|
5
|
+
import { Drawer } from '../Drawer'
|
|
6
|
+
import { Input } from '../Field'
|
|
7
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
8
|
+
import { VirtualList, type VirtualControl } from '../VirtualList'
|
|
9
|
+
import { SWEEP } from './peer'
|
|
10
|
+
import '../styles'
|
|
11
|
+
import '@weft-js/warp-core/styles/data/base.css'
|
|
12
|
+
import '@weft-js/warp-core/styles/data/kanban.css'
|
|
13
|
+
import '@weft-js/warp-core/styles/app/kanban.css'
|
|
14
|
+
import '@weft-js/warp-core/styles/layout/edges.css'
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
/** A card on the board, with what warp draws on it. */
|
|
18
|
+
export type KanbanCardData = BoardCard & { label?: string | undefined; color?: number | undefined; assignee?: string | undefined; /** Footer text: a due date, a count. */ meta?: string | undefined }
|
|
19
|
+
/** A board column of warp cards. */
|
|
20
|
+
export type KanbanColumn = BoardColumn<KanbanCardData>
|
|
21
|
+
|
|
22
|
+
type Ctx = {
|
|
23
|
+
board: () => KanbanColumn[]
|
|
24
|
+
move: (id: string, col: number, index: number) => void
|
|
25
|
+
sheet: (c: KanbanCardData) => void
|
|
26
|
+
coarse: (el: Element) => boolean
|
|
27
|
+
focus: (id: string) => void
|
|
28
|
+
hint: string
|
|
29
|
+
drift?: (index: number, predicted: number, actual: number) => void
|
|
30
|
+
window: (column: string, c: VirtualControl) => void
|
|
31
|
+
adds: () => boolean
|
|
32
|
+
add: (column: string, title: string) => void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const hasFooter = (c: KanbanCardData) => c.label !== undefined || c.assignee !== undefined || c.meta !== undefined
|
|
36
|
+
|
|
37
|
+
function Card({ card, ctx }: { card: KanbanCardData; ctx: Ctx }) {
|
|
38
|
+
const open = state(false)
|
|
39
|
+
let btn: HTMLButtonElement | undefined
|
|
40
|
+
let panel: HTMLDivElement | undefined
|
|
41
|
+
let el: HTMLDivElement | undefined
|
|
42
|
+
use(menu, () => panel, () => ({ open: open.value, trigger: () => btn, onClose: () => (open.value = false) }))
|
|
43
|
+
watch(() => open.value, on => { const row = el?.parentElement; if (row) row.style.zIndex = on ? '5' : '' }) // a row is its own stacking context, so the menu's has to rise
|
|
44
|
+
const to = (col: number, index: number) => {
|
|
45
|
+
const id = card.id
|
|
46
|
+
open.value = false
|
|
47
|
+
ctx.move(id, col, index)
|
|
48
|
+
ctx.focus(id)
|
|
49
|
+
}
|
|
50
|
+
const at = () => findCard(ctx.board(), card.id)
|
|
51
|
+
return (
|
|
52
|
+
<div ref={n => (el = n)} data-part="card" data-virtual="" data-focus="" data-sweep="" tabindex="0" draggable="true" data-id={card.id} aria-label={card.title} aria-describedby={ctx.hint}>
|
|
53
|
+
<SWEEP />
|
|
54
|
+
<h4 data-measure="">{card.title}</h4>
|
|
55
|
+
{hasFooter(card) && (
|
|
56
|
+
<footer>
|
|
57
|
+
{card.meta}
|
|
58
|
+
{card.label !== undefined && <span data-part="label-chip" style={`--_dc:var(--warp-color-data-${card.color ?? 1})`}><i></i>{card.label}</span>}
|
|
59
|
+
{card.assignee !== undefined && <span data-scope="avatar" data-size="sm" aria-hidden="true">{card.assignee}</span>}
|
|
60
|
+
</footer>
|
|
61
|
+
)}
|
|
62
|
+
<button ref={n => (btn = n)} type="button" data-part="move" aria-haspopup="true" aria-expanded={open.value ? 'true' : 'false'} aria-label={`Move ${card.title}`} onClick={(e: MouseEvent) => { if (ctx.coarse(e.currentTarget as Element)) ctx.sheet(card); else open.value = !open.value }}><MoreIcon /></button>
|
|
63
|
+
<div ref={n => (panel = n)} data-part="menu" hidden={!open.value} role="menu" aria-label="Move to">
|
|
64
|
+
{open.value && ctx.board().map(c => <button key={c.id} type="button" role="menuitem" tabindex="-1" aria-disabled={at()?.col === ctx.board().indexOf(c) ? 'true' : undefined} onClick={() => { const n = ctx.board().indexOf(c); if (at()?.col !== n) to(n, c.cards.length) }}>Move to {c.title}</button>)}
|
|
65
|
+
{open.value && <button type="button" role="menuitem" tabindex="-1" onClick={() => { const a = at()!; to(a.col, a.index - 1) }}>Move up</button>}
|
|
66
|
+
{open.value && <button type="button" role="menuitem" tabindex="-1" onClick={() => { const a = at()!; to(a.col, a.index + 1) }}>Move down</button>}
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function QuickAdd({ column, onAdd }: { column: KanbanColumn; onAdd: (title: string) => void }) {
|
|
73
|
+
const adding = state(false)
|
|
74
|
+
const add = (e: Event) => {
|
|
75
|
+
e.preventDefault()
|
|
76
|
+
const el = (e.currentTarget as HTMLFormElement).querySelector('input')!
|
|
77
|
+
if (el.value.trim()) onAdd(el.value.trim())
|
|
78
|
+
el.value = ''
|
|
79
|
+
}
|
|
80
|
+
return (
|
|
81
|
+
<>
|
|
82
|
+
{adding.value
|
|
83
|
+
? <form onSubmit={add} onKeyDown={(e: KeyboardEvent) => { if (e.key === 'Escape') adding.value = false }}><Input label={`Add a card to ${column.title}`} size="sm" autofocus /></form>
|
|
84
|
+
: <Button emphasis="ghost" tone="neutral" size="sm" onClick={() => (adding.value = true)}>Add card</Button>}
|
|
85
|
+
</>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function Column({ col, ctx, hint }: { col: () => KanbanColumn; ctx: Ctx; hint: string }) {
|
|
90
|
+
const id = untrack(() => col().id)
|
|
91
|
+
return (
|
|
92
|
+
<section data-part="column" aria-labelledby={`${hint}-${id}`} data-over-limit={col().limit !== undefined && col().cards.length > col().limit! ? '' : undefined}>
|
|
93
|
+
<header data-part="column-head">
|
|
94
|
+
{col().color !== undefined && <i style={`--_dc:var(--warp-color-data-${col().color})`}></i>}
|
|
95
|
+
<h3 id={`${hint}-${id}`} style="margin-inline-end:auto">{col().title}</h3>
|
|
96
|
+
<em data-part="count">{col().cards.length}{col().limit !== undefined ? ` / ${col().limit}` : ''}</em>
|
|
97
|
+
</header>
|
|
98
|
+
<VirtualList
|
|
99
|
+
part="cards"
|
|
100
|
+
tag="ol"
|
|
101
|
+
role="list"
|
|
102
|
+
inner
|
|
103
|
+
label={col().title}
|
|
104
|
+
items={col().cards}
|
|
105
|
+
rowKey={c => c.id}
|
|
106
|
+
text={c => c.title}
|
|
107
|
+
variant={c => (hasFooter(c) ? 'footer' : 'plain')}
|
|
108
|
+
estimate={72}
|
|
109
|
+
gap={8}
|
|
110
|
+
onDrift={ctx.drift}
|
|
111
|
+
control={c => ctx.window(id, c)}
|
|
112
|
+
>{card => { const c = derived(card); return <Card card={c.value} ctx={ctx} /> }}</VirtualList>
|
|
113
|
+
{ctx.adds() && <QuickAdd column={col()} onAdd={t => ctx.add(id, t)} />}
|
|
114
|
+
</section>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The board (07 §6.2, 06 §10). Columns are `section > ol`, each a window, so one with 10,000 cards costs what one with
|
|
120
|
+
* 20 does, with count and WIP limit, caution when over. Three move paths: HTML drag with a placeholder; keyboard
|
|
121
|
+
* (Alt + arrows, or Space, arrows, Space); the Move button, a menu at a fine pointer and a Sheet on a phone. Each move is
|
|
122
|
+
* announced. `columns` is a box, or a list the board follows and `onMove` reports to. `height` defaults to 560px.
|
|
123
|
+
*/
|
|
124
|
+
export function Kanban({ columns, onMove, onAdd, onDrift, height, label = 'Board' }: { columns: State<KanbanColumn[]> | KanbanColumn[]; onMove?: (id: string, column: string, index: number) => void; onAdd?: (column: string, title: string) => void; onDrift?: ((index: number, predicted: number, actual: number) => void) | undefined; height?: string; label?: string | undefined }) {
|
|
125
|
+
const hint = uid('kanban')
|
|
126
|
+
const own = state(untrack(() => (columns instanceof Box ? [] : columns)))
|
|
127
|
+
const board = derived<KanbanColumn[]>(() => (columns instanceof Box ? columns.value : own.value))
|
|
128
|
+
watch(() => columns, c => { if (!(c instanceof Box)) own.value = c })
|
|
129
|
+
const adds = derived(() => onAdd !== undefined)
|
|
130
|
+
const moving = state<KanbanCardData | null>(null)
|
|
131
|
+
const sheetOpen = state(false)
|
|
132
|
+
const windows = new Map<string, VirtualControl>()
|
|
133
|
+
let host: HTMLDivElement | undefined
|
|
134
|
+
const focus = (id: string) => {
|
|
135
|
+
const at = findCard(board.value, id)
|
|
136
|
+
if (at) queueMicrotask(() => windows.get(board.value[at.col]!.id)?.focus(id)) // focus follows the card
|
|
137
|
+
}
|
|
138
|
+
const move = (id: string, col: number, index: number) => {
|
|
139
|
+
const next = moveCard(board.value, id, col, index)
|
|
140
|
+
if (columns instanceof Box) columns.value = next
|
|
141
|
+
else own.value = next
|
|
142
|
+
flush()
|
|
143
|
+
const at = findCard(next, id)!
|
|
144
|
+
announce(host!.ownerDocument, moveLine(next, id))
|
|
145
|
+
onMove?.(id, next[at.col]!.id, at.index)
|
|
146
|
+
}
|
|
147
|
+
use(kanban, () => host, () => ({ columns: () => untrack(() => board.value), onMove: move, focus }))
|
|
148
|
+
use(edges, () => host) // a column cut at the stage's edge gets a shadow there, as a wide table's does
|
|
149
|
+
const ctx: Ctx = {
|
|
150
|
+
board: () => board.value,
|
|
151
|
+
move,
|
|
152
|
+
focus,
|
|
153
|
+
hint,
|
|
154
|
+
coarse: el => isSheet(el) || !!el.ownerDocument.defaultView?.matchMedia?.('(pointer: coarse)').matches,
|
|
155
|
+
sheet: c => { moving.value = c; sheetOpen.value = true },
|
|
156
|
+
drift: (i, p, a) => onDrift?.(i, p, a),
|
|
157
|
+
window: (column, c) => windows.set(column, c),
|
|
158
|
+
adds: () => adds.value,
|
|
159
|
+
add: (column, title) => onAdd?.(column, title),
|
|
160
|
+
}
|
|
161
|
+
const sheetTo = (col: number, index: number) => {
|
|
162
|
+
const id = moving.value!.id
|
|
163
|
+
sheetOpen.value = false
|
|
164
|
+
move(id, col, index)
|
|
165
|
+
focus(id)
|
|
166
|
+
}
|
|
167
|
+
const place = derived(() => (moving.value ? findCard(board.value, moving.value.id) : null))
|
|
168
|
+
return (
|
|
169
|
+
<>
|
|
170
|
+
<div data-scope="edges">
|
|
171
|
+
<div ref={n => (host = n)} data-scope="kanban" role="group" aria-label={label} style={`block-size:${height ?? '560px'}`}>
|
|
172
|
+
{board.value.map(c => <Column key={c.id} col={() => board.value.find(x => x.id === c.id) ?? c} ctx={ctx} hint={hint} />)}
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
<p data-part="sr-only" id={hint}>Alt plus arrow keys move this card. Space picks it up. The move button lists every destination.</p>
|
|
176
|
+
<Drawer open={sheetOpen} title={`Move ${moving.value?.title ?? ''}`} side="bottom">
|
|
177
|
+
<div style="display:grid;gap:4px">
|
|
178
|
+
{board.value.map(c => <Button key={c.id} emphasis="ghost" tone="neutral" full disabled={place.value !== null && board.value[place.value.col]?.id === c.id} onClick={() => sheetTo(board.value.indexOf(c), c.cards.length)}>Move to {c.title}</Button>)}
|
|
179
|
+
<Button emphasis="ghost" tone="neutral" full onClick={() => sheetTo(place.value!.col, place.value!.index - 1)}>Move up</Button>
|
|
180
|
+
<Button emphasis="ghost" tone="neutral" full onClick={() => sheetTo(place.value!.col, place.value!.index + 1)}>Move down</Button>
|
|
181
|
+
</div>
|
|
182
|
+
</Drawer>
|
|
183
|
+
</>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { firstName, peerColor, uid, type Peer } from '@weft-js/warp-core'
|
|
2
|
+
import { Input } from '../Field'
|
|
3
|
+
import { PeerAvatar } from './peer'
|
|
4
|
+
import '../styles'
|
|
5
|
+
import '@weft-js/warp-core/styles/app/tokens.css'
|
|
6
|
+
import '@weft-js/warp-core/styles/app/base.css'
|
|
7
|
+
import '@weft-js/warp-core/styles/app/lock.css'
|
|
8
|
+
|
|
9
|
+
/** The chip on a field another peer holds (07 §6.1): "Kofi is editing". */
|
|
10
|
+
export function LockIndicator({ peer, ...rest }: { peer: Peer; [attr: string]: unknown }) {
|
|
11
|
+
return <span data-scope="lock" data-presence-color={peerColor(peer)} {...rest}><PeerAvatar peer={peer} />{firstName(peer.name)} is editing</span>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A text field held by a peer: `readonly`, not disabled, so it stays readable and focusable; the chip describes it. */
|
|
15
|
+
export function LockedField({ peer, ...field }: { peer: Peer | null; label: string; value?: string; [attr: string]: unknown }) {
|
|
16
|
+
const id = uid('lock')
|
|
17
|
+
return (
|
|
18
|
+
<div data-locked-by={peer?.id}>
|
|
19
|
+
<Input {...field} readonly={peer !== null} aria-describedby={peer ? id : undefined} />
|
|
20
|
+
{peer && <LockIndicator peer={peer} id={id} />}
|
|
21
|
+
</div>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { announce, burst, firstName, followViewport, peerColor, popover, presenceDelta, uid, type Peer } from '@weft-js/warp-core'
|
|
2
|
+
import { derived, onCleanup, onMount, state, untrack, watch } from '@weft-js/core'
|
|
3
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
4
|
+
import { PeerAvatar, read, type Live } from './peer'
|
|
5
|
+
import '../styles'
|
|
6
|
+
import '@weft-js/warp-core/styles/components/popover.css'
|
|
7
|
+
import '@weft-js/warp-core/styles/app/tokens.css'
|
|
8
|
+
import '@weft-js/warp-core/styles/app/base.css'
|
|
9
|
+
import '@weft-js/warp-core/styles/app/avatar-stack.css'
|
|
10
|
+
import '@weft-js/warp-core/styles/app/follow-flag.css'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Who is here (07 §6.1): avatars with a ring in each peer's colour, `+n` overflow opening the list, joins and leaves
|
|
14
|
+
* announced as one summary. With `onFollow` each avatar is a toggle: `following` is the peer's id.
|
|
15
|
+
*/
|
|
16
|
+
export function PresenceStack({ peers, max = 4, following, onFollow, label = 'People here' }: { peers: Live<Peer[]>; max?: number | undefined; following?: string | null | undefined; onFollow?: (p: Peer | null) => void; label?: string | undefined }) {
|
|
17
|
+
const id = uid('presence')
|
|
18
|
+
const list = derived(() => read(peers))
|
|
19
|
+
const front = derived(() => list.value.slice(0, max))
|
|
20
|
+
const rest = derived(() => list.value.slice(max))
|
|
21
|
+
const open = state(false)
|
|
22
|
+
const canFollow = derived(() => onFollow !== undefined)
|
|
23
|
+
let more: HTMLButtonElement | undefined
|
|
24
|
+
let panel: HTMLDivElement | undefined
|
|
25
|
+
use(popover, () => panel, () => ({ open: open.value, trigger: more!, onClose: () => (open.value = false) }))
|
|
26
|
+
onMount(() => {
|
|
27
|
+
const told = burst<string>(lines => announce(panel!.ownerDocument, lines.join('. ')))
|
|
28
|
+
let was = untrack(() => list.value)
|
|
29
|
+
onCleanup(told.cancel)
|
|
30
|
+
watch(() => list.value, now => {
|
|
31
|
+
const line = presenceDelta(was, now)
|
|
32
|
+
was = now
|
|
33
|
+
if (line) told.push(line)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
return (
|
|
37
|
+
<span data-scope="avatar-stack" role="group" aria-label={label}>
|
|
38
|
+
{front.value.map(p => (canFollow.value
|
|
39
|
+
? <button key={p.id} type="button" data-hit="" aria-pressed={following === p.id ? 'true' : 'false'} style="all:unset;cursor:pointer;border-radius:99px" onClick={() => onFollow!(following === p.id ? null : p)}><span data-part="sr-only">Follow {p.name}</span><PeerAvatar peer={p} size="sm" ring /></button>
|
|
40
|
+
: <PeerAvatar key={p.id} peer={p} size="sm" ring />))}
|
|
41
|
+
{rest.value.length > 0 && <button ref={n => (more = n)} type="button" data-part="more" aria-haspopup="dialog" aria-expanded={open.value ? 'true' : 'false'} aria-controls={id} aria-label={`${rest.value.length} more people`} style="border:0;margin-inline-start:0;cursor:pointer" onClick={() => (open.value = !open.value)}>+{rest.value.length}</button>}
|
|
42
|
+
<div ref={n => (panel = n)} data-scope="popover" data-part="content" role="dialog" popover="manual" id={id} aria-label={label}>
|
|
43
|
+
<ul>{list.value.map(p => <li key={p.id}>{p.name}</li>)}</ul>
|
|
44
|
+
</div>
|
|
45
|
+
</span>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Following (07 §6.1): wraps the surface that rides the peer's viewport. The frame and the flag take their colour; any
|
|
51
|
+
* scroll of the user's, or Esc, stops. `top` is the peer's viewport top, 0–1.
|
|
52
|
+
*/
|
|
53
|
+
export function Following({ peer, top, onStop, children, ...rest }: { peer: Peer | null; top?: number | undefined; onStop: () => void; children?: unknown; [attr: string]: unknown }) {
|
|
54
|
+
let el: HTMLDivElement | undefined
|
|
55
|
+
const on = derived(() => peer !== null)
|
|
56
|
+
onMount(() => watch(() => peer, (p, was) => {
|
|
57
|
+
if (p) announce(el!.ownerDocument, `Following ${p.name}`)
|
|
58
|
+
else if (was) announce(el!.ownerDocument, 'Stopped following')
|
|
59
|
+
}))
|
|
60
|
+
use(followViewport, () => el, () => ({ on: on.value, top, onStop }))
|
|
61
|
+
return (
|
|
62
|
+
<div ref={n => (el = n)} data-following={on.value ? '' : undefined} data-presence-color={peer ? peerColor(peer) : undefined} style="position:relative" {...rest}>
|
|
63
|
+
{peer && <span data-scope="follow-flag" data-presence-color={peerColor(peer)}>Following {firstName(peer.name)} · Esc to stop</span>}
|
|
64
|
+
{children}
|
|
65
|
+
</div>
|
|
66
|
+
)
|
|
67
|
+
}
|