@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,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reactions and votes — the two engagement models, never both at once.
|
|
3
|
+
*
|
|
4
|
+
* ADR-147 D8. Reactions are ACKNOWLEDGEMENT, votes are RANKING. A surface
|
|
5
|
+
* picks exactly one: task threads take reactions, community and roadmap take
|
|
6
|
+
* votes. Mixing them asks a reader to decide whether a heart means "I agree"
|
|
7
|
+
* or "this should be higher", and the answer is different per person.
|
|
8
|
+
*
|
|
9
|
+
* REACTIONS ARE EMOJI. Owner instruction, 2026-09-06, overriding the "Lucide
|
|
10
|
+
* glyph, never emoji" half of D8: "more emojis rather than svgs (this is where
|
|
11
|
+
* emojis are allowed)". This is the ONE carve-out from CLAUDE.md §6 and the
|
|
12
|
+
* house no-emoji rule — code, comments, commit messages and UI copy are all
|
|
13
|
+
* still emoji-free. It is a defensible carve-out because a reaction IS the
|
|
14
|
+
* glyph: the emoji is the content, not decoration standing in for it.
|
|
15
|
+
*
|
|
16
|
+
* D8's other two reasons are untouched, and they were the load-bearing ones:
|
|
17
|
+
*
|
|
18
|
+
* • The set stays BOUNDED, and what we store is still the KEY (`up`,
|
|
19
|
+
* `heart`, …), never the emoji literal. An open picker would mean an
|
|
20
|
+
* unbounded string column that cannot be enumerated or aggregated.
|
|
21
|
+
* • Because the key is what persists, the glyph is a presentation detail we
|
|
22
|
+
* can change later without a migration. Storing the literal would weld the
|
|
23
|
+
* 2026 rasterisation into the database.
|
|
24
|
+
*
|
|
25
|
+
* TWO CONSEQUENCES OF EMOJI THAT NEEDED HANDLING:
|
|
26
|
+
*
|
|
27
|
+
* • They do not inherit `currentColor`, so the viewer-reacted state cannot be
|
|
28
|
+
* signalled by glyph colour the way a Lucide icon's would be. It is carried
|
|
29
|
+
* entirely by the chip — accent border, opaque step-up, accent-ink count —
|
|
30
|
+
* plus `aria-pressed`. Colour was never the only cue, which is why this
|
|
31
|
+
* survived the switch unchanged.
|
|
32
|
+
* • They need an explicit font stack. A host that sets a narrow
|
|
33
|
+
* `font-family` on an ancestor turns an emoji into tofu, and the thread
|
|
34
|
+
* primitive does not control its host's typography.
|
|
35
|
+
*
|
|
36
|
+
* The remaining correction to the port still holds: state is props in,
|
|
37
|
+
* callbacks out. The reference held vote state in local `useState`, so closing
|
|
38
|
+
* the dialog threw the vote away. Nothing in this file remembers anything.
|
|
39
|
+
*
|
|
40
|
+
* The selected state is an OPAQUE step-up, not an alpha tint (CLAUDE.md §2):
|
|
41
|
+
* an unreacted chip is recessed (`--surface-inset`), a reacted one steps up to
|
|
42
|
+
* `--surface-card` with an accent border and a shadow. Two translucent layers
|
|
43
|
+
* stacked is exactly how a selected element ends up rendering darker than the
|
|
44
|
+
* track it sits in.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
|
48
|
+
import type { CSSProperties } from 'react'
|
|
49
|
+
import { ArrowBigDown, ArrowBigUp, SmilePlus } from 'lucide-react'
|
|
50
|
+
import { cn } from '../lib/utils'
|
|
51
|
+
import { THREAD_REACTION_KEYS, type ThreadReactionCount, type ThreadReactionKey, type ThreadVote } from './types'
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The emoji font stack, as a value rather than a class so it can be applied to
|
|
55
|
+
* exactly the glyph and nothing else. `--font-emoji` is declared by `.ds-thread`
|
|
56
|
+
* so a host can substitute a bundled font; the literal fallback is what keeps
|
|
57
|
+
* these from rendering as tofu when `ReactionBar` is used outside a thread, or
|
|
58
|
+
* under an ancestor with a narrow `font-family`.
|
|
59
|
+
*/
|
|
60
|
+
const EMOJI_FONT =
|
|
61
|
+
'var(--font-emoji, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif)'
|
|
62
|
+
|
|
63
|
+
interface ReactionMeta {
|
|
64
|
+
/**
|
|
65
|
+
* The glyph. Chosen for CROSS-PLATFORM STABILITY, not expressiveness — every
|
|
66
|
+
* one below renders as recognisably the same object on Apple, Windows and
|
|
67
|
+
* Android, because a reaction whose meaning changes with the reader's OS is
|
|
68
|
+
* worse than no reaction. That rules out the expressive faces (the same
|
|
69
|
+
* codepoint is a smirk on one platform and a grimace on another) and the
|
|
70
|
+
* gesture emoji, whose skin-tone and orientation handling differ.
|
|
71
|
+
*/
|
|
72
|
+
readonly emoji: string
|
|
73
|
+
/**
|
|
74
|
+
* Sentence case, names what it MEANS rather than what it looks like. This is
|
|
75
|
+
* the accessible name — a screen reader announcing "party popper" has told
|
|
76
|
+
* someone what the picture is and nothing about what pressing it says.
|
|
77
|
+
*/
|
|
78
|
+
readonly label: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const REACTION_META: Readonly<Record<ThreadReactionKey, ReactionMeta>> = {
|
|
82
|
+
// Thumbs up: the single most consistently drawn emoji there is.
|
|
83
|
+
up: { emoji: '\u{1F44D}', label: 'Agree' },
|
|
84
|
+
// Red heart. Identical silhouette and hue everywhere; the VS16 is required or
|
|
85
|
+
// some platforms fall back to a monochrome text-presentation heart.
|
|
86
|
+
heart: { emoji: '\u{2764}\u{FE0F}', label: 'Love this' },
|
|
87
|
+
// Party popper: a cone plus confetti on every platform.
|
|
88
|
+
celebrate: { emoji: '\u{1F389}', label: 'Celebrate' },
|
|
89
|
+
// Eyes: two eyes looking aside, unambiguous everywhere.
|
|
90
|
+
eyes: { emoji: '\u{1F440}', label: 'Looking at this' },
|
|
91
|
+
// White heavy check mark — the GREEN-BOXED one. Deliberately not U+2714,
|
|
92
|
+
// which Apple draws grey-black and Windows draws green: the same codepoint
|
|
93
|
+
// reading "done" or "just a tick" by platform is exactly the failure mode.
|
|
94
|
+
check: { emoji: '\u{2705}', label: 'Done' },
|
|
95
|
+
// Red question mark ornament. Red on all three, unlike U+2753's text variant.
|
|
96
|
+
question: { emoji: '\u{2754}', label: 'Question' },
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The glyph itself. `aria-hidden` because the button's `aria-label` is the
|
|
101
|
+
* accessible name — without this a reader would announce the emoji's Unicode
|
|
102
|
+
* name after the label, which is the "party popper" problem twice over.
|
|
103
|
+
*/
|
|
104
|
+
function ReactionGlyph({ emoji }: { readonly emoji: string }) {
|
|
105
|
+
return (
|
|
106
|
+
<span
|
|
107
|
+
aria-hidden="true"
|
|
108
|
+
className="flex h-[1lh] shrink-0 items-center leading-none"
|
|
109
|
+
style={{ fontFamily: EMOJI_FONT, fontSize: '12px' }}
|
|
110
|
+
>
|
|
111
|
+
{emoji}
|
|
112
|
+
</span>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const CHIP_BASE = cn(
|
|
117
|
+
'inline-flex items-center justify-center gap-[var(--space-1)] border px-[var(--space-2)] text-[12px] font-semibold tabular-nums',
|
|
118
|
+
'transition-[background-color,border-color,color,box-shadow,transform] duration-[var(--dur-fast)] ease-[var(--ease-out)]',
|
|
119
|
+
'motion-reduce:transition-none',
|
|
120
|
+
'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
|
|
121
|
+
'active:scale-[0.96] motion-reduce:active:scale-100',
|
|
122
|
+
'disabled:cursor-not-allowed disabled:opacity-60',
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
const CHIP_RESTING = cn(
|
|
126
|
+
'border-[rgb(var(--border))] bg-[rgb(var(--surface-inset))] text-[rgb(var(--text-secondary))]',
|
|
127
|
+
'hover:border-[rgb(var(--border-strong))] hover:bg-[rgb(var(--surface-hover))]',
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
const CHIP_ACTIVE = cn(
|
|
131
|
+
'border-[rgb(var(--accent))] bg-[rgb(var(--surface-card))] text-[rgb(var(--accent-ink))]',
|
|
132
|
+
'[box-shadow:var(--shadow-sm)]',
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The chip's box, `--thread-action-h` (24). It went 24 → 32 when emoji landed
|
|
137
|
+
* ("a tap target, not a footnote") and back to 24 on the owner's eyeball pass
|
|
138
|
+
* ("icons are still a little too big, maybe 30% smaller"). 24 is also the
|
|
139
|
+
* accessibility floor, so it cannot go lower without the transparent-padding
|
|
140
|
+
* trick the rail uses; the glyphs inside shrank instead. Declared as a style
|
|
141
|
+
* rather than a class because it reads a thread-scoped custom property, with a
|
|
142
|
+
* literal fallback for use outside `.ds-thread`.
|
|
143
|
+
*/
|
|
144
|
+
const CHIP_SIZE: CSSProperties = {
|
|
145
|
+
blockSize: 'var(--thread-action-h, var(--space-8))',
|
|
146
|
+
borderRadius: 'var(--radius-sm)',
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Icon-only variants: square, so the glyph sits on the chip's optical centre. */
|
|
150
|
+
const CHIP_SQUARE: CSSProperties = {
|
|
151
|
+
...CHIP_SIZE,
|
|
152
|
+
inlineSize: 'var(--thread-action-h, var(--space-8))',
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface ReactionBarProps {
|
|
156
|
+
readonly reactions: readonly ThreadReactionCount[]
|
|
157
|
+
readonly onReact?: ((key: ThreadReactionKey) => void) | undefined
|
|
158
|
+
readonly disabled?: boolean | undefined
|
|
159
|
+
readonly className?: string | undefined
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function ReactionBar({ reactions, onReact, disabled = false, className }: ReactionBarProps) {
|
|
163
|
+
const [pickerOpen, setPickerOpen] = useState(false)
|
|
164
|
+
const pickerId = useId()
|
|
165
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
166
|
+
const triggerRef = useRef<HTMLButtonElement>(null)
|
|
167
|
+
const panelRef = useRef<HTMLDivElement>(null)
|
|
168
|
+
|
|
169
|
+
const present = reactions.filter((reaction) => reaction.count > 0)
|
|
170
|
+
const canReact = onReact !== undefined && !disabled
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Close the picker, moving focus back to the trigger BEFORE the close.
|
|
174
|
+
*
|
|
175
|
+
* This ordering is the whole point. The panel below is always mounted and
|
|
176
|
+
* goes `aria-hidden` + `tabIndex={-1}` when it closes, so closing it while a
|
|
177
|
+
* reaction button inside it still holds focus strands that focus on an
|
|
178
|
+
* element that is not in the accessibility tree — browsers then drop it to
|
|
179
|
+
* `<body>` and the next Tab restarts from the top of the document. Calling
|
|
180
|
+
* `focus()` synchronously first means focus lands on the trigger while the
|
|
181
|
+
* panel is still open and focusable, and React's batching applies the close
|
|
182
|
+
* in the same commit.
|
|
183
|
+
*
|
|
184
|
+
* `restoreFocus` is false for exactly one path: a pointer press outside. The
|
|
185
|
+
* press is already choosing its own focus target, and pulling focus back to
|
|
186
|
+
* the trigger would fight it.
|
|
187
|
+
*/
|
|
188
|
+
const closePicker = useCallback((restoreFocus: boolean) => {
|
|
189
|
+
if (restoreFocus) triggerRef.current?.focus()
|
|
190
|
+
setPickerOpen(false)
|
|
191
|
+
}, [])
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Dismiss on a pointer press outside the bar.
|
|
195
|
+
*
|
|
196
|
+
* Bound only WHILE OPEN and removed by the effect's own cleanup, so there is
|
|
197
|
+
* no always-on document listener and nothing survives an unmount — a global
|
|
198
|
+
* listener registered once per bar for the life of the thread would be one
|
|
199
|
+
* per comment, all of them idle.
|
|
200
|
+
*
|
|
201
|
+
* `pointerdown` rather than `click`: it fires before focus moves, so the
|
|
202
|
+
* decision about where focus should land is made while the answer is still
|
|
203
|
+
* knowable. `focusout` was the other candidate and is wrong here — Safari
|
|
204
|
+
* does not focus a button on mousedown, so the picker would close before its
|
|
205
|
+
* own button's click handler ran.
|
|
206
|
+
*/
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
if (!pickerOpen) return undefined
|
|
209
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
210
|
+
const root = rootRef.current
|
|
211
|
+
if (root !== null && event.target instanceof Node && root.contains(event.target)) return
|
|
212
|
+
const panel = panelRef.current
|
|
213
|
+
// Restore only when focus is inside the panel we are about to hide.
|
|
214
|
+
closePicker(panel !== null && panel.contains(document.activeElement))
|
|
215
|
+
}
|
|
216
|
+
document.addEventListener('pointerdown', onPointerDown)
|
|
217
|
+
return () => {
|
|
218
|
+
document.removeEventListener('pointerdown', onPointerDown)
|
|
219
|
+
}
|
|
220
|
+
}, [closePicker, pickerOpen])
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<div
|
|
224
|
+
ref={rootRef}
|
|
225
|
+
className={cn('flex flex-col items-start', className)}
|
|
226
|
+
// APG: Escape closes an overlay and returns focus to its trigger. The
|
|
227
|
+
// handler sits on the bar rather than on the panel so it works from the
|
|
228
|
+
// trigger too, and it CONSUMES the key when it acted — an Escape aimed at
|
|
229
|
+
// this picker must not also close the drawer the thread is mounted in.
|
|
230
|
+
onKeyDown={(event) => {
|
|
231
|
+
if (event.key !== 'Escape' || !pickerOpen) return
|
|
232
|
+
event.preventDefault()
|
|
233
|
+
event.stopPropagation()
|
|
234
|
+
closePicker(true)
|
|
235
|
+
}}
|
|
236
|
+
>
|
|
237
|
+
<div className="flex flex-wrap items-center gap-[var(--space-1)]">
|
|
238
|
+
{present.map((reaction) => {
|
|
239
|
+
const meta = REACTION_META[reaction.key]
|
|
240
|
+
return (
|
|
241
|
+
<button
|
|
242
|
+
key={reaction.key}
|
|
243
|
+
type="button"
|
|
244
|
+
// aria-pressed carries the toggle semantics; the label carries the
|
|
245
|
+
// meaning, because the colour alone must never be the signal.
|
|
246
|
+
aria-pressed={reaction.viewerReacted}
|
|
247
|
+
aria-label={`${meta.label} — ${String(reaction.count)}`}
|
|
248
|
+
disabled={!canReact}
|
|
249
|
+
onClick={() => {
|
|
250
|
+
onReact?.(reaction.key)
|
|
251
|
+
}}
|
|
252
|
+
className={cn(
|
|
253
|
+
CHIP_BASE,
|
|
254
|
+
reaction.viewerReacted ? CHIP_ACTIVE : CHIP_RESTING,
|
|
255
|
+
)}
|
|
256
|
+
style={CHIP_SIZE}
|
|
257
|
+
>
|
|
258
|
+
<ReactionGlyph emoji={meta.emoji} />
|
|
259
|
+
<span>{reaction.count}</span>
|
|
260
|
+
</button>
|
|
261
|
+
)
|
|
262
|
+
})}
|
|
263
|
+
|
|
264
|
+
{canReact ? (
|
|
265
|
+
<button
|
|
266
|
+
ref={triggerRef}
|
|
267
|
+
type="button"
|
|
268
|
+
aria-label="Add a reaction"
|
|
269
|
+
aria-expanded={pickerOpen}
|
|
270
|
+
aria-controls={pickerId}
|
|
271
|
+
// Routed through `closePicker` on the way down so that EVERY close
|
|
272
|
+
// path restores focus, without a reader having to check which ones
|
|
273
|
+
// do. Focus is already here on a real click; this only matters as
|
|
274
|
+
// an invariant.
|
|
275
|
+
onClick={() => {
|
|
276
|
+
if (pickerOpen) closePicker(true)
|
|
277
|
+
else setPickerOpen(true)
|
|
278
|
+
}}
|
|
279
|
+
className={cn(CHIP_BASE, CHIP_RESTING, 'px-[var(--space-1)]')}
|
|
280
|
+
style={CHIP_SQUARE}
|
|
281
|
+
>
|
|
282
|
+
<SmilePlus size={12} strokeWidth={1.75} aria-hidden="true" />
|
|
283
|
+
</button>
|
|
284
|
+
) : null}
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
{/* Mounted, never conditionally rendered — correction 4 to the port.
|
|
288
|
+
`if (!open) return null` kills the open/close transition outright, so
|
|
289
|
+
the panel is always in the DOM and driven by `data-open`. Closed rows
|
|
290
|
+
leave the tab order via tabIndex rather than display:none, which would
|
|
291
|
+
make the grid-rows transition a no-op. */}
|
|
292
|
+
{canReact ? (
|
|
293
|
+
<div
|
|
294
|
+
ref={panelRef}
|
|
295
|
+
id={pickerId}
|
|
296
|
+
data-open={pickerOpen}
|
|
297
|
+
aria-hidden={!pickerOpen}
|
|
298
|
+
className={cn(
|
|
299
|
+
'grid grid-rows-[0fr] opacity-0 data-[open=true]:grid-rows-[1fr] data-[open=true]:opacity-100',
|
|
300
|
+
'transition-[grid-template-rows,opacity] duration-[var(--dur-fast)] ease-[var(--ease-out)]',
|
|
301
|
+
'motion-reduce:transition-none',
|
|
302
|
+
)}
|
|
303
|
+
>
|
|
304
|
+
<div className="overflow-hidden">
|
|
305
|
+
<div
|
|
306
|
+
className="flex flex-wrap items-center gap-[var(--space-1)]"
|
|
307
|
+
style={{ paddingBlockStart: 'var(--space-1)' }}
|
|
308
|
+
>
|
|
309
|
+
{THREAD_REACTION_KEYS.map((key) => {
|
|
310
|
+
const meta = REACTION_META[key]
|
|
311
|
+
return (
|
|
312
|
+
<button
|
|
313
|
+
key={key}
|
|
314
|
+
type="button"
|
|
315
|
+
tabIndex={pickerOpen ? 0 : -1}
|
|
316
|
+
aria-label={meta.label}
|
|
317
|
+
title={meta.label}
|
|
318
|
+
onClick={() => {
|
|
319
|
+
onReact?.(key)
|
|
320
|
+
// Focus first, then close — the button being pressed is
|
|
321
|
+
// the one that is about to become aria-hidden.
|
|
322
|
+
closePicker(true)
|
|
323
|
+
}}
|
|
324
|
+
className={cn(CHIP_BASE, CHIP_RESTING, 'px-0')}
|
|
325
|
+
style={CHIP_SQUARE}
|
|
326
|
+
>
|
|
327
|
+
<ReactionGlyph emoji={meta.emoji} />
|
|
328
|
+
</button>
|
|
329
|
+
)
|
|
330
|
+
})}
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
) : null}
|
|
335
|
+
</div>
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export interface CommentVoteProps {
|
|
340
|
+
readonly score: number
|
|
341
|
+
readonly viewerVote: ThreadVote
|
|
342
|
+
readonly onVote?: ((next: ThreadVote) => void) | undefined
|
|
343
|
+
readonly disabled?: boolean | undefined
|
|
344
|
+
readonly className?: string | undefined
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The vote variant (D8), exposed so the community surface can choose it in T6.
|
|
349
|
+
* Controlled: `viewerVote` comes in, `onVote` goes out, nothing is remembered.
|
|
350
|
+
*/
|
|
351
|
+
export function CommentVote({
|
|
352
|
+
score,
|
|
353
|
+
viewerVote,
|
|
354
|
+
onVote,
|
|
355
|
+
disabled = false,
|
|
356
|
+
className,
|
|
357
|
+
}: CommentVoteProps) {
|
|
358
|
+
const up = viewerVote === 1
|
|
359
|
+
const down = viewerVote === -1
|
|
360
|
+
const canVote = onVote !== undefined && !disabled
|
|
361
|
+
|
|
362
|
+
const buttonClass = (active: boolean) =>
|
|
363
|
+
cn(
|
|
364
|
+
'inline-flex items-center justify-center border border-transparent',
|
|
365
|
+
'transition-[color,background-color,transform] duration-[var(--dur-fast)] ease-[var(--ease-out)]',
|
|
366
|
+
'motion-reduce:transition-none',
|
|
367
|
+
'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
|
|
368
|
+
'active:scale-[0.96] motion-reduce:active:scale-100',
|
|
369
|
+
'disabled:cursor-not-allowed disabled:opacity-60',
|
|
370
|
+
active
|
|
371
|
+
? 'text-[rgb(var(--accent-ink))]'
|
|
372
|
+
: 'text-[rgb(var(--text-tertiary))] hover:bg-[rgb(var(--surface-hover))] hover:text-[rgb(var(--foreground))]',
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
return (
|
|
376
|
+
<div className={cn('inline-flex items-center gap-[var(--space-1)]', className)}>
|
|
377
|
+
<button
|
|
378
|
+
type="button"
|
|
379
|
+
aria-label={up ? 'Remove upvote' : 'Upvote'}
|
|
380
|
+
aria-pressed={up}
|
|
381
|
+
disabled={!canVote}
|
|
382
|
+
onClick={() => {
|
|
383
|
+
onVote?.(up ? 0 : 1)
|
|
384
|
+
}}
|
|
385
|
+
className={buttonClass(up)}
|
|
386
|
+
style={CHIP_SQUARE}
|
|
387
|
+
>
|
|
388
|
+
<ArrowBigUp size={14} strokeWidth={1.75} aria-hidden="true" />
|
|
389
|
+
</button>
|
|
390
|
+
{/* Reserved slot: a score going 9 → 10 must not shove the buttons. */}
|
|
391
|
+
<span
|
|
392
|
+
className="text-center text-[13px] font-semibold tabular-nums"
|
|
393
|
+
style={{
|
|
394
|
+
minInlineSize: '3ch',
|
|
395
|
+
color: up || down ? 'rgb(var(--accent-ink))' : 'rgb(var(--text-secondary))',
|
|
396
|
+
}}
|
|
397
|
+
>
|
|
398
|
+
{score}
|
|
399
|
+
</span>
|
|
400
|
+
<button
|
|
401
|
+
type="button"
|
|
402
|
+
aria-label={down ? 'Remove downvote' : 'Downvote'}
|
|
403
|
+
aria-pressed={down}
|
|
404
|
+
disabled={!canVote}
|
|
405
|
+
onClick={() => {
|
|
406
|
+
onVote?.(down ? 0 : -1)
|
|
407
|
+
}}
|
|
408
|
+
className={buttonClass(down)}
|
|
409
|
+
style={CHIP_SQUARE}
|
|
410
|
+
>
|
|
411
|
+
<ArrowBigDown size={14} strokeWidth={1.75} aria-hidden="true" />
|
|
412
|
+
</button>
|
|
413
|
+
</div>
|
|
414
|
+
)
|
|
415
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RelativeTime — "now" / "4m" / "3h" / "6d", then an absolute date.
|
|
3
|
+
*
|
|
4
|
+
* ADR-147 D16. Three rules, all of them load-bearing:
|
|
5
|
+
*
|
|
6
|
+
* • `now` is a PROP. Nothing here reads the clock. See use-now.ts.
|
|
7
|
+
* • The output sits inside `<time dateTime>` carrying the exact ISO value,
|
|
8
|
+
* so the precise timestamp survives for a screen reader, a copy-paste, and
|
|
9
|
+
* anything scraping the DOM — the relative string is a convenience layer
|
|
10
|
+
* over real data, not a replacement for it.
|
|
11
|
+
* • The slot has a `min-inline-size` and `tabular-nums`, so "now" becoming
|
|
12
|
+
* "10m" cannot reflow the byline. Layout stability: the box's size is
|
|
13
|
+
* decoupled from its contents.
|
|
14
|
+
*
|
|
15
|
+
* The crossover to absolute is seven days, matching D16. Relative labels stop
|
|
16
|
+
* being useful roughly when people stop counting in days, and an absolute date
|
|
17
|
+
* never changes, so nothing past the crossover can reflow anything.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { cn } from '../lib/utils'
|
|
21
|
+
|
|
22
|
+
const MINUTE = 60_000
|
|
23
|
+
const HOUR = 60 * MINUTE
|
|
24
|
+
const DAY = 24 * HOUR
|
|
25
|
+
const WEEK = 7 * DAY
|
|
26
|
+
|
|
27
|
+
/** ISO 8601 for the `datetime` attribute. Invalid input degrades to ''. */
|
|
28
|
+
export function toIsoTimestamp(value: number): string {
|
|
29
|
+
if (!Number.isFinite(value)) return ''
|
|
30
|
+
const date = new Date(value)
|
|
31
|
+
if (Number.isNaN(date.getTime())) return ''
|
|
32
|
+
return date.toISOString()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The full, unambiguous timestamp — used as the hover title. */
|
|
36
|
+
export function formatAbsoluteTime(value: number, locale?: string): string {
|
|
37
|
+
if (!Number.isFinite(value)) return ''
|
|
38
|
+
return new Date(value).toLocaleString(locale, {
|
|
39
|
+
dateStyle: 'medium',
|
|
40
|
+
timeStyle: 'short',
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Pure. Deterministic for a given (value, now) pair — that is the whole point.
|
|
46
|
+
* A future timestamp (clock skew between the server and the browser is normal)
|
|
47
|
+
* reads "now" rather than a negative age.
|
|
48
|
+
*/
|
|
49
|
+
export function formatRelativeTime(
|
|
50
|
+
value: number,
|
|
51
|
+
now: number,
|
|
52
|
+
locale?: string,
|
|
53
|
+
): string {
|
|
54
|
+
if (!Number.isFinite(value) || !Number.isFinite(now)) return ''
|
|
55
|
+
const elapsed = now - value
|
|
56
|
+
if (elapsed < MINUTE) return 'now'
|
|
57
|
+
if (elapsed < HOUR) return `${String(Math.floor(elapsed / MINUTE))}m`
|
|
58
|
+
if (elapsed < DAY) return `${String(Math.floor(elapsed / HOUR))}h`
|
|
59
|
+
if (elapsed < WEEK) return `${String(Math.floor(elapsed / DAY))}d`
|
|
60
|
+
|
|
61
|
+
const date = new Date(value)
|
|
62
|
+
const sameYear = date.getFullYear() === new Date(now).getFullYear()
|
|
63
|
+
return date.toLocaleDateString(locale, {
|
|
64
|
+
day: 'numeric',
|
|
65
|
+
month: 'short',
|
|
66
|
+
...(sameYear ? {} : { year: 'numeric' }),
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface RelativeTimeProps {
|
|
71
|
+
/** Epoch milliseconds. */
|
|
72
|
+
readonly value: number
|
|
73
|
+
/** Epoch milliseconds, injected from the thread root's single ticker. */
|
|
74
|
+
readonly now: number
|
|
75
|
+
readonly locale?: string | undefined
|
|
76
|
+
readonly className?: string | undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function RelativeTime({ value, now, locale, className }: RelativeTimeProps) {
|
|
80
|
+
const iso = toIsoTimestamp(value)
|
|
81
|
+
return (
|
|
82
|
+
<time
|
|
83
|
+
// An unparseable timestamp gets no `datetime` rather than an invalid one.
|
|
84
|
+
{...(iso === '' ? {} : { dateTime: iso })}
|
|
85
|
+
title={formatAbsoluteTime(value, locale)}
|
|
86
|
+
className={cn('inline-block shrink-0 tabular-nums', className)}
|
|
87
|
+
// Reserved slot: 3.5ch fits every relative label ("now" … "6d"). Past the
|
|
88
|
+
// seven-day crossover the label is an absolute date, which never changes.
|
|
89
|
+
style={{ minInlineSize: '3.5ch' }}
|
|
90
|
+
>
|
|
91
|
+
{formatRelativeTime(value, now, locale)}
|
|
92
|
+
</time>
|
|
93
|
+
)
|
|
94
|
+
}
|