@lovett/ui 0.0.11 → 0.2.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 +5574 -57
- package/dist/index.js +21650 -11206
- 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 +1570 -0
- package/dist/tokens.css +112 -0
- package/package.json +8 -1
- 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__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +307 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +156 -2
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/sortable.test.tsx +394 -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/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- 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/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +369 -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 +611 -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 +301 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +290 -19
- 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 +303 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +43 -1
- package/src/lib/layer-stack.ts +30 -3
- 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/modal.tsx +42 -18
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/sortable.tsx +520 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1570 -0
- 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 +252 -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 +742 -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 +967 -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 +232 -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 +112 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The depth rail — a CONNECTOR, not a margin decoration.
|
|
3
|
+
*
|
|
4
|
+
* WHAT THE PREVIOUS PASS GOT WRONG
|
|
5
|
+
*
|
|
6
|
+
* It drew a hairline at the inline-start edge of a nesting level. That line was
|
|
7
|
+
* anchored to nothing: it started at an arbitrary x, ended at an arbitrary y,
|
|
8
|
+
* and touched neither the comment it came from nor the comment it led to. Read
|
|
9
|
+
* as a floating margin rule, which is exactly what it was. It also shipped a
|
|
10
|
+
* "Collapse" text button at the bottom of every level, so a ten-deep chain
|
|
11
|
+
* ended in ten stacked Collapse links. Both are gone.
|
|
12
|
+
*
|
|
13
|
+
* THE FIGURE, and it is one figure repeated verbatim at every depth:
|
|
14
|
+
*
|
|
15
|
+
* ( A ) Marcus Webb · 4m ← the avatar, and the byline beside it
|
|
16
|
+
* | body text
|
|
17
|
+
* | reactions Reply ← the action row
|
|
18
|
+
* (-) ← the collapse circle, LEVEL with it
|
|
19
|
+
* |
|
|
20
|
+
* '--( A ) Dana Ortiz · 3m ← elbow, terminating ON the child avatar
|
|
21
|
+
*
|
|
22
|
+
* The vertical runs down the PARENT AVATAR'S CENTRE LINE. It ends in a circular
|
|
23
|
+
* collapse control sitting on that line, vertically level with the parent's
|
|
24
|
+
* action row — the last row of the parent's own content. Below the circle it
|
|
25
|
+
* continues and turns a rounded elbow into the child's avatar. Parent and child
|
|
26
|
+
* are physically joined; the line is never ornamental.
|
|
27
|
+
*
|
|
28
|
+
* EVERY NUMBER IS DERIVED FROM THE AVATAR SIZE AND ONE INDENT TOKEN, so nothing
|
|
29
|
+
* can drift. With `A` = `--thread-avatar` and `I` = `--thread-indent`:
|
|
30
|
+
*
|
|
31
|
+
* rail x = A / 2 the parent avatar's centre line
|
|
32
|
+
* elbow run = I − A/2 horizontal distance to the child
|
|
33
|
+
* elbow radius = I − A/2 identical, so it is a PURE quarter
|
|
34
|
+
* arc with no straight horizontal tail
|
|
35
|
+
* elbow height = rowGap + A/2 the child avatar's vertical centre
|
|
36
|
+
*
|
|
37
|
+
* Radius equalling the run is not a coincidence to preserve by hand: it falls
|
|
38
|
+
* out of terminating a quarter turn exactly on the child's avatar edge, and it
|
|
39
|
+
* holds for any A and any I. Change `--thread-avatar` and the whole figure moves
|
|
40
|
+
* correctly.
|
|
41
|
+
*
|
|
42
|
+
* CONTINUITY ACROSS SIBLINGS is why the connector belongs to the CHILD rather
|
|
43
|
+
* than to the level. Each child draws the segment that spans its OWN box: an
|
|
44
|
+
* elbow from its top edge to its avatar, plus — when it is not the last sibling
|
|
45
|
+
* — a trunk from that avatar down to its bottom edge. Sibling boxes are
|
|
46
|
+
* flush (all vertical separation is a child's own top padding, never a
|
|
47
|
+
* container gap), so those segments concatenate into one unbroken line that
|
|
48
|
+
* stops precisely at the LAST reply's avatar and never dangles past it. No
|
|
49
|
+
* element has to know any other element's height.
|
|
50
|
+
*
|
|
51
|
+
* THE 24x24 FLOOR. The visible mark is a 1px line and a quiet circle; the
|
|
52
|
+
* TARGET is the whole avatar-width column beneath the avatar — `--thread-avatar`
|
|
53
|
+
* (32) wide and, at minimum, a circle tall. The transparent padding does the
|
|
54
|
+
* work, and the focus ring lands on the circle, which is a real visible object,
|
|
55
|
+
* rather than around a 600px column of nothing.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
import type { ReactNode } from 'react'
|
|
59
|
+
import { ChevronRight, Minus, Plus } from 'lucide-react'
|
|
60
|
+
import { Avatar, AvatarGroup, asAvatarTint } from '../avatar'
|
|
61
|
+
import { cn } from '../lib/utils'
|
|
62
|
+
import { RelativeTime } from './relative-time'
|
|
63
|
+
import type { CommentNode, ThreadAuthor } from './types'
|
|
64
|
+
|
|
65
|
+
/** Levels deeper than this stop ADDING indent. Nesting itself stays uncapped. */
|
|
66
|
+
export const DEFAULT_INDENT_CLAMP_DEPTH = 6
|
|
67
|
+
|
|
68
|
+
export interface ThreadRailProps {
|
|
69
|
+
readonly collapsed: boolean
|
|
70
|
+
/** Accessible name. The stem is a hairline, so this is most of what a reader gets. */
|
|
71
|
+
readonly label: string
|
|
72
|
+
readonly onToggle: () => void
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The stem and the circle: ONE control, in the avatar column, under the avatar.
|
|
77
|
+
*
|
|
78
|
+
* One per comment that has replies — never one per level plus a footer link,
|
|
79
|
+
* which is how the previous pass ended up with ten stacked "Collapse"s at the
|
|
80
|
+
* bottom of a deep chain. The circle is the affordance you aim at; the stem is
|
|
81
|
+
* the same button, so the whole column beneath the avatar is live.
|
|
82
|
+
*
|
|
83
|
+
* It is `flex-1` inside a column that stretches to the comment's content
|
|
84
|
+
* height, which is what pins the circle level with the action row without
|
|
85
|
+
* anything measuring anything.
|
|
86
|
+
*/
|
|
87
|
+
export function ThreadRail({ collapsed, label, onToggle }: ThreadRailProps) {
|
|
88
|
+
return (
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
onClick={onToggle}
|
|
92
|
+
aria-label={label}
|
|
93
|
+
aria-expanded={!collapsed}
|
|
94
|
+
title={label}
|
|
95
|
+
className={cn(
|
|
96
|
+
'group/rail flex w-full flex-1 cursor-pointer flex-col items-center border-0 bg-transparent p-0',
|
|
97
|
+
'focus-visible:outline-none',
|
|
98
|
+
)}
|
|
99
|
+
style={{
|
|
100
|
+
minBlockSize: 'var(--thread-circle)',
|
|
101
|
+
// Centres the circle ON the engagement row rather than flush with its
|
|
102
|
+
// bottom edge: half the difference between that row's floor
|
|
103
|
+
// (`--thread-action-h`) and the circle lifts it onto the row's centre
|
|
104
|
+
// line. Derived, so it tracks either token — it followed the row up to
|
|
105
|
+
// 32 when emoji reactions landed and back down to 24 when the owner
|
|
106
|
+
// asked for ~30% smaller, with no change here.
|
|
107
|
+
paddingBlockEnd: 'calc((var(--thread-action-h) - var(--thread-circle)) / 2)',
|
|
108
|
+
}}
|
|
109
|
+
>
|
|
110
|
+
{/* The stem. `flex-1` so it eats whatever height the content has, which
|
|
111
|
+
is what puts the circle on the action row's line. `--space-1` of clear
|
|
112
|
+
air under the avatar so the line reads as leaving it, not welded to
|
|
113
|
+
it. */}
|
|
114
|
+
<span
|
|
115
|
+
className={cn(
|
|
116
|
+
'w-px flex-1 bg-[rgb(var(--border))]',
|
|
117
|
+
'transition-[background-color] duration-[var(--dur-fast)] ease-[var(--ease-out)]',
|
|
118
|
+
'motion-reduce:transition-none',
|
|
119
|
+
'group-hover/rail:bg-[rgb(var(--border-strong))]',
|
|
120
|
+
'group-focus-visible/rail:bg-[rgb(var(--accent))]',
|
|
121
|
+
)}
|
|
122
|
+
style={{ marginBlockStart: 'var(--space-1)' }}
|
|
123
|
+
/>
|
|
124
|
+
<span
|
|
125
|
+
className={cn(
|
|
126
|
+
'flex shrink-0 items-center justify-center rounded-full border',
|
|
127
|
+
'border-[rgb(var(--border))] bg-[rgb(var(--surface-card))] text-[rgb(var(--text-tertiary))]',
|
|
128
|
+
'transition-[color,border-color,background-color,box-shadow] duration-[var(--dur-fast)] ease-[var(--ease-out)]',
|
|
129
|
+
'motion-reduce:transition-none',
|
|
130
|
+
'group-hover/rail:border-[rgb(var(--border-strong))] group-hover/rail:text-[rgb(var(--foreground))]',
|
|
131
|
+
'group-focus-visible/rail:border-[rgb(var(--accent))] group-focus-visible/rail:text-[rgb(var(--accent-ink))]',
|
|
132
|
+
'group-focus-visible/rail:[box-shadow:var(--ring-focus)]',
|
|
133
|
+
'group-active/rail:scale-[0.96] motion-reduce:group-active/rail:scale-100',
|
|
134
|
+
)}
|
|
135
|
+
style={{
|
|
136
|
+
inlineSize: 'var(--thread-circle)',
|
|
137
|
+
blockSize: 'var(--thread-circle)',
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
{/* Outline glyph, one asset, recoloured by state. The stroke matches the
|
|
141
|
+
optical weight of the 14/600 name it sits under. */}
|
|
142
|
+
{collapsed ? (
|
|
143
|
+
<Plus size={10} strokeWidth={2} aria-hidden="true" />
|
|
144
|
+
) : (
|
|
145
|
+
<Minus size={10} strokeWidth={2} aria-hidden="true" />
|
|
146
|
+
)}
|
|
147
|
+
</span>
|
|
148
|
+
</button>
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface ThreadConnectorProps {
|
|
153
|
+
/**
|
|
154
|
+
* Past `indentClampDepth` a level adds no indent, so there is no gutter to
|
|
155
|
+
* curve in. The elbow's horizontal run collapses to zero and the trunk drops
|
|
156
|
+
* straight onto the TOP edge of the child's avatar, which sits directly under
|
|
157
|
+
* the parent's rail. Still connected, still the same figure — it just stops
|
|
158
|
+
* stepping sideways, which is the whole point of the clamp.
|
|
159
|
+
*/
|
|
160
|
+
readonly clamped: boolean
|
|
161
|
+
/** The last sibling terminates the line; every other one carries it onward. */
|
|
162
|
+
readonly isLast: boolean
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The segment of the connector that belongs to ONE child.
|
|
167
|
+
*
|
|
168
|
+
* Purely decorative — the rail button above it is the control — so it is hidden
|
|
169
|
+
* from the accessibility tree entirely.
|
|
170
|
+
*/
|
|
171
|
+
export function ThreadConnector({ clamped, isLast }: ThreadConnectorProps) {
|
|
172
|
+
const run = clamped ? '0' : 'var(--thread-elbow-run)'
|
|
173
|
+
return (
|
|
174
|
+
<>
|
|
175
|
+
<span
|
|
176
|
+
aria-hidden="true"
|
|
177
|
+
className="pointer-events-none absolute"
|
|
178
|
+
style={{
|
|
179
|
+
insetBlockStart: '0',
|
|
180
|
+
insetInlineStart: 'var(--thread-rail-x)',
|
|
181
|
+
inlineSize: run,
|
|
182
|
+
// Down to the child avatar's centre when there is an elbow to land
|
|
183
|
+
// there; only as far as its top edge when the run is zero, so the
|
|
184
|
+
// avatar itself terminates the line instead of being crossed by it.
|
|
185
|
+
blockSize: clamped ? 'var(--thread-row-gap)' : 'var(--thread-avatar-center)',
|
|
186
|
+
borderInlineStartWidth: '1px',
|
|
187
|
+
borderBlockEndWidth: clamped ? '0' : '1px',
|
|
188
|
+
borderStyle: 'solid',
|
|
189
|
+
borderColor: 'rgb(var(--border))',
|
|
190
|
+
borderEndStartRadius: run,
|
|
191
|
+
}}
|
|
192
|
+
/>
|
|
193
|
+
{isLast || clamped ? null : (
|
|
194
|
+
// Carries the line from this child's avatar down to its bottom edge,
|
|
195
|
+
// where the next sibling's segment picks it up. Flush, so the join is
|
|
196
|
+
// invisible.
|
|
197
|
+
//
|
|
198
|
+
// Never past the clamp: with no indent there is no gutter for it to run
|
|
199
|
+
// in, and it would strike straight down through the avatar and the body
|
|
200
|
+
// text. Clamped siblings each get their own short drop instead.
|
|
201
|
+
<span
|
|
202
|
+
aria-hidden="true"
|
|
203
|
+
className="pointer-events-none absolute w-px"
|
|
204
|
+
style={{
|
|
205
|
+
insetBlockStart: clamped
|
|
206
|
+
? 'var(--thread-row-gap)'
|
|
207
|
+
: 'var(--thread-avatar-center)',
|
|
208
|
+
insetBlockEnd: '0',
|
|
209
|
+
insetInlineStart: 'var(--thread-rail-x)',
|
|
210
|
+
background: 'rgb(var(--border))',
|
|
211
|
+
}}
|
|
212
|
+
/>
|
|
213
|
+
)}
|
|
214
|
+
</>
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* A bare vertical continuation, for the gap an open reply composer opens up
|
|
220
|
+
* between the collapse circle and the first child. Without it the trunk breaks
|
|
221
|
+
* for exactly as long as someone is typing.
|
|
222
|
+
*/
|
|
223
|
+
export function ThreadTrunkSegment() {
|
|
224
|
+
return (
|
|
225
|
+
<span
|
|
226
|
+
aria-hidden="true"
|
|
227
|
+
className="pointer-events-none absolute inset-y-0 w-px"
|
|
228
|
+
style={{
|
|
229
|
+
insetInlineStart: 'var(--thread-rail-x)',
|
|
230
|
+
background: 'rgb(var(--border))',
|
|
231
|
+
}}
|
|
232
|
+
/>
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** An inline icon that centres on the FIRST line of the text beside it. */
|
|
237
|
+
export function LineIcon({ children }: { readonly children: ReactNode }) {
|
|
238
|
+
// `1lh` is the line box, so the icon tracks the text's own leading rather
|
|
239
|
+
// than the block's centre — which is what keeps it on line one when the text
|
|
240
|
+
// beside it wraps to three.
|
|
241
|
+
return <span className="flex h-[1lh] shrink-0 items-center">{children}</span>
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Distinct authors in a subtree, first-seen order. Presentation, not data. */
|
|
245
|
+
function subtreeAuthors(node: CommentNode): readonly ThreadAuthor[] {
|
|
246
|
+
const seen = new Set<string>()
|
|
247
|
+
const out: ThreadAuthor[] = []
|
|
248
|
+
const walk = (current: CommentNode): void => {
|
|
249
|
+
for (const reply of current.replies) {
|
|
250
|
+
const author = reply.author
|
|
251
|
+
if (author != null) {
|
|
252
|
+
// No id (it can be null once the user row is gone) means no identity to
|
|
253
|
+
// dedupe on, so the name stands in and an unnamed one is skipped
|
|
254
|
+
// rather than collapsing every anonymous reply into one face.
|
|
255
|
+
const key = author.id ?? author.name ?? author.email ?? null
|
|
256
|
+
if (key !== null && !seen.has(key)) {
|
|
257
|
+
seen.add(key)
|
|
258
|
+
out.push(author)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
walk(reply)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
walk(node)
|
|
265
|
+
return out
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The most recent timestamp anywhere in the subtree. */
|
|
269
|
+
function latestReplyAt(node: CommentNode): number {
|
|
270
|
+
let latest = 0
|
|
271
|
+
const walk = (current: CommentNode): void => {
|
|
272
|
+
for (const reply of current.replies) {
|
|
273
|
+
if (reply.createdAt > latest) latest = reply.createdAt
|
|
274
|
+
walk(reply)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
walk(node)
|
|
278
|
+
return latest
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface CollapsedRepliesProps {
|
|
282
|
+
readonly node: CommentNode
|
|
283
|
+
readonly count: number
|
|
284
|
+
readonly now: number
|
|
285
|
+
readonly locale?: string | undefined
|
|
286
|
+
/** The parent's display name, for the accessible name. */
|
|
287
|
+
readonly parentLabel: string
|
|
288
|
+
readonly onExpand: () => void
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* A collapsed subtree is a SUMMARY, not a count.
|
|
293
|
+
*
|
|
294
|
+
* "8 replies" tells you how much you are not reading. Faces plus a recency tell
|
|
295
|
+
* you whether it is worth opening — who is in there and whether anything
|
|
296
|
+
* happened since you last looked. That is the actual question.
|
|
297
|
+
*
|
|
298
|
+
* It deliberately has NO connector. Collapsed, the rail terminates at the
|
|
299
|
+
* circle exactly as the reference's does, and a line running on to a chip that
|
|
300
|
+
* is itself the "there is more here" signal would be saying the same thing
|
|
301
|
+
* twice.
|
|
302
|
+
*/
|
|
303
|
+
export function CollapsedReplies({
|
|
304
|
+
node,
|
|
305
|
+
count,
|
|
306
|
+
now,
|
|
307
|
+
locale,
|
|
308
|
+
parentLabel,
|
|
309
|
+
onExpand,
|
|
310
|
+
}: CollapsedRepliesProps) {
|
|
311
|
+
const authors = subtreeAuthors(node)
|
|
312
|
+
const latest = latestReplyAt(node)
|
|
313
|
+
const word = count === 1 ? 'reply' : 'replies'
|
|
314
|
+
|
|
315
|
+
return (
|
|
316
|
+
<button
|
|
317
|
+
type="button"
|
|
318
|
+
onClick={onExpand}
|
|
319
|
+
aria-expanded={false}
|
|
320
|
+
aria-label={`Show ${String(count)} ${word} to ${parentLabel}`}
|
|
321
|
+
className={cn(
|
|
322
|
+
'inline-flex items-center gap-[var(--space-2)] border px-[var(--space-2)] py-[var(--space-1)] text-[12px]',
|
|
323
|
+
'border-[rgb(var(--border))] bg-[rgb(var(--surface-inset))] text-[rgb(var(--text-secondary))]',
|
|
324
|
+
'transition-[background-color,border-color,color] duration-[var(--dur-fast)] ease-[var(--ease-out)]',
|
|
325
|
+
'motion-reduce:transition-none',
|
|
326
|
+
'hover:border-[rgb(var(--border-strong))] hover:bg-[rgb(var(--surface-hover))] hover:text-[rgb(var(--foreground))]',
|
|
327
|
+
'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
|
|
328
|
+
)}
|
|
329
|
+
style={{ minBlockSize: 'var(--space-6)', borderRadius: 'var(--radius-sm)' }}
|
|
330
|
+
>
|
|
331
|
+
<LineIcon>
|
|
332
|
+
<ChevronRight size={10} strokeWidth={1.75} aria-hidden="true" />
|
|
333
|
+
</LineIcon>
|
|
334
|
+
{authors.length === 0 ? null : (
|
|
335
|
+
<AvatarGroup
|
|
336
|
+
max={3}
|
|
337
|
+
total={authors.length}
|
|
338
|
+
size="sm"
|
|
339
|
+
className="ds-avatar-on-inset"
|
|
340
|
+
>
|
|
341
|
+
{authors.map((author, index) => {
|
|
342
|
+
const tint = asAvatarTint(author.tint)
|
|
343
|
+
return (
|
|
344
|
+
<Avatar
|
|
345
|
+
key={author.id ?? author.email ?? `${author.name ?? 'unknown'}-${String(index)}`}
|
|
346
|
+
decorative
|
|
347
|
+
name={author.name ?? author.email ?? null}
|
|
348
|
+
size="sm"
|
|
349
|
+
ring
|
|
350
|
+
src={author.avatarUrl ?? null}
|
|
351
|
+
{...(tint === undefined ? {} : { tint })}
|
|
352
|
+
{...(author.id == null ? {} : { seed: author.id })}
|
|
353
|
+
/>
|
|
354
|
+
)
|
|
355
|
+
})}
|
|
356
|
+
</AvatarGroup>
|
|
357
|
+
)}
|
|
358
|
+
<span className="font-semibold tabular-nums">
|
|
359
|
+
{count} {word}
|
|
360
|
+
</span>
|
|
361
|
+
{latest === 0 ? null : (
|
|
362
|
+
<span
|
|
363
|
+
className="flex items-center gap-[var(--space-1)]"
|
|
364
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
365
|
+
>
|
|
366
|
+
·
|
|
367
|
+
<RelativeTime value={latest} now={now} {...(locale === undefined ? {} : { locale })} />
|
|
368
|
+
</span>
|
|
369
|
+
)}
|
|
370
|
+
</button>
|
|
371
|
+
)
|
|
372
|
+
}
|