@lovett/ui 0.0.10 → 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/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +6064 -80
- package/dist/index.js +21561 -9696
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1586 -2
- package/dist/theme-v2.css +228 -0
- package/dist/tokens.css +121 -0
- package/package.json +8 -1
- package/src/__tests__/anchor.test.tsx +422 -0
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/combobox.test.tsx +677 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +208 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +572 -0
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/helpers/geometry.ts +58 -0
- package/src/__tests__/layer-stack.test.tsx +228 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/popover.test.tsx +460 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/select.test.tsx +543 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/__tests__/tooltip.test.tsx +355 -0
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/calculator-shell-v2.tsx +19 -39
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/combobox.tsx +796 -0
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +186 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +507 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +242 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +412 -151
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +402 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/anchor.ts +427 -0
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +74 -0
- package/src/lib/layer-stack.ts +215 -0
- package/src/lib/refs.ts +31 -0
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/metric-card.tsx +57 -22
- package/src/modal.tsx +73 -66
- package/src/popover.tsx +407 -0
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/select.tsx +646 -0
- package/src/sortable.tsx +315 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-row.tsx +108 -70
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1586 -2
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +235 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +662 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +915 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +218 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- package/src/tokens.css +121 -0
- package/src/tooltip.tsx +297 -0
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ActivityPane — Everything · Comments · Activity, with one composer.
|
|
3
|
+
*
|
|
4
|
+
* `docs/design-briefs/tasks-item-detail-full.md` §5.
|
|
5
|
+
*
|
|
6
|
+
* ## The composer is pinned to the bottom of the pane on EVERY tab
|
|
7
|
+
*
|
|
8
|
+
* Including Activity, which is the instruction that decides the whole
|
|
9
|
+
* structure. `Thread` pins a root composer of its own at the foot of the
|
|
10
|
+
* comments list, so mounting it inside the Comments tab and a second one for
|
|
11
|
+
* the other tabs would put two composers on screen at once — and switching
|
|
12
|
+
* tabs would unmount whichever one you were typing in. So the composer lives
|
|
13
|
+
* OUTSIDE the tab panels, mounted once, and `Thread` is asked to draw no root
|
|
14
|
+
* composer (`rootComposer={false}`, added to that primitive for this). Replies
|
|
15
|
+
* still work: they are gated on `onReply`, not on the root composer.
|
|
16
|
+
*
|
|
17
|
+
* The three panels are all mounted and toggled with `hidden` for the same
|
|
18
|
+
* reason — a scroll position, an expanded reply and a half-read thread all
|
|
19
|
+
* survive a tab change, and `hidden` takes them out of the accessibility tree
|
|
20
|
+
* and the tab ring while they are away.
|
|
21
|
+
*
|
|
22
|
+
* ## It fetches nothing
|
|
23
|
+
*
|
|
24
|
+
* Comments and activity arrive as props, already shaped. §6 is explicit that
|
|
25
|
+
* the activity feed is written server-side in the same transaction as the
|
|
26
|
+
* change — never derived on the client — so this component has no business
|
|
27
|
+
* inferring history from an item, and does not.
|
|
28
|
+
*
|
|
29
|
+
* ## Everything is read-only
|
|
30
|
+
*
|
|
31
|
+
* The merged feed interleaves both by time so you can see what happened in
|
|
32
|
+
* order. Reacting, replying and collapsing live in Comments, where the thread
|
|
33
|
+
* is drawn in full — a feed that is half interactive and half not is worse
|
|
34
|
+
* than one that is honestly neither.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { useId, useMemo, useState, type ReactNode } from 'react'
|
|
38
|
+
import { Tabs } from '../tabs'
|
|
39
|
+
import { Avatar, asAvatarTint } from '../avatar'
|
|
40
|
+
import { cn } from '../lib/utils'
|
|
41
|
+
import { CommentBody } from '../thread/comment-body'
|
|
42
|
+
import { RelativeTime } from '../thread/relative-time'
|
|
43
|
+
import { Thread } from '../thread/thread'
|
|
44
|
+
import { ThreadComposer } from '../thread/composer'
|
|
45
|
+
import { useNowTick } from '../thread/use-now'
|
|
46
|
+
import type { ThreadAuthor, ThreadComment } from '../thread/types'
|
|
47
|
+
import type { ActivityEntry, FieldPerson } from './types'
|
|
48
|
+
|
|
49
|
+
export type ActivityTab = 'everything' | 'comments' | 'activity'
|
|
50
|
+
|
|
51
|
+
const TAB_ORDER: readonly ActivityTab[] = ['everything', 'comments', 'activity']
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The tabs a given pane actually shows.
|
|
55
|
+
*
|
|
56
|
+
* A host with no comment backend gets Everything and Activity, and Everything
|
|
57
|
+
* then means "the activity feed" rather than a merge of two sources — which is
|
|
58
|
+
* still true, just shorter. Dropping the tab rather than showing an empty
|
|
59
|
+
* Comments panel is the honest version: a tab that can never have content is a
|
|
60
|
+
* promise the product does not keep.
|
|
61
|
+
*/
|
|
62
|
+
function tabsFor(withComments: boolean): readonly ActivityTab[] {
|
|
63
|
+
return withComments ? TAB_ORDER : TAB_ORDER.filter((id) => id !== 'comments')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const TAB_LABEL: Readonly<Record<ActivityTab, string>> = {
|
|
67
|
+
everything: 'Everything',
|
|
68
|
+
comments: 'Comments',
|
|
69
|
+
activity: 'Activity',
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** In the interface's own voice: what this is, and what will appear here. */
|
|
73
|
+
const DEFAULT_EMPTY: Readonly<Record<ActivityTab, string>> = {
|
|
74
|
+
everything: 'Nothing here yet. Comments and changes to this record show up together.',
|
|
75
|
+
comments: 'No comments yet. Start the conversation.',
|
|
76
|
+
activity: 'No activity yet. Every change to this record is recorded here.',
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ActivityPaneProps {
|
|
80
|
+
/** Controlled tab. Omit for internal state. */
|
|
81
|
+
tab?: ActivityTab | undefined
|
|
82
|
+
defaultTab?: ActivityTab | undefined
|
|
83
|
+
onTabChange?: ((next: ActivityTab) => void) | undefined
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Already shaped. The pane fetches nothing.
|
|
87
|
+
*
|
|
88
|
+
* Omit it — along with the composer trio below — for a host that has no
|
|
89
|
+
* comment backend at all. The Comments tab is then not rendered, rather than
|
|
90
|
+
* rendered empty. See `tabsFor`.
|
|
91
|
+
*/
|
|
92
|
+
comments?: readonly ThreadComment[] | undefined
|
|
93
|
+
/** Item history, in any order — the pane sorts by time. */
|
|
94
|
+
activity: readonly ActivityEntry[]
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The composer's draft, controlled by the host so it survives a remount.
|
|
98
|
+
*
|
|
99
|
+
* The trio is all-or-nothing: supply `onSubmit` and you get a composer,
|
|
100
|
+
* omit it and the pane draws none. `onSubmit` is the one that decides,
|
|
101
|
+
* because a composer with nowhere to post is worse than no composer.
|
|
102
|
+
*/
|
|
103
|
+
draft?: string | undefined
|
|
104
|
+
onDraftChange?: ((next: string) => void) | undefined
|
|
105
|
+
/** A new root comment. Omitted, the pane renders no composer. */
|
|
106
|
+
onSubmit?: ((body: string) => void) | undefined
|
|
107
|
+
/** A reply. Omitted, the thread offers no reply control at all. */
|
|
108
|
+
onReply?: ((body: string, parentId: string) => void) | undefined
|
|
109
|
+
sending?: boolean | undefined
|
|
110
|
+
composerAvatar?: ReactNode | undefined
|
|
111
|
+
composerPlaceholder?: string | undefined
|
|
112
|
+
composerLabel?: string | undefined
|
|
113
|
+
composerMaxLength?: number | undefined
|
|
114
|
+
submitLabel?: string | undefined
|
|
115
|
+
|
|
116
|
+
/** Frozen clock, epoch ms. Omit and the shared 60s ticker drives the times. */
|
|
117
|
+
now?: number | undefined
|
|
118
|
+
locale?: string | undefined
|
|
119
|
+
/** Override any tab's empty state. */
|
|
120
|
+
emptyStates?: Partial<Record<ActivityTab, ReactNode>> | undefined
|
|
121
|
+
className?: string | undefined
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function ActivityPane({
|
|
125
|
+
tab,
|
|
126
|
+
defaultTab = 'everything',
|
|
127
|
+
onTabChange,
|
|
128
|
+
comments = [],
|
|
129
|
+
activity,
|
|
130
|
+
draft = '',
|
|
131
|
+
onDraftChange,
|
|
132
|
+
onSubmit,
|
|
133
|
+
onReply,
|
|
134
|
+
sending = false,
|
|
135
|
+
composerAvatar,
|
|
136
|
+
composerPlaceholder = 'Add a comment…',
|
|
137
|
+
composerLabel = 'Add a comment',
|
|
138
|
+
composerMaxLength,
|
|
139
|
+
submitLabel = 'Comment',
|
|
140
|
+
now,
|
|
141
|
+
locale,
|
|
142
|
+
emptyStates,
|
|
143
|
+
className,
|
|
144
|
+
}: ActivityPaneProps) {
|
|
145
|
+
const reactId = useId()
|
|
146
|
+
const [internalTab, setInternalTab] = useState<ActivityTab>(defaultTab)
|
|
147
|
+
const withComments = onSubmit !== undefined
|
|
148
|
+
const tabs = tabsFor(withComments)
|
|
149
|
+
const requested = tab ?? internalTab
|
|
150
|
+
// A host that drops Comments while it is the active tab (or that defaults to
|
|
151
|
+
// it) must not land on a panel that is not rendered.
|
|
152
|
+
const active = tabs.includes(requested) ? requested : (tabs[0] as ActivityTab)
|
|
153
|
+
const ticked = useNowTick(now === undefined)
|
|
154
|
+
const clock = now ?? ticked
|
|
155
|
+
|
|
156
|
+
const sortedActivity = useMemo(
|
|
157
|
+
() => [...activity].sort((a, b) => a.at - b.at),
|
|
158
|
+
[activity],
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
// Oldest first, so the newest sits nearest the composer.
|
|
162
|
+
const merged = useMemo(() => {
|
|
163
|
+
const entries: FeedEntry[] = [
|
|
164
|
+
...comments.map((comment) => ({
|
|
165
|
+
kind: 'comment' as const,
|
|
166
|
+
at: comment.createdAt,
|
|
167
|
+
comment,
|
|
168
|
+
})),
|
|
169
|
+
...sortedActivity.map((entry) => ({
|
|
170
|
+
kind: 'activity' as const,
|
|
171
|
+
at: entry.at,
|
|
172
|
+
entry,
|
|
173
|
+
})),
|
|
174
|
+
]
|
|
175
|
+
return entries.sort((a, b) => a.at - b.at)
|
|
176
|
+
}, [comments, sortedActivity])
|
|
177
|
+
|
|
178
|
+
const empty = (id: ActivityTab): ReactNode => emptyStates?.[id] ?? DEFAULT_EMPTY[id]
|
|
179
|
+
|
|
180
|
+
// Tab -> panel wiring. The strip alone was a tablist whose tabs
|
|
181
|
+
// controlled nothing; each tab now points `aria-controls` at the panel it
|
|
182
|
+
// shows, and each panel is focusable so keyboard users can reach its
|
|
183
|
+
// scrolling content.
|
|
184
|
+
const idPrefix = `activity-${reactId.replace(/[^a-zA-Z0-9_-]/g, '')}`
|
|
185
|
+
const panelDomId = (id: ActivityTab) => `${idPrefix}-panel-${id}`
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<section
|
|
189
|
+
data-slot="activity-pane"
|
|
190
|
+
aria-label="Activity"
|
|
191
|
+
className={cn('flex min-h-0 flex-1 flex-col', className)}
|
|
192
|
+
>
|
|
193
|
+
{/*
|
|
194
|
+
Matched to the field-list header on the other side of the split, so
|
|
195
|
+
the two panes' first rows sit on one line: same `--space-2` block
|
|
196
|
+
padding, same bottom border, and a control the same 36px tall (the
|
|
197
|
+
segmented track is 26 + 4 + 4 + 1 + 1, the same as that header's
|
|
198
|
+
SegmentedChoice). Change one and change the other.
|
|
199
|
+
*/}
|
|
200
|
+
<div
|
|
201
|
+
className="shrink-0 border-b"
|
|
202
|
+
style={{
|
|
203
|
+
paddingInline: 'var(--space-4)',
|
|
204
|
+
paddingBlock: 'var(--space-2)',
|
|
205
|
+
borderColor: 'rgb(var(--border))',
|
|
206
|
+
}}
|
|
207
|
+
>
|
|
208
|
+
<Tabs
|
|
209
|
+
size="sm"
|
|
210
|
+
variant="segmented"
|
|
211
|
+
stretch
|
|
212
|
+
idPrefix={idPrefix}
|
|
213
|
+
activeTabId={active}
|
|
214
|
+
onTabChange={(next) => {
|
|
215
|
+
const id = next as ActivityTab
|
|
216
|
+
if (tab === undefined) setInternalTab(id)
|
|
217
|
+
onTabChange?.(id)
|
|
218
|
+
}}
|
|
219
|
+
tabs={tabs.map((id) => ({
|
|
220
|
+
id,
|
|
221
|
+
label: TAB_LABEL[id],
|
|
222
|
+
panelId: panelDomId(id),
|
|
223
|
+
// A badge only when there is something to count. "Comments 0"
|
|
224
|
+
// says nothing the empty state does not, and in a segmented
|
|
225
|
+
// strip at the pane's 280px floor that zero is bought with the
|
|
226
|
+
// label — "Co… 0" instead of "Comments".
|
|
227
|
+
...(id === 'comments' && comments.length > 0
|
|
228
|
+
? { count: comments.length }
|
|
229
|
+
: {}),
|
|
230
|
+
...(id === 'activity' && activity.length > 0
|
|
231
|
+
? { count: activity.length }
|
|
232
|
+
: {}),
|
|
233
|
+
}))}
|
|
234
|
+
/>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
{/* EVERYTHING */}
|
|
238
|
+
<div
|
|
239
|
+
role="tabpanel"
|
|
240
|
+
id={panelDomId('everything')}
|
|
241
|
+
// Named by `aria-label`, not `aria-labelledby` back at the tab: two
|
|
242
|
+
// of the three tabs carry a count badge, so borrowing the tab's text
|
|
243
|
+
// would name the panel "Comments 3".
|
|
244
|
+
aria-label={TAB_LABEL.everything}
|
|
245
|
+
tabIndex={0}
|
|
246
|
+
hidden={active !== 'everything'}
|
|
247
|
+
className="flex min-h-0 flex-1 flex-col overflow-y-auto"
|
|
248
|
+
style={{ paddingInline: 'var(--space-4)', paddingBlock: 'var(--space-3)' }}
|
|
249
|
+
>
|
|
250
|
+
{merged.length === 0 ? (
|
|
251
|
+
<EmptyLine>{empty('everything')}</EmptyLine>
|
|
252
|
+
) : (
|
|
253
|
+
<ol className="flex list-none flex-col" style={{ gap: 'var(--space-3)', margin: 0, padding: 0 }}>
|
|
254
|
+
{merged.map((item) =>
|
|
255
|
+
item.kind === 'comment' ? (
|
|
256
|
+
<li key={`comment-${item.comment.id}`}>
|
|
257
|
+
<FeedLine
|
|
258
|
+
author={item.comment.author ?? null}
|
|
259
|
+
at={item.at}
|
|
260
|
+
now={clock}
|
|
261
|
+
locale={locale}
|
|
262
|
+
body={
|
|
263
|
+
item.comment.deletedAt === null || item.comment.deletedAt === undefined ? (
|
|
264
|
+
<CommentBody bodyMd={item.comment.bodyMd} />
|
|
265
|
+
) : (
|
|
266
|
+
<span style={{ color: 'rgb(var(--text-tertiary))' }}>
|
|
267
|
+
Comment deleted
|
|
268
|
+
</span>
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
lead="commented"
|
|
272
|
+
/>
|
|
273
|
+
</li>
|
|
274
|
+
) : (
|
|
275
|
+
<li key={`activity-${item.entry.id}`}>
|
|
276
|
+
<ActivityLine entry={item.entry} now={clock} locale={locale} />
|
|
277
|
+
</li>
|
|
278
|
+
),
|
|
279
|
+
)}
|
|
280
|
+
</ol>
|
|
281
|
+
)}
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
{/* COMMENTS — omitted entirely without a backend, not rendered empty. */}
|
|
285
|
+
{!withComments ? null : (
|
|
286
|
+
<div
|
|
287
|
+
role="tabpanel"
|
|
288
|
+
id={panelDomId('comments')}
|
|
289
|
+
// Named by `aria-label`, not `aria-labelledby` back at the tab: two
|
|
290
|
+
// of the three tabs carry a count badge, so borrowing the tab's text
|
|
291
|
+
// would name the panel "Comments 3".
|
|
292
|
+
aria-label={TAB_LABEL.comments}
|
|
293
|
+
tabIndex={0}
|
|
294
|
+
hidden={active !== 'comments'}
|
|
295
|
+
className="flex min-h-0 flex-1 flex-col"
|
|
296
|
+
style={{ paddingInline: 'var(--space-4)', paddingBlock: 'var(--space-3)' }}
|
|
297
|
+
>
|
|
298
|
+
<Thread
|
|
299
|
+
variant="bare"
|
|
300
|
+
rootComposer={false}
|
|
301
|
+
comments={comments}
|
|
302
|
+
emptyState={empty('comments')}
|
|
303
|
+
{...(now === undefined ? {} : { now })}
|
|
304
|
+
{...(locale === undefined ? {} : { locale })}
|
|
305
|
+
{...(onReply === undefined
|
|
306
|
+
? {}
|
|
307
|
+
: {
|
|
308
|
+
onSubmit: (body: string, parentId?: string) => {
|
|
309
|
+
// Root submissions come from the pinned composer below, so
|
|
310
|
+
// anything arriving here without a parent is dropped rather
|
|
311
|
+
// than posted twice.
|
|
312
|
+
if (parentId !== undefined) onReply(body, parentId)
|
|
313
|
+
},
|
|
314
|
+
})}
|
|
315
|
+
/>
|
|
316
|
+
</div>
|
|
317
|
+
)}
|
|
318
|
+
|
|
319
|
+
{/* ACTIVITY */}
|
|
320
|
+
<div
|
|
321
|
+
role="tabpanel"
|
|
322
|
+
id={panelDomId('activity')}
|
|
323
|
+
// Named by `aria-label`, not `aria-labelledby` back at the tab: two
|
|
324
|
+
// of the three tabs carry a count badge, so borrowing the tab's text
|
|
325
|
+
// would name the panel "Comments 3".
|
|
326
|
+
aria-label={TAB_LABEL.activity}
|
|
327
|
+
tabIndex={0}
|
|
328
|
+
hidden={active !== 'activity'}
|
|
329
|
+
className="flex min-h-0 flex-1 flex-col overflow-y-auto"
|
|
330
|
+
style={{ paddingInline: 'var(--space-4)', paddingBlock: 'var(--space-3)' }}
|
|
331
|
+
>
|
|
332
|
+
{sortedActivity.length === 0 ? (
|
|
333
|
+
<EmptyLine>{empty('activity')}</EmptyLine>
|
|
334
|
+
) : (
|
|
335
|
+
<ol className="flex list-none flex-col" style={{ gap: 'var(--space-3)', margin: 0, padding: 0 }}>
|
|
336
|
+
{sortedActivity.map((entry) => (
|
|
337
|
+
<li key={entry.id}>
|
|
338
|
+
<ActivityLine entry={entry} now={clock} locale={locale} />
|
|
339
|
+
</li>
|
|
340
|
+
))}
|
|
341
|
+
</ol>
|
|
342
|
+
)}
|
|
343
|
+
</div>
|
|
344
|
+
|
|
345
|
+
{/* THE COMPOSER — one, mounted once, below every panel. Absent when
|
|
346
|
+
there is nowhere to post to; a composer that discards what you typed
|
|
347
|
+
is worse than no composer. */}
|
|
348
|
+
{onSubmit === undefined ? null : (
|
|
349
|
+
<div
|
|
350
|
+
data-slot="activity-composer"
|
|
351
|
+
className="shrink-0 border-t"
|
|
352
|
+
style={{
|
|
353
|
+
borderColor: 'rgb(var(--border))',
|
|
354
|
+
paddingInline: 'var(--space-4)',
|
|
355
|
+
paddingBlock: 'var(--space-3)',
|
|
356
|
+
}}
|
|
357
|
+
>
|
|
358
|
+
<ThreadComposer
|
|
359
|
+
label={composerLabel}
|
|
360
|
+
placeholder={composerPlaceholder}
|
|
361
|
+
submitLabel={submitLabel}
|
|
362
|
+
value={draft}
|
|
363
|
+
onChange={onDraftChange ?? (() => {})}
|
|
364
|
+
onSubmit={() => {
|
|
365
|
+
const body = draft.trim()
|
|
366
|
+
if (body === '') return
|
|
367
|
+
onSubmit(body)
|
|
368
|
+
}}
|
|
369
|
+
sending={sending}
|
|
370
|
+
{...(composerAvatar === undefined ? {} : { avatar: composerAvatar })}
|
|
371
|
+
{...(composerMaxLength === undefined ? {} : { maxLength: composerMaxLength })}
|
|
372
|
+
/>
|
|
373
|
+
</div>
|
|
374
|
+
)}
|
|
375
|
+
</section>
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
type FeedEntry =
|
|
380
|
+
| { kind: 'comment'; at: number; comment: ThreadComment }
|
|
381
|
+
| { kind: 'activity'; at: number; entry: ActivityEntry }
|
|
382
|
+
|
|
383
|
+
function EmptyLine({ children }: { children: ReactNode }) {
|
|
384
|
+
return (
|
|
385
|
+
<p
|
|
386
|
+
className="text-[12.5px] leading-[1.5]"
|
|
387
|
+
style={{ margin: 0, color: 'rgb(var(--text-tertiary))' }}
|
|
388
|
+
>
|
|
389
|
+
{children}
|
|
390
|
+
</p>
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Never fabricate a name (thread D5) — fall back through what exists. */
|
|
395
|
+
function actorName(actor: FieldPerson | ThreadAuthor | null | undefined): string {
|
|
396
|
+
if (actor === null || actor === undefined) return 'Someone'
|
|
397
|
+
if ('name' in actor && typeof actor.name === 'string' && actor.name.length > 0) {
|
|
398
|
+
return actor.name
|
|
399
|
+
}
|
|
400
|
+
if ('email' in actor && typeof actor.email === 'string' && actor.email.length > 0) {
|
|
401
|
+
return actor.email
|
|
402
|
+
}
|
|
403
|
+
return 'Someone'
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function FeedLine({
|
|
407
|
+
author,
|
|
408
|
+
at,
|
|
409
|
+
now,
|
|
410
|
+
locale,
|
|
411
|
+
lead,
|
|
412
|
+
body,
|
|
413
|
+
}: {
|
|
414
|
+
author: FieldPerson | ThreadAuthor | null
|
|
415
|
+
at: number
|
|
416
|
+
now: number
|
|
417
|
+
locale?: string | undefined
|
|
418
|
+
lead: ReactNode
|
|
419
|
+
body?: ReactNode | undefined
|
|
420
|
+
}) {
|
|
421
|
+
const tint = asAvatarTint(author?.tint)
|
|
422
|
+
const name = actorName(author)
|
|
423
|
+
return (
|
|
424
|
+
<div className="flex items-start" style={{ gap: 'var(--space-2)' }}>
|
|
425
|
+
<Avatar
|
|
426
|
+
name={author?.name ?? null}
|
|
427
|
+
size="sm"
|
|
428
|
+
src={author?.avatarUrl ?? null}
|
|
429
|
+
{...(author?.id === undefined || author.id === null ? {} : { seed: author.id })}
|
|
430
|
+
{...(tint === undefined ? {} : { tint })}
|
|
431
|
+
/>
|
|
432
|
+
<div className="min-w-0 flex-1">
|
|
433
|
+
<p className="text-[12.5px] leading-[1.5]" style={{ margin: 0, color: 'rgb(var(--text-secondary))' }}>
|
|
434
|
+
<span className="font-semibold" style={{ color: 'rgb(var(--foreground))' }}>
|
|
435
|
+
{name}
|
|
436
|
+
</span>{' '}
|
|
437
|
+
{lead}{' '}
|
|
438
|
+
<RelativeTime value={at} now={now} locale={locale} />
|
|
439
|
+
</p>
|
|
440
|
+
{body === undefined ? null : (
|
|
441
|
+
<div className="text-[12.5px] leading-[1.55]" style={{ marginBlockStart: 'var(--space-1)' }}>
|
|
442
|
+
{body}
|
|
443
|
+
</div>
|
|
444
|
+
)}
|
|
445
|
+
</div>
|
|
446
|
+
</div>
|
|
447
|
+
)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* `‹who› moved from ‹A› to ‹B› ‹when›` (§5), and the three degenerate cases
|
|
452
|
+
* the sentence has to cover — set, cleared, and a change the server described
|
|
453
|
+
* itself. Field names arrive user-facing ("Status"), never as schema words:
|
|
454
|
+
* §7's copy rule is a host responsibility this component cannot enforce, but
|
|
455
|
+
* it never invents one either.
|
|
456
|
+
*/
|
|
457
|
+
function ActivityLine({
|
|
458
|
+
entry,
|
|
459
|
+
now,
|
|
460
|
+
locale,
|
|
461
|
+
}: {
|
|
462
|
+
entry: ActivityEntry
|
|
463
|
+
now: number
|
|
464
|
+
locale?: string | undefined
|
|
465
|
+
}) {
|
|
466
|
+
const lead = ((): ReactNode => {
|
|
467
|
+
if (entry.summary !== undefined) return entry.summary
|
|
468
|
+
const { field, from, to } = entry
|
|
469
|
+
if (field === undefined) return 'updated this item'
|
|
470
|
+
const has = (value: string | null | undefined): value is string =>
|
|
471
|
+
typeof value === 'string' && value.length > 0
|
|
472
|
+
if (has(from) && has(to)) {
|
|
473
|
+
return (
|
|
474
|
+
<>
|
|
475
|
+
changed {field} from <Value>{from}</Value> to <Value>{to}</Value>
|
|
476
|
+
</>
|
|
477
|
+
)
|
|
478
|
+
}
|
|
479
|
+
if (has(to)) {
|
|
480
|
+
return (
|
|
481
|
+
<>
|
|
482
|
+
set {field} to <Value>{to}</Value>
|
|
483
|
+
</>
|
|
484
|
+
)
|
|
485
|
+
}
|
|
486
|
+
if (has(from)) return <>cleared {field}</>
|
|
487
|
+
return <>updated {field}</>
|
|
488
|
+
})()
|
|
489
|
+
|
|
490
|
+
return (
|
|
491
|
+
<FeedLine
|
|
492
|
+
author={entry.actor ?? null}
|
|
493
|
+
at={entry.at}
|
|
494
|
+
now={now}
|
|
495
|
+
locale={locale}
|
|
496
|
+
lead={lead}
|
|
497
|
+
/>
|
|
498
|
+
)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function Value({ children }: { children: ReactNode }) {
|
|
502
|
+
return (
|
|
503
|
+
<span className="font-medium" style={{ color: 'rgb(var(--foreground))' }}>
|
|
504
|
+
{children}
|
|
505
|
+
</span>
|
|
506
|
+
)
|
|
507
|
+
}
|