@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.
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,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
+ }