@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
package/src/markdown.tsx
CHANGED
|
@@ -18,7 +18,8 @@ import type {
|
|
|
18
18
|
} from 'react'
|
|
19
19
|
import ReactMarkdown, { type Components } from 'react-markdown'
|
|
20
20
|
import remarkGfm from 'remark-gfm'
|
|
21
|
-
import { MarkdownCode } from './code-block'
|
|
21
|
+
import { MarkdownCode, MarkdownPre } from './code-block'
|
|
22
|
+
import { remarkUnderline } from './lib/remark-underline'
|
|
22
23
|
|
|
23
24
|
type NodeProps = HTMLAttributes<HTMLElement>
|
|
24
25
|
type AnchorProps = AnchorHTMLAttributes<HTMLAnchorElement>
|
|
@@ -93,10 +94,11 @@ const MARKDOWN_COMPONENTS: Components = {
|
|
|
93
94
|
{children}
|
|
94
95
|
</em>
|
|
95
96
|
),
|
|
96
|
-
// code: inline chip or
|
|
97
|
-
//
|
|
97
|
+
// code: inline chip or code block (CodeBlock via MarkdownCode). pre renders
|
|
98
|
+
// no element — CodeBlock owns its container — but it is what tells the code
|
|
99
|
+
// inside it that it is a block, language or not (see PreContext).
|
|
98
100
|
code: MarkdownCode,
|
|
99
|
-
pre:
|
|
101
|
+
pre: MarkdownPre,
|
|
100
102
|
blockquote: ({ children }: NodeProps) => (
|
|
101
103
|
<blockquote
|
|
102
104
|
className="my-4 border-l-4 py-1 pl-4 italic"
|
|
@@ -169,6 +171,19 @@ const MARKDOWN_COMPONENTS: Components = {
|
|
|
169
171
|
{children}
|
|
170
172
|
</del>
|
|
171
173
|
),
|
|
174
|
+
// Underline — `++text++` via remarkUnderline (ADR-148 D13). The line is a
|
|
175
|
+
// step softer than the ink so it reads as a deliberate underline, not a link.
|
|
176
|
+
u: ({ children }: NodeProps) => (
|
|
177
|
+
<u
|
|
178
|
+
className="underline-offset-2"
|
|
179
|
+
style={{
|
|
180
|
+
color: 'rgb(var(--foreground))',
|
|
181
|
+
textDecorationColor: 'rgb(var(--text-tertiary))',
|
|
182
|
+
}}
|
|
183
|
+
>
|
|
184
|
+
{children}
|
|
185
|
+
</u>
|
|
186
|
+
),
|
|
172
187
|
// Task-list checkboxes rendered by remark-gfm for "- [ ] ..." / "- [x] ...".
|
|
173
188
|
input: (props: InputHTMLAttributes<HTMLInputElement>) =>
|
|
174
189
|
props.type === 'checkbox' ? (
|
|
@@ -183,7 +198,10 @@ const MARKDOWN_COMPONENTS: Components = {
|
|
|
183
198
|
),
|
|
184
199
|
}
|
|
185
200
|
|
|
186
|
-
|
|
201
|
+
// THE remark plugin list. Every markdown surface in the package parses with
|
|
202
|
+
// this — `CommentBody` imports it rather than keeping its own, so GFM and the
|
|
203
|
+
// `++text++` underline (ADR-148 D13) cannot drift between surfaces.
|
|
204
|
+
const REMARK_PLUGINS = [remarkGfm, remarkUnderline]
|
|
187
205
|
|
|
188
206
|
export interface MarkdownRendererProps {
|
|
189
207
|
/** Raw markdown string to render. */
|
|
@@ -210,5 +228,6 @@ export function MarkdownRenderer({ children, className }: MarkdownRendererProps)
|
|
|
210
228
|
)
|
|
211
229
|
}
|
|
212
230
|
|
|
213
|
-
// Re-export for callers that
|
|
214
|
-
|
|
231
|
+
// Re-export for callers that drive ReactMarkdown themselves (CommentBody):
|
|
232
|
+
// the raw component map and the plugin list, so both surfaces stay one.
|
|
233
|
+
export { MARKDOWN_COMPONENTS as markdownComponents, REMARK_PLUGINS as markdownRemarkPlugins }
|
package/src/modal.tsx
CHANGED
|
@@ -89,9 +89,31 @@ interface ModalProps {
|
|
|
89
89
|
* when `title` is non-empty — the `<h2>` names the dialog then.
|
|
90
90
|
*/
|
|
91
91
|
ariaLabel?: string | undefined
|
|
92
|
+
/**
|
|
93
|
+
* Suppress the built-in header row (title + close) so the CALLER owns the
|
|
94
|
+
* whole chrome.
|
|
95
|
+
*
|
|
96
|
+
* Exists for one shape: a content component that must render identically
|
|
97
|
+
* inside `Modal` and `FloatingDrawer` (the Tasks item detail — one content
|
|
98
|
+
* component, two presentations). Without it that content carries its own
|
|
99
|
+
* header and the modal stacks a second, near-empty one with a second close
|
|
100
|
+
* button above it.
|
|
101
|
+
*
|
|
102
|
+
* The dialog still needs a NAME: pass `ariaLabel`, because there is no
|
|
103
|
+
* `<h2>` left to point `aria-labelledby` at.
|
|
104
|
+
*/
|
|
105
|
+
hideHeader?: boolean | undefined
|
|
92
106
|
}
|
|
93
107
|
|
|
94
|
-
function Modal({
|
|
108
|
+
function Modal({
|
|
109
|
+
isOpen,
|
|
110
|
+
onClose,
|
|
111
|
+
title,
|
|
112
|
+
children,
|
|
113
|
+
className,
|
|
114
|
+
ariaLabel,
|
|
115
|
+
hideHeader = false,
|
|
116
|
+
}: ModalProps) {
|
|
95
117
|
const panelRef = useRef<HTMLDivElement | null>(null)
|
|
96
118
|
const titleId = useId()
|
|
97
119
|
|
|
@@ -205,8 +227,8 @@ function Modal({ isOpen, onClose, title, children, className, ariaLabel }: Modal
|
|
|
205
227
|
ref={panelRef}
|
|
206
228
|
role="dialog"
|
|
207
229
|
aria-modal="true"
|
|
208
|
-
aria-labelledby={title ? titleId : undefined}
|
|
209
|
-
aria-label={title ? undefined : ariaLabel}
|
|
230
|
+
aria-labelledby={title && !hideHeader ? titleId : undefined}
|
|
231
|
+
aria-label={title && !hideHeader ? undefined : ariaLabel}
|
|
210
232
|
tabIndex={-1}
|
|
211
233
|
data-framed="true"
|
|
212
234
|
className={cn(
|
|
@@ -225,21 +247,23 @@ function Modal({ isOpen, onClose, title, children, className, ariaLabel }: Modal
|
|
|
225
247
|
60px tall for a 23px title — and because the button was taller
|
|
226
248
|
than the text, the BUTTON set the row height and the title
|
|
227
249
|
floated in slack it never asked for. */}
|
|
228
|
-
|
|
229
|
-
<
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
250
|
+
{hideHeader ? null : (
|
|
251
|
+
<div className="flex items-center justify-between gap-3 shrink-0 px-4 py-3">
|
|
252
|
+
<h2
|
|
253
|
+
id={titleId}
|
|
254
|
+
className="text-[15px] font-bold tracking-[-0.01em] text-[rgb(var(--foreground))]"
|
|
255
|
+
>
|
|
256
|
+
{title}
|
|
257
|
+
</h2>
|
|
258
|
+
<button
|
|
259
|
+
onClick={onClose}
|
|
260
|
+
className="inline-flex items-center justify-center w-6 h-6 shrink-0 rounded-[var(--radius-sm)] text-[rgb(var(--text-tertiary))] hover:bg-[rgb(var(--surface-hover))] hover:text-[rgb(var(--foreground))] transition-colors"
|
|
261
|
+
aria-label="Close"
|
|
262
|
+
>
|
|
263
|
+
<X className="h-[15px] w-[15px]" />
|
|
264
|
+
</button>
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
243
267
|
{renderBody(children)}
|
|
244
268
|
</div>
|
|
245
269
|
</div>
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProgressLedger — the per-task block bar.
|
|
3
|
+
*
|
|
4
|
+
* ADR-146's one piece of boldness, and the reason the port keeps a ledger
|
|
5
|
+
* rather than a percentage bar: it is COUNTABLE. You read 7 of 11 without
|
|
6
|
+
* reading a number, and each segment doubles as navigation into that group.
|
|
7
|
+
*
|
|
8
|
+
* Three rules it must not lose:
|
|
9
|
+
*
|
|
10
|
+
* 1. **Never colour-only.** The label row always carries the count AND the
|
|
11
|
+
* percentage as text (interface-standards §6), so completion is legible
|
|
12
|
+
* without perceiving hue.
|
|
13
|
+
* 2. **The track is grey structure, not a surface.** `--surface-inset` +
|
|
14
|
+
* 1px `--border`, exactly the "grey structure inside an opaque card" the
|
|
15
|
+
* data-display recipe requires (CLAUDE.md §2). Filled cells take the
|
|
16
|
+
* column hue off the tint ladder, so they composite opaque in both themes.
|
|
17
|
+
* 3. **A cell settles, it never pops.** Colour transitions over `--dur-base`
|
|
18
|
+
* (`.ds-ledger-cell` in styles.css) and `prefers-reduced-motion` jumps to
|
|
19
|
+
* the end state.
|
|
20
|
+
*
|
|
21
|
+
* Radius is the documented exception to the concentric ladder: cells are 3px
|
|
22
|
+
* because squareness is what makes them countable. The track is 5px so that
|
|
23
|
+
* 5 − 2px of padding = the cells' 3px and the chain still holds locally.
|
|
24
|
+
*
|
|
25
|
+
* The hue comes from an ancestor's `.hue hue-<key>` classes (see `hue.ts`);
|
|
26
|
+
* this primitive reads `--tint-fill` / `--tint-task` / `--h` and never names a
|
|
27
|
+
* colour of its own.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { CSSProperties, ReactNode } from 'react'
|
|
31
|
+
import { cn } from './lib/utils'
|
|
32
|
+
import { StatusRing } from './status'
|
|
33
|
+
|
|
34
|
+
/** One cell — a single task. */
|
|
35
|
+
export interface LedgerCell {
|
|
36
|
+
id: string
|
|
37
|
+
done: boolean
|
|
38
|
+
/** The task in flight. Rendered as an outlined cell, not a filled one. */
|
|
39
|
+
current?: boolean
|
|
40
|
+
/** Falls back to the cell id in the cell's title attribute. */
|
|
41
|
+
label?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One phase / group. A divider is drawn at every boundary between segments. */
|
|
45
|
+
export interface LedgerSegment {
|
|
46
|
+
id: string
|
|
47
|
+
label: string
|
|
48
|
+
cells: readonly LedgerCell[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Done, total and the rounded percentage over a set of segments. */
|
|
52
|
+
export interface LedgerProgress {
|
|
53
|
+
done: number
|
|
54
|
+
total: number
|
|
55
|
+
/** 0–100, rounded. `0` when there is nothing to measure. */
|
|
56
|
+
percent: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* THE completion calculation — one function, every consumer.
|
|
61
|
+
*
|
|
62
|
+
* The ledger prints these numbers as text, the card's ring draws them, and a
|
|
63
|
+
* board column aggregates its lane's items through this same function. A
|
|
64
|
+
* second implementation anywhere is how a filled meter ends up disagreeing
|
|
65
|
+
* with the percentage printed beside it (header-primitives §1, last row).
|
|
66
|
+
*/
|
|
67
|
+
export function ledgerProgress(segments: readonly LedgerSegment[]): LedgerProgress {
|
|
68
|
+
const cells = segments.flatMap((segment) => segment.cells)
|
|
69
|
+
const total = cells.length
|
|
70
|
+
const done = cells.filter((cell) => cell.done).length
|
|
71
|
+
return { done, total, percent: total === 0 ? 0 : Math.round((done / total) * 100) }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ProgressLedgerProps {
|
|
75
|
+
segments: readonly LedgerSegment[]
|
|
76
|
+
/** Leading word in the label row. Sentence case. */
|
|
77
|
+
label?: string
|
|
78
|
+
/** Optional middle term — the phase the item is in. */
|
|
79
|
+
phase?: string
|
|
80
|
+
/** `lg` is the detail-drawer size; `sm` is the card size. */
|
|
81
|
+
size?: 'sm' | 'lg'
|
|
82
|
+
/**
|
|
83
|
+
* Emitted when a segment is activated. Interactivity is OPT-IN: without
|
|
84
|
+
* this, segments render as inert `<span>`s and add no tab stops.
|
|
85
|
+
*
|
|
86
|
+
* Supplying it is a commitment to a destination. A board that wires an empty
|
|
87
|
+
* handler here puts one dead 12px-tall tab stop on the page per segment per
|
|
88
|
+
* card — six of the Tasks board's twenty tab stops did exactly that — which
|
|
89
|
+
* is worse than the control being absent. When it IS supplied, the segment
|
|
90
|
+
* is forced to a 24px minimum hit area (interface-standards §6), so it
|
|
91
|
+
* cannot come back as a 12px target.
|
|
92
|
+
*/
|
|
93
|
+
onSelectSegment?: (segmentId: string) => void
|
|
94
|
+
/**
|
|
95
|
+
* Draws a `<StatusRing>` at the head of the label row, showing the SAME
|
|
96
|
+
* percentage this ledger prints. It is decorative by construction — the
|
|
97
|
+
* number is already text two words away, and announcing it twice is the
|
|
98
|
+
* defect, not the feature.
|
|
99
|
+
*/
|
|
100
|
+
showRing?: boolean
|
|
101
|
+
/** Names the ledger for assistive tech, e.g. the item title. */
|
|
102
|
+
ariaLabel?: string
|
|
103
|
+
className?: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const TRACK_RADIUS = '5px'
|
|
107
|
+
const CELL_RADIUS = '3px'
|
|
108
|
+
|
|
109
|
+
export function ProgressLedger({
|
|
110
|
+
segments,
|
|
111
|
+
label = 'Progress',
|
|
112
|
+
phase,
|
|
113
|
+
size = 'sm',
|
|
114
|
+
onSelectSegment,
|
|
115
|
+
showRing = false,
|
|
116
|
+
ariaLabel,
|
|
117
|
+
className,
|
|
118
|
+
}: ProgressLedgerProps) {
|
|
119
|
+
const { done, total, percent } = ledgerProgress(segments)
|
|
120
|
+
const cellHeight = size === 'lg' ? 22 : 12
|
|
121
|
+
const cellMinWidth = size === 'lg' ? 12 : 5
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div
|
|
125
|
+
className={cn('flex flex-col', className)}
|
|
126
|
+
style={{ gap: 'var(--space-1)' }}
|
|
127
|
+
role="group"
|
|
128
|
+
aria-label={
|
|
129
|
+
ariaLabel
|
|
130
|
+
? `${label} for ${ariaLabel}: ${done} of ${total} done, ${percent} percent`
|
|
131
|
+
: `${label}: ${done} of ${total} done, ${percent} percent`
|
|
132
|
+
}
|
|
133
|
+
>
|
|
134
|
+
{/* Label row — `Progress · 7/11 · 64%`. The count is the reason the
|
|
135
|
+
ledger beats a bar, so it is never dropped for space. */}
|
|
136
|
+
<div
|
|
137
|
+
className="flex items-baseline text-[11px] leading-none"
|
|
138
|
+
style={{ gap: 'var(--space-2)', color: 'rgb(var(--text-muted))' }}
|
|
139
|
+
>
|
|
140
|
+
{showRing ? (
|
|
141
|
+
<StatusRing
|
|
142
|
+
percent={percent}
|
|
143
|
+
size={size === 'lg' ? 16 : 13}
|
|
144
|
+
strokeWidth={2}
|
|
145
|
+
decorative
|
|
146
|
+
// The 11px label row sits on its baseline; a ring has none, so it
|
|
147
|
+
// is nudged optically rather than mathematically.
|
|
148
|
+
style={{ alignSelf: 'center', marginBlockStart: '-1px' }}
|
|
149
|
+
/>
|
|
150
|
+
) : null}
|
|
151
|
+
<span>{label}</span>
|
|
152
|
+
{/* min-width slots: the card must not resize when a task is checked
|
|
153
|
+
and `9/11` becomes `10/11` (layout-stability rule). */}
|
|
154
|
+
<LedgerFigure style={{ minInlineSize: '3.5ch' }}>
|
|
155
|
+
{done}/{total}
|
|
156
|
+
</LedgerFigure>
|
|
157
|
+
{phase ? <span className="truncate">{phase}</span> : null}
|
|
158
|
+
<LedgerFigure
|
|
159
|
+
className="ms-auto font-semibold"
|
|
160
|
+
style={{
|
|
161
|
+
color: 'rgb(var(--muted-foreground))',
|
|
162
|
+
minInlineSize: '4ch',
|
|
163
|
+
textAlign: 'end',
|
|
164
|
+
}}
|
|
165
|
+
>
|
|
166
|
+
{percent}%
|
|
167
|
+
</LedgerFigure>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<div
|
|
171
|
+
className="flex items-stretch"
|
|
172
|
+
style={{
|
|
173
|
+
gap: '2px',
|
|
174
|
+
padding: '2px',
|
|
175
|
+
background: 'rgb(var(--surface-inset))',
|
|
176
|
+
border: '1px solid rgb(var(--border))',
|
|
177
|
+
borderRadius: TRACK_RADIUS,
|
|
178
|
+
}}
|
|
179
|
+
>
|
|
180
|
+
{total === 0 ? (
|
|
181
|
+
<span
|
|
182
|
+
className="block w-full"
|
|
183
|
+
style={{ height: cellHeight, borderRadius: CELL_RADIUS }}
|
|
184
|
+
aria-hidden="true"
|
|
185
|
+
/>
|
|
186
|
+
) : null}
|
|
187
|
+
{segments.map((segment, index) => (
|
|
188
|
+
<SegmentRun
|
|
189
|
+
key={segment.id}
|
|
190
|
+
segment={segment}
|
|
191
|
+
showDivider={index > 0}
|
|
192
|
+
cellHeight={cellHeight}
|
|
193
|
+
cellMinWidth={cellMinWidth}
|
|
194
|
+
onSelect={onSelectSegment}
|
|
195
|
+
/>
|
|
196
|
+
))}
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function SegmentRun({
|
|
203
|
+
segment,
|
|
204
|
+
showDivider,
|
|
205
|
+
cellHeight,
|
|
206
|
+
cellMinWidth,
|
|
207
|
+
onSelect,
|
|
208
|
+
}: {
|
|
209
|
+
segment: LedgerSegment
|
|
210
|
+
showDivider: boolean
|
|
211
|
+
cellHeight: number
|
|
212
|
+
cellMinWidth: number
|
|
213
|
+
onSelect?: ((segmentId: string) => void) | undefined
|
|
214
|
+
}) {
|
|
215
|
+
const doneCount = segment.cells.filter((cell) => cell.done).length
|
|
216
|
+
const cells = (
|
|
217
|
+
<>
|
|
218
|
+
{segment.cells.map((cell) => (
|
|
219
|
+
<span
|
|
220
|
+
key={cell.id}
|
|
221
|
+
className="ds-ledger-cell block"
|
|
222
|
+
title={cell.label ?? undefined}
|
|
223
|
+
style={{
|
|
224
|
+
flex: '1 1 0',
|
|
225
|
+
minWidth: cellMinWidth,
|
|
226
|
+
height: cellHeight,
|
|
227
|
+
borderRadius: CELL_RADIUS,
|
|
228
|
+
background: cell.done
|
|
229
|
+
? 'var(--tint-fill)'
|
|
230
|
+
: cell.current
|
|
231
|
+
? 'var(--tint-task)'
|
|
232
|
+
: 'rgb(var(--surface-card))',
|
|
233
|
+
boxShadow: cell.current && !cell.done ? 'inset 0 0 0 1px var(--h)' : undefined,
|
|
234
|
+
}}
|
|
235
|
+
/>
|
|
236
|
+
))}
|
|
237
|
+
</>
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
const runStyle = {
|
|
241
|
+
flex: `${Math.max(segment.cells.length, 1)} 1 0`,
|
|
242
|
+
display: 'flex',
|
|
243
|
+
gap: '2px',
|
|
244
|
+
minWidth: 0,
|
|
245
|
+
} as const
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<>
|
|
249
|
+
{/* Phase boundary. A 1px rule, not a gap — a gap at this scale reads as
|
|
250
|
+
a missing cell and would make the count wrong at a glance. */}
|
|
251
|
+
{showDivider ? (
|
|
252
|
+
<span
|
|
253
|
+
aria-hidden="true"
|
|
254
|
+
style={{
|
|
255
|
+
flex: 'none',
|
|
256
|
+
width: '1px',
|
|
257
|
+
background: 'rgb(var(--border-strong))',
|
|
258
|
+
marginBlock: '-2px',
|
|
259
|
+
marginInline: '2px',
|
|
260
|
+
}}
|
|
261
|
+
/>
|
|
262
|
+
) : null}
|
|
263
|
+
{onSelect ? (
|
|
264
|
+
<button
|
|
265
|
+
type="button"
|
|
266
|
+
onClick={() => onSelect(segment.id)}
|
|
267
|
+
aria-label={`${segment.label}: ${doneCount} of ${segment.cells.length} done`}
|
|
268
|
+
className="rounded-[3px] focus-visible:[box-shadow:var(--ring-focus)] focus-visible:outline-none"
|
|
269
|
+
style={{
|
|
270
|
+
...runStyle,
|
|
271
|
+
// The accessibility floor, enforced in the primitive rather than
|
|
272
|
+
// asked of every caller: a 12px cell row is not a 24px target, so
|
|
273
|
+
// the run grows to 24px and centres its cells inside it.
|
|
274
|
+
alignItems: 'center',
|
|
275
|
+
minBlockSize: '24px',
|
|
276
|
+
background: 'transparent',
|
|
277
|
+
border: 'none',
|
|
278
|
+
padding: 0,
|
|
279
|
+
}}
|
|
280
|
+
>
|
|
281
|
+
{cells}
|
|
282
|
+
</button>
|
|
283
|
+
) : (
|
|
284
|
+
<span style={runStyle}>{cells}</span>
|
|
285
|
+
)}
|
|
286
|
+
</>
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function LedgerFigure({
|
|
291
|
+
children,
|
|
292
|
+
className,
|
|
293
|
+
style,
|
|
294
|
+
}: {
|
|
295
|
+
children: ReactNode
|
|
296
|
+
className?: string
|
|
297
|
+
style?: CSSProperties
|
|
298
|
+
}) {
|
|
299
|
+
return (
|
|
300
|
+
<span className={cn('tabular-nums', className)} style={style}>
|
|
301
|
+
{children}
|
|
302
|
+
</span>
|
|
303
|
+
)
|
|
304
|
+
}
|