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