@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.
Files changed (142) hide show
  1. package/dist/chunk-RBYWGBQ2.js +2752 -0
  2. package/dist/chunk-RBYWGBQ2.js.map +1 -0
  3. package/dist/index.d.ts +5574 -57
  4. package/dist/index.js +21650 -11206
  5. package/dist/index.js.map +1 -1
  6. package/dist/rich-composer-impl-5NO443A6.js +1859 -0
  7. package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
  8. package/dist/styles.css +1570 -0
  9. package/dist/tokens.css +112 -0
  10. package/package.json +8 -1
  11. package/src/__tests__/avatar.test.tsx +272 -0
  12. package/src/__tests__/bar-chart.test.tsx +809 -0
  13. package/src/__tests__/board.test.tsx +420 -0
  14. package/src/__tests__/chart-math.test.ts +922 -0
  15. package/src/__tests__/chart-series.test.ts +339 -0
  16. package/src/__tests__/code-block.test.tsx +134 -0
  17. package/src/__tests__/display-popover.test.tsx +195 -0
  18. package/src/__tests__/display-store.test.tsx +307 -0
  19. package/src/__tests__/donut-chart.test.tsx +397 -0
  20. package/src/__tests__/dropdown-menu.test.tsx +156 -2
  21. package/src/__tests__/filter-menu.test.tsx +175 -0
  22. package/src/__tests__/gauge-ring.test.tsx +233 -0
  23. package/src/__tests__/line-chart.test.tsx +612 -0
  24. package/src/__tests__/ranked-bars.test.tsx +343 -0
  25. package/src/__tests__/remark-underline.test.ts +194 -0
  26. package/src/__tests__/sortable.test.tsx +394 -0
  27. package/src/__tests__/sparkline.test.tsx +368 -0
  28. package/src/__tests__/stat-layer.test.tsx +271 -0
  29. package/src/__tests__/stat-strip.test.tsx +175 -0
  30. package/src/__tests__/status.test.tsx +293 -0
  31. package/src/__tests__/tabs.test.tsx +303 -0
  32. package/src/__tests__/token-shape.test.ts +132 -2
  33. package/src/avatar.tsx +352 -0
  34. package/src/bar-chart.tsx +1214 -0
  35. package/src/board.tsx +658 -0
  36. package/src/chart-frame.tsx +960 -0
  37. package/src/chart-legend.tsx +304 -0
  38. package/src/chart-tooltip.tsx +267 -0
  39. package/src/code-block.tsx +62 -8
  40. package/src/delta-chip.tsx +263 -0
  41. package/src/detail/__tests__/activity-pane.test.tsx +369 -0
  42. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  43. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  44. package/src/detail/__tests__/field-row.test.tsx +357 -0
  45. package/src/detail/activity-pane.tsx +611 -0
  46. package/src/detail/calendar.tsx +355 -0
  47. package/src/detail/detail-divider.tsx +261 -0
  48. package/src/detail/detail-header.tsx +287 -0
  49. package/src/detail/detail-menu.tsx +254 -0
  50. package/src/detail/detail-surface.tsx +1110 -0
  51. package/src/detail/field-list.tsx +196 -0
  52. package/src/detail/field-row.tsx +1131 -0
  53. package/src/detail/index.ts +58 -0
  54. package/src/detail/segmented-choice.tsx +94 -0
  55. package/src/detail/types.ts +129 -0
  56. package/src/display-popover.tsx +487 -0
  57. package/src/display-store.tsx +301 -0
  58. package/src/donut-chart.tsx +988 -0
  59. package/src/dropdown-menu.tsx +290 -19
  60. package/src/filter-core/EXPORTS.md +85 -0
  61. package/src/filter-core/__tests__/columns.test.ts +159 -0
  62. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  63. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  64. package/src/filter-core/__tests__/operators.test.ts +235 -0
  65. package/src/filter-core/__tests__/state.test.ts +268 -0
  66. package/src/filter-core/__tests__/url.test.ts +350 -0
  67. package/src/filter-core/columns.ts +134 -0
  68. package/src/filter-core/date-utils.ts +38 -0
  69. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  70. package/src/filter-core/faceting.ts +120 -0
  71. package/src/filter-core/filter-fns.ts +335 -0
  72. package/src/filter-core/index.ts +105 -0
  73. package/src/filter-core/operators.ts +433 -0
  74. package/src/filter-core/state.ts +280 -0
  75. package/src/filter-core/types.ts +247 -0
  76. package/src/filter-core/url.ts +261 -0
  77. package/src/filter-dropdown.tsx +12 -0
  78. package/src/filter-menu.tsx +649 -0
  79. package/src/floating-drawer.tsx +19 -1
  80. package/src/gauge-ring.tsx +435 -0
  81. package/src/hue.ts +52 -0
  82. package/src/index.ts +303 -0
  83. package/src/kbd.tsx +27 -4
  84. package/src/lib/chart.ts +866 -0
  85. package/src/lib/focus.ts +43 -1
  86. package/src/lib/layer-stack.ts +30 -3
  87. package/src/lib/remark-underline.ts +443 -0
  88. package/src/lib/series.ts +169 -0
  89. package/src/line-chart.tsx +1176 -0
  90. package/src/markdown.tsx +26 -7
  91. package/src/modal.tsx +42 -18
  92. package/src/progress-ledger.tsx +304 -0
  93. package/src/ranked-bars.tsx +386 -0
  94. package/src/segmented-pill.tsx +32 -9
  95. package/src/sortable.tsx +520 -1
  96. package/src/sparkline.tsx +416 -0
  97. package/src/stat-card.tsx +376 -0
  98. package/src/stat-strip.tsx +327 -0
  99. package/src/status.tsx +215 -0
  100. package/src/styles.css +1570 -0
  101. package/src/tabs.tsx +206 -25
  102. package/src/task-card.tsx +610 -0
  103. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  104. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  105. package/src/thread/__tests__/emoji.test.ts +187 -0
  106. package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
  107. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  108. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  109. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  110. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  111. package/src/thread/__tests__/relative-time.test.ts +71 -0
  112. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  113. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  114. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  115. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  116. package/src/thread/__tests__/thread.test.tsx +742 -0
  117. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  118. package/src/thread/actions.tsx +196 -0
  119. package/src/thread/attachments.tsx +1071 -0
  120. package/src/thread/comment-body.tsx +148 -0
  121. package/src/thread/comment-tree.ts +182 -0
  122. package/src/thread/comment.tsx +967 -0
  123. package/src/thread/composer-footer.tsx +125 -0
  124. package/src/thread/composer.tsx +319 -0
  125. package/src/thread/emoji.ts +283 -0
  126. package/src/thread/index.ts +153 -0
  127. package/src/thread/link-preview.tsx +341 -0
  128. package/src/thread/markdown-format.ts +155 -0
  129. package/src/thread/markdown-spec.ts +754 -0
  130. package/src/thread/rail.tsx +372 -0
  131. package/src/thread/reactions.tsx +415 -0
  132. package/src/thread/relative-time.tsx +94 -0
  133. package/src/thread/rich-composer-impl.tsx +1601 -0
  134. package/src/thread/rich-composer.tsx +195 -0
  135. package/src/thread/scroll-caret.ts +37 -0
  136. package/src/thread/suggestion-list.tsx +182 -0
  137. package/src/thread/thread.tsx +718 -0
  138. package/src/thread/types.ts +232 -0
  139. package/src/thread/use-attachments.ts +598 -0
  140. package/src/thread/use-now.ts +73 -0
  141. package/src/thread/use-thread.ts +316 -0
  142. package/src/tokens.css +112 -0
@@ -0,0 +1,355 @@
1
+ /**
2
+ * MonthCalendar — the date editor's month grid, and the date-value helpers.
3
+ *
4
+ * `docs/design-briefs/tasks-item-detail-full.md` §4: "a calendar popover for
5
+ * dates (with an `Include time` toggle and a confirm)". This file is the grid;
6
+ * `FieldRow.Date` owns the toggle, the time field and the confirm.
7
+ *
8
+ * ## Values are strings, and they are LOCAL
9
+ *
10
+ * `YYYY-MM-DD`, or `YYYY-MM-DDTHH:mm` when a time is included — the same shape
11
+ * a `<input type="date">` and the wire both use. Every conversion here goes
12
+ * through the numeric parts, never `new Date('2026-09-06')`: that literal
13
+ * parses as UTC midnight, which renders as the fifth of September for
14
+ * everybody west of Greenwich. A date field that shows the wrong day in half
15
+ * the world is the classic version of this bug and it is entirely avoidable.
16
+ *
17
+ * ## The clock
18
+ *
19
+ * "Today" comes from the thread's shared ticker rather than `Date.now()` in a
20
+ * render path, so a test freezes it by passing `now` and no timer runs.
21
+ */
22
+
23
+ import { useEffect, useMemo, useRef, useState } from 'react'
24
+ import { ChevronLeft, ChevronRight } from 'lucide-react'
25
+ import { Button } from '../button'
26
+ import { cn } from '../lib/utils'
27
+ import { useNowTick } from '../thread/use-now'
28
+
29
+ export interface DateParts {
30
+ readonly year: number
31
+ /** 0-11, like `Date`. */
32
+ readonly month: number
33
+ readonly day: number
34
+ /** `HH:mm`, or null when the value is a plain date. */
35
+ readonly time: string | null
36
+ }
37
+
38
+ const VALUE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?/
39
+
40
+ /** Defensive parse: an unreadable value is "no value", never a throw. */
41
+ export function parseDateValue(value: string | null | undefined): DateParts | null {
42
+ if (typeof value !== 'string') return null
43
+ const match = VALUE_PATTERN.exec(value.trim())
44
+ if (match === null) return null
45
+ const [, y, m, d, hh, mm] = match
46
+ if (y === undefined || m === undefined || d === undefined) return null
47
+ const year = Number(y)
48
+ const month = Number(m) - 1
49
+ const day = Number(d)
50
+ if (month < 0 || month > 11 || day < 1 || day > 31) return null
51
+ const time = hh !== undefined && mm !== undefined ? `${hh}:${mm}` : null
52
+ return { year, month, day, time }
53
+ }
54
+
55
+ function pad(n: number): string {
56
+ return n < 10 ? `0${n}` : String(n)
57
+ }
58
+
59
+ export function formatDateValue(parts: DateParts): string {
60
+ const date = `${parts.year}-${pad(parts.month + 1)}-${pad(parts.day)}`
61
+ return parts.time === null ? date : `${date}T${parts.time}`
62
+ }
63
+
64
+ /** Local midday, so a DST shift cannot roll the day over. */
65
+ function toDate(parts: DateParts): Date {
66
+ return new Date(parts.year, parts.month, parts.day, 12, 0, 0, 0)
67
+ }
68
+
69
+ /** The value as a person reads it. Falls back to the raw string if unparseable. */
70
+ export function formatDateDisplay(
71
+ value: string | null | undefined,
72
+ locale?: string | undefined,
73
+ ): string {
74
+ const parts = parseDateValue(value)
75
+ if (parts === null) return typeof value === 'string' ? value : ''
76
+ const day = new Intl.DateTimeFormat(locale, {
77
+ year: 'numeric',
78
+ month: 'short',
79
+ day: 'numeric',
80
+ }).format(toDate(parts))
81
+ if (parts.time === null) return day
82
+ const [hh = '0', mm = '0'] = parts.time.split(':')
83
+ const withTime = new Date(parts.year, parts.month, parts.day, Number(hh), Number(mm))
84
+ const time = new Intl.DateTimeFormat(locale, {
85
+ hour: 'numeric',
86
+ minute: '2-digit',
87
+ }).format(withTime)
88
+ return `${day}, ${time}`
89
+ }
90
+
91
+ function daysInMonth(year: number, month: number): number {
92
+ return new Date(year, month + 1, 0).getDate()
93
+ }
94
+
95
+ function sameDay(a: DateParts | null, year: number, month: number, day: number): boolean {
96
+ return a !== null && a.year === year && a.month === month && a.day === day
97
+ }
98
+
99
+ export interface MonthCalendarProps {
100
+ /** The selected day, or null. */
101
+ selected: DateParts | null
102
+ onSelect: (next: { year: number; month: number; day: number }) => void
103
+ locale?: string | undefined
104
+ /** 0 = Sunday. Default 0. */
105
+ weekStartsOn?: number | undefined
106
+ /** Frozen clock, epoch ms. Omit and the shared ticker supplies "today". */
107
+ now?: number | undefined
108
+ className?: string | undefined
109
+ }
110
+
111
+ export function MonthCalendar({
112
+ selected,
113
+ onSelect,
114
+ locale,
115
+ weekStartsOn = 0,
116
+ now,
117
+ className,
118
+ }: MonthCalendarProps) {
119
+ const ticked = useNowTick(now === undefined)
120
+ const clock = now ?? ticked
121
+ const today = useMemo(() => new Date(clock), [clock])
122
+
123
+ const [view, setView] = useState<{ year: number; month: number }>(() =>
124
+ selected === null
125
+ ? { year: today.getFullYear(), month: today.getMonth() }
126
+ : { year: selected.year, month: selected.month },
127
+ )
128
+ // The roving tab stop. One button in the grid is tabbable; the arrows move
129
+ // which one, so the grid is one stop rather than 35.
130
+ const [focusedDay, setFocusedDay] = useState<number>(selected?.day ?? today.getDate())
131
+ const moveFocusRef = useRef(false)
132
+ const gridRef = useRef<HTMLDivElement | null>(null)
133
+
134
+ // Follow the selection when it changes from outside (a different item).
135
+ //
136
+ // Keyed on the three NUMBERS, never on `selected` itself. The parent builds
137
+ // that object by parsing a string on every render, so a fresh identity
138
+ // arrives each time — an effect depending on it would set state, re-render,
139
+ // receive another fresh object and run again. That is the render loop the
140
+ // repo has already been bitten by once (`useResumableDraft`, ADR-116 Phase
141
+ // B), and it presents as an out-of-memory test rather than as a loop.
142
+ const selectedYear = selected === null ? null : selected.year
143
+ const selectedMonth = selected === null ? null : selected.month
144
+ const selectedDay = selected === null ? null : selected.day
145
+ useEffect(() => {
146
+ if (selectedYear === null || selectedMonth === null || selectedDay === null) return
147
+ setView((current) =>
148
+ current.year === selectedYear && current.month === selectedMonth
149
+ ? current
150
+ : { year: selectedYear, month: selectedMonth },
151
+ )
152
+ setFocusedDay(selectedDay)
153
+ }, [selectedYear, selectedMonth, selectedDay])
154
+
155
+ useEffect(() => {
156
+ if (!moveFocusRef.current) return
157
+ moveFocusRef.current = false
158
+ const node = gridRef.current?.querySelector<HTMLElement>('[data-day][tabindex="0"]')
159
+ node?.focus()
160
+ }, [focusedDay, view])
161
+
162
+ const total = daysInMonth(view.year, view.month)
163
+ const firstWeekday = new Date(view.year, view.month, 1).getDay()
164
+ const lead = (firstWeekday - weekStartsOn + 7) % 7
165
+
166
+ const weekdayLabels = useMemo(() => {
167
+ const formatter = new Intl.DateTimeFormat(locale, { weekday: 'short' })
168
+ // 2024-01-07 was a Sunday; offsetting from it gives every weekday name
169
+ // without touching the current date.
170
+ return Array.from({ length: 7 }, (_, index) =>
171
+ formatter.format(new Date(2024, 0, 7 + ((index + weekStartsOn) % 7))),
172
+ )
173
+ }, [locale, weekStartsOn])
174
+
175
+ const monthLabel = useMemo(
176
+ () =>
177
+ new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(
178
+ new Date(view.year, view.month, 1),
179
+ ),
180
+ [locale, view.year, view.month],
181
+ )
182
+
183
+ const dayLabel = useMemo(
184
+ () => new Intl.DateTimeFormat(locale, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }),
185
+ [locale],
186
+ )
187
+
188
+ const shift = (delta: number) => {
189
+ const next = new Date(view.year, view.month, focusedDay + delta)
190
+ moveFocusRef.current = true
191
+ setView({ year: next.getFullYear(), month: next.getMonth() })
192
+ setFocusedDay(next.getDate())
193
+ }
194
+
195
+ const shiftMonth = (delta: number) => {
196
+ const next = new Date(view.year, view.month + delta, 1)
197
+ const year = next.getFullYear()
198
+ const month = next.getMonth()
199
+ setView({ year, month })
200
+ setFocusedDay((current) => Math.min(current, daysInMonth(year, month)))
201
+ }
202
+
203
+ const cells: (number | null)[] = [
204
+ ...Array.from({ length: lead }, () => null),
205
+ ...Array.from({ length: total }, (_, index) => index + 1),
206
+ ]
207
+ while (cells.length % 7 !== 0) cells.push(null)
208
+ const rows: (number | null)[][] = []
209
+ for (let index = 0; index < cells.length; index += 7) {
210
+ rows.push(cells.slice(index, index + 7))
211
+ }
212
+
213
+ return (
214
+ <div data-slot="month-calendar" className={cn('flex flex-col', className)} style={{ gap: 'var(--space-2)' }}>
215
+ <div className="flex items-center justify-between" style={{ gap: 'var(--space-2)' }}>
216
+ <Button
217
+ variant="icon"
218
+ size="sm"
219
+ aria-label="Previous month"
220
+ onClick={() => shiftMonth(-1)}
221
+ >
222
+ <ChevronLeft size={15} strokeWidth={2} aria-hidden="true" />
223
+ </Button>
224
+ <span
225
+ aria-live="polite"
226
+ className="text-[12.5px] font-semibold"
227
+ style={{ color: 'rgb(var(--foreground))' }}
228
+ >
229
+ {monthLabel}
230
+ </span>
231
+ <Button variant="icon" size="sm" aria-label="Next month" onClick={() => shiftMonth(1)}>
232
+ <ChevronRight size={15} strokeWidth={2} aria-hidden="true" />
233
+ </Button>
234
+ </div>
235
+
236
+ <div
237
+ ref={gridRef}
238
+ role="grid"
239
+ aria-label={monthLabel}
240
+ onKeyDown={(event) => {
241
+ const moves: Readonly<Record<string, number>> = {
242
+ ArrowLeft: -1,
243
+ ArrowRight: 1,
244
+ ArrowUp: -7,
245
+ ArrowDown: 7,
246
+ PageUp: -total,
247
+ PageDown: total,
248
+ }
249
+ const delta = moves[event.key]
250
+ if (delta !== undefined) {
251
+ event.preventDefault()
252
+ shift(delta)
253
+ return
254
+ }
255
+ if (event.key === 'Home') {
256
+ event.preventDefault()
257
+ shift(-((focusedDay - 1) % 7))
258
+ } else if (event.key === 'End') {
259
+ event.preventDefault()
260
+ shift(6 - ((focusedDay - 1) % 7))
261
+ }
262
+ }}
263
+ >
264
+ <div role="row" className="grid grid-cols-7" style={{ gap: 'var(--space-1)' }}>
265
+ {weekdayLabels.map((label) => (
266
+ <span
267
+ key={label}
268
+ role="columnheader"
269
+ className="flex h-6 items-center justify-center text-[10.5px] font-semibold uppercase tracking-[0.06em]"
270
+ style={{ color: 'rgb(var(--text-tertiary))' }}
271
+ >
272
+ {label.slice(0, 2)}
273
+ </span>
274
+ ))}
275
+ </div>
276
+ {rows.map((row, rowIndex) => (
277
+ <div
278
+ key={`row-${String(rowIndex)}`}
279
+ role="row"
280
+ className="grid grid-cols-7"
281
+ style={{ gap: 'var(--space-1)' }}
282
+ >
283
+ {row.map((day, cellIndex) => {
284
+ if (day === null) {
285
+ return (
286
+ <span
287
+ key={`blank-${String(rowIndex)}-${String(cellIndex)}`}
288
+ role="gridcell"
289
+ aria-hidden="true"
290
+ className="h-7"
291
+ />
292
+ )
293
+ }
294
+ const isSelected = sameDay(selected, view.year, view.month, day)
295
+ const isToday =
296
+ today.getFullYear() === view.year &&
297
+ today.getMonth() === view.month &&
298
+ today.getDate() === day
299
+ return (
300
+ // The CELL is the gridcell; the button inside keeps its native
301
+ // role. `role="gridcell"` was on the <button>, which overwrote
302
+ // it — a grid full of cells that a screen reader could not
303
+ // announce as buttons, and buttons it could not announce as
304
+ // cells. `aria-selected` belongs on the cell too (it is a
305
+ // gridcell state; a plain button does not support it).
306
+ <div
307
+ key={day}
308
+ role="gridcell"
309
+ aria-selected={isSelected}
310
+ >
311
+ <button
312
+ type="button"
313
+ data-day={day}
314
+ aria-label={dayLabel.format(new Date(view.year, view.month, day, 12))}
315
+ // The cell that IS today, announced as such rather than left
316
+ // to the underline and the extra font weight.
317
+ {...(isToday ? { 'aria-current': 'date' as const } : {})}
318
+ tabIndex={day === focusedDay ? 0 : -1}
319
+ onClick={() => {
320
+ setFocusedDay(day)
321
+ onSelect({ year: view.year, month: view.month, day })
322
+ }}
323
+ className={cn(
324
+ // `w-full`: the button used to BE the grid item and
325
+ // stretched to the column; now the cell wrapper does, so
326
+ // the button has to fill it or the hit area shrinks to the
327
+ // width of the digits.
328
+ 'flex h-7 w-full items-center justify-center text-[12px] tabular-nums',
329
+ 'transition-[background-color,color,border-color] duration-[var(--dur-fast)] ease-[var(--ease-out)]',
330
+ 'motion-reduce:transition-none',
331
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
332
+ isSelected
333
+ ? 'border border-[rgb(var(--accent))] bg-[rgb(var(--surface-card))] font-semibold text-[rgb(var(--accent-ink))] [box-shadow:var(--shadow-sm)]'
334
+ : 'border border-transparent text-[rgb(var(--foreground))] hover:bg-[rgb(var(--surface-hover))]',
335
+ )}
336
+ style={{
337
+ borderRadius: 'var(--radius-xs)',
338
+ // Today is marked by weight as well as colour, so it is
339
+ // legible without perceiving hue.
340
+ fontWeight: isToday && !isSelected ? 700 : undefined,
341
+ textDecoration: isToday ? 'underline' : undefined,
342
+ textUnderlineOffset: isToday ? '3px' : undefined,
343
+ }}
344
+ >
345
+ {day}
346
+ </button>
347
+ </div>
348
+ )
349
+ })}
350
+ </div>
351
+ ))}
352
+ </div>
353
+ </div>
354
+ )
355
+ }
@@ -0,0 +1,261 @@
1
+ /**
2
+ * DetailDivider — the seam between the fields and the activity pane.
3
+ *
4
+ * §8: "The divider is a real `separator` with keyboard resize." That is
5
+ * already `ResizableHandle` (WAI-ARIA splitter: `role="separator"`,
6
+ * `aria-valuenow/min/max`, arrows, PageUp/PageDown, Home), so this file adds
7
+ * no second implementation of any of it. What it adds is the three things the
8
+ * handle deliberately does not own:
9
+ *
10
+ * 1. AN IN-FLOW STRIP. `ResizableHandle` is `position: absolute` — it
11
+ * decorates an edge of an already-positioned pane. The detail's split is
12
+ * a flex row (or column) where the divider is a sibling BETWEEN two
13
+ * panes, so it needs a box of its own to sit in.
14
+ * 2. THE VISIBLE LINE. The handle paints only on hover and drag; at rest it
15
+ * is transparent. A split with no line at rest is the "flat / floating"
16
+ * defect (CLAUDE.md §2) — grey structure inside an opaque surface is
17
+ * required, not a candidate for cleanup. So the strip carries a 1px
18
+ * `--border` hairline down its centre.
19
+ * 3. THE COLLAPSE CAPSULE, and the double-click that goes with it. Modelled
20
+ * on `lenses/social/components/ResizableSplit.tsx`, which is the shape
21
+ * the owner already tuned: a hover-revealed capsule centred on the seam
22
+ * with one button per direction, and the seam's double-click toggling
23
+ * collapse rather than resetting the size.
24
+ *
25
+ * ORIENTATION, and why the mapping is inverted. `value` is the size of the
26
+ * pane AFTER the divider (the activity pane). `ResizableHandle` inverts the
27
+ * drag delta for its `left` and `top` sides, which is exactly the sign we
28
+ * need: dragging the seam toward the inline start grows the pane that sits
29
+ * after it. So `side` → `left` and `bottom` → `top`, and dragging always
30
+ * grows the thing you are dragging away from.
31
+ *
32
+ * ## Why double-click is intercepted in the CAPTURE phase
33
+ *
34
+ * `ResizableHandle` binds double-click to "reset to `defaultValue`", and the
35
+ * handle covers the whole strip, so a listener on the strip would fire in
36
+ * ADDITION to it — one gesture doing two things. Taking the event in the
37
+ * capture phase and stopping it there is the only way to REPLACE the handle's
38
+ * behaviour without forking the handle, and the reset is not lost: it is what
39
+ * `Home` does, which is where the WAI-ARIA splitter pattern puts it anyway.
40
+ *
41
+ * ## Where the collapse's FOCUS move lives (it is not here)
42
+ *
43
+ * Pressing a capsule button collapses a pane, which sets `hidden` on this
44
+ * whole strip — so the button unmounts under the operator's finger and focus
45
+ * falls to `<body>`. The fix is to park focus on the `RestoreRail` the
46
+ * collapse leaves behind, and that CANNOT be done from this file: the rail is
47
+ * rendered by `DetailSurface` and mounts in the same commit this strip
48
+ * disappears in, so there is no node here to move focus to.
49
+ *
50
+ * So the divider stays presentational — it calls the callbacks it is given
51
+ * and knows nothing about what they do. `DetailSurface`'s `collapseTo` owns
52
+ * the state change AND the focus that follows it, in a `useLayoutEffect`
53
+ * after the commit. If you are here because focus is being dropped on
54
+ * collapse, read `collapseTo` in `detail-surface.tsx`.
55
+ */
56
+
57
+ import type { ReactNode } from 'react'
58
+ import { ChevronsDown, ChevronsLeft, ChevronsRight, ChevronsUp } from 'lucide-react'
59
+ import { ResizableHandle } from '../resizable'
60
+ import { cn } from '../lib/utils'
61
+ import type { DetailPaneSide } from './types'
62
+
63
+ export interface DetailDividerProps {
64
+ /**
65
+ * Where the resizable pane sits: `side` (a vertical seam, activity at the
66
+ * inline end) or `bottom` (a horizontal seam, activity below). Comes from
67
+ * `detailPaneSide(mode)` — never chosen independently.
68
+ */
69
+ orientation: DetailPaneSide
70
+ /** Size of the pane after the seam, in px. */
71
+ value: number
72
+ onChange: (next: number) => void
73
+ /** Fires once on release / keypress. Persist from here, not `onChange`. */
74
+ onCommit?: ((next: number) => void) | undefined
75
+ min: number
76
+ max: number
77
+ /** Home target, and what the capsule restores to. Defaults to the midpoint. */
78
+ defaultValue?: number | undefined
79
+ /** Keyboard nudge, px. Default 16. */
80
+ step?: number | undefined
81
+ /** Required. Names what is being resized, not "divider". */
82
+ 'aria-label': string
83
+ disabled?: boolean | undefined
84
+ /**
85
+ * Takes the strip out of the layout AND the tab ring. Set while a pane is
86
+ * collapsed: there is nothing left to resize, and a separator reporting a
87
+ * range it cannot move through is worse than no separator.
88
+ */
89
+ hidden?: boolean | undefined
90
+ /** Collapses the pane BEFORE the seam. Omitted, no button is drawn. */
91
+ onCollapseFields?: (() => void) | undefined
92
+ /** Collapses the pane AFTER the seam. Omitted, no button is drawn. */
93
+ onCollapseAside?: (() => void) | undefined
94
+ /** Double-click on the seam. Omitted, double-click does nothing. */
95
+ onToggleCollapse?: (() => void) | undefined
96
+ className?: string | undefined
97
+ }
98
+
99
+ export function DetailDivider({
100
+ orientation,
101
+ value,
102
+ onChange,
103
+ onCommit,
104
+ min,
105
+ max,
106
+ defaultValue,
107
+ step,
108
+ 'aria-label': ariaLabel,
109
+ disabled,
110
+ hidden,
111
+ onCollapseFields,
112
+ onCollapseAside,
113
+ onToggleCollapse,
114
+ className,
115
+ }: DetailDividerProps) {
116
+ const vertical = orientation === 'side'
117
+ const CollapseFieldsIcon = vertical ? ChevronsLeft : ChevronsUp
118
+ const CollapseAsideIcon = vertical ? ChevronsRight : ChevronsDown
119
+ const hasCapsule = onCollapseFields !== undefined || onCollapseAside !== undefined
120
+
121
+ return (
122
+ <div
123
+ data-slot="detail-divider"
124
+ data-orientation={vertical ? 'vertical' : 'horizontal'}
125
+ hidden={hidden === true}
126
+ onDoubleClickCapture={
127
+ onToggleCollapse === undefined
128
+ ? undefined
129
+ : (event) => {
130
+ // Capture, and stopped here: see the header note. Without this
131
+ // the handle's own reset runs on the same gesture.
132
+ event.stopPropagation()
133
+ onToggleCollapse()
134
+ }
135
+ }
136
+ className={cn(
137
+ 'relative shrink-0',
138
+ vertical ? 'w-1 self-stretch' : 'h-1 w-full',
139
+ className,
140
+ )}
141
+ >
142
+ {/* The hairline. Centred in the 4px strip so the handle's hover fill
143
+ reads as thickening the existing line rather than introducing one. */}
144
+ <span
145
+ aria-hidden="true"
146
+ className="pointer-events-none absolute"
147
+ style={{
148
+ background: 'rgb(var(--border))',
149
+ ...(vertical
150
+ ? { insetBlock: 0, insetInlineStart: 'calc(50% - 0.5px)', inlineSize: '1px' }
151
+ : { insetInline: 0, insetBlockStart: 'calc(50% - 0.5px)', blockSize: '1px' }),
152
+ }}
153
+ />
154
+ <ResizableHandle
155
+ side={vertical ? 'left' : 'top'}
156
+ value={value}
157
+ onChange={onChange}
158
+ min={min}
159
+ max={max}
160
+ step={step ?? 16}
161
+ aria-label={ariaLabel}
162
+ disabled={disabled ?? false}
163
+ {...(onCommit === undefined ? {} : { onCommit })}
164
+ {...(defaultValue === undefined ? {} : { defaultValue })}
165
+ />
166
+
167
+ {/* The capsule. Revealed by `:hover` / `:focus-within` on the strip
168
+ (styles.css) rather than by state, so a keyboard user reaches the
169
+ buttons by Tab and the reveal follows the focus ring. It is
170
+ ABSOLUTE, so revealing it shifts nothing. */}
171
+ {hasCapsule ? (
172
+ <div
173
+ data-slot="detail-collapse-capsule"
174
+ className={cn(
175
+ 'absolute z-[1] flex items-center',
176
+ vertical ? 'flex-col' : 'flex-row',
177
+ )}
178
+ style={{
179
+ // A QUARTER along the seam, not half. Centred, the capsule covers
180
+ // the seam's midpoint — which is exactly where a pointer goes to
181
+ // grab it — and the buttons swallow the press, so the divider
182
+ // stops being draggable at its most obvious grab point. The
183
+ // reference splitter gets away with centring because its channel
184
+ // is 28px wide and the capsule leaves a grip between the buttons;
185
+ // this seam is 4px, so the control moves instead.
186
+ insetBlockStart: vertical ? '25%' : '50%',
187
+ insetInlineStart: vertical ? '50%' : '25%',
188
+ transform: 'translate(-50%, -50%)',
189
+ gap: '1px',
190
+ // An OPAQUE raised chrome. `--surface-overlay` here would read as
191
+ // a grey ghost over whichever pane it overlaps (CLAUDE.md §2);
192
+ // `--muted` is the public opaque neutral until `--surface-raised`
193
+ // lands (ADR-063).
194
+ background: 'rgb(var(--muted))',
195
+ border: '1px solid rgb(var(--border))',
196
+ borderRadius: 'var(--radius-full)',
197
+ padding: 'var(--space-1)',
198
+ boxShadow: 'var(--shadow-md)',
199
+ }}
200
+ >
201
+ {onCollapseFields === undefined ? null : (
202
+ <CapsuleButton
203
+ label="Collapse the fields pane"
204
+ onClick={onCollapseFields}
205
+ >
206
+ <CollapseFieldsIcon size={14} strokeWidth={1.5} aria-hidden="true" />
207
+ </CapsuleButton>
208
+ )}
209
+ {onCollapseAside === undefined ? null : (
210
+ <CapsuleButton label="Collapse the activity pane" onClick={onCollapseAside}>
211
+ <CollapseAsideIcon size={14} strokeWidth={1.5} aria-hidden="true" />
212
+ </CapsuleButton>
213
+ )}
214
+ </div>
215
+ ) : null}
216
+ </div>
217
+ )
218
+ }
219
+
220
+ /**
221
+ * One button in the capsule.
222
+ *
223
+ * `onPointerDown` is stopped: the handle underneath starts a drag on
224
+ * pointerdown, so without this every collapse click would also nudge the seam
225
+ * by however far the pointer moved between press and release.
226
+ */
227
+ function CapsuleButton({
228
+ label,
229
+ onClick,
230
+ children,
231
+ }: {
232
+ label: string
233
+ onClick: () => void
234
+ children: ReactNode
235
+ }) {
236
+ return (
237
+ <button
238
+ type="button"
239
+ aria-label={label}
240
+ title={label}
241
+ data-slot="detail-collapse-button"
242
+ onClick={onClick}
243
+ onPointerDown={(event) => event.stopPropagation()}
244
+ onDoubleClick={(event) => event.stopPropagation()}
245
+ className={cn(
246
+ 'inline-flex items-center justify-center rounded-[var(--radius-full)]',
247
+ 'transition-colors duration-[var(--dur-fast)] ease-[var(--ease-out)]',
248
+ 'motion-reduce:transition-none',
249
+ 'hover:bg-[rgb(var(--surface-hover))] hover:text-[rgb(var(--foreground))]',
250
+ 'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
251
+ )}
252
+ style={{
253
+ inlineSize: 'var(--space-6)',
254
+ blockSize: 'var(--space-6)',
255
+ color: 'rgb(var(--text-secondary))',
256
+ }}
257
+ >
258
+ {children}
259
+ </button>
260
+ )
261
+ }