@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,610 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TaskCard — the board card.
|
|
3
|
+
*
|
|
4
|
+
* `docs/design-briefs/tasks-board-design-language.md` §2 + §3, as corrected by
|
|
5
|
+
* `tasks-board-interface-standards.md` §1 (radius), §2 (surface) and §3
|
|
6
|
+
* (grouping). ADR-146 D13.
|
|
7
|
+
*
|
|
8
|
+
* "Dynamic and more informative" concretely means: **every row is optional
|
|
9
|
+
* except the title.** The card grows to fit what the item actually has and
|
|
10
|
+
* reserves nothing for what it does not. It is therefore not a template with
|
|
11
|
+
* placeholders — an item with no note, no media and no dates renders as three
|
|
12
|
+
* short lines, and one with everything renders tall.
|
|
13
|
+
*
|
|
14
|
+
* ## The five groups (interface-standards §3)
|
|
15
|
+
*
|
|
16
|
+
* Grouped with SPACE, not lines — `--space-1` within a group, `--space-3`
|
|
17
|
+
* between groups, and no dividers inside a card:
|
|
18
|
+
*
|
|
19
|
+
* 1. tags + overflow menu
|
|
20
|
+
* 2. eyebrow · title · note
|
|
21
|
+
* 3. media
|
|
22
|
+
* 4. embedded panel, then the ledger
|
|
23
|
+
* 5. date chip · avatars · counts
|
|
24
|
+
*
|
|
25
|
+
* ## The radius chain
|
|
26
|
+
*
|
|
27
|
+
* Column `--radius-2xl` (24) − `--space-2` (8) = card `--radius-lg` (16);
|
|
28
|
+
* card 16 − media inset `--space-2` (8) = media `--radius-sm` (8). Text rows
|
|
29
|
+
* pad `--space-3` and are outside the chain because text has no corner. Chips
|
|
30
|
+
* sit flat on the card, so they are `--radius-sm` and outside it too.
|
|
31
|
+
*
|
|
32
|
+
* ## Why the card is not a `<button>`, and why it is not a drag handle either
|
|
33
|
+
*
|
|
34
|
+
* It holds a menu, a vote control and clickable ledger segments; a button
|
|
35
|
+
* cannot legally contain those. The TITLE is the actionable element instead,
|
|
36
|
+
* so the card is fully keyboard-operable without nesting interactive content,
|
|
37
|
+
* and the article carries a POINTER click on top of it — one target for the
|
|
38
|
+
* mouse, one real control for the keyboard, one `onOpen`.
|
|
39
|
+
*
|
|
40
|
+
* The same reasoning is why the card is not the drag handle. The article gets
|
|
41
|
+
* `setNodeRef` and `style` — it is what dnd-kit measures and transforms — and
|
|
42
|
+
* NOTHING else. The activators (`onPointerDown`, `onKeyDown`) and the aria
|
|
43
|
+
* wiring both ride the visible grip. Spreading the listeners on the article
|
|
44
|
+
* made every pointer-down anywhere on the card the start of a drag, which is
|
|
45
|
+
* why the card read `cursor: grab` and could not be clicked.
|
|
46
|
+
*
|
|
47
|
+
* The hue comes from an ancestor's `.hue hue-<key>` pair (see `hue.ts`).
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { useState, type ComponentPropsWithoutRef } from 'react'
|
|
51
|
+
import type {
|
|
52
|
+
CSSProperties,
|
|
53
|
+
HTMLAttributes,
|
|
54
|
+
MouseEvent as ReactMouseEvent,
|
|
55
|
+
ReactNode,
|
|
56
|
+
} from 'react'
|
|
57
|
+
import { Flag, GripVertical, ImageOff } from 'lucide-react'
|
|
58
|
+
import { cn } from './lib/utils'
|
|
59
|
+
import { hueClass, hueForKey } from './hue'
|
|
60
|
+
|
|
61
|
+
type CardHeadingLevel = 2 | 3 | 4 | 5
|
|
62
|
+
|
|
63
|
+
function CardTitleHeading({
|
|
64
|
+
level,
|
|
65
|
+
...rest
|
|
66
|
+
}: { level: CardHeadingLevel } & ComponentPropsWithoutRef<'h4'>) {
|
|
67
|
+
const Tag = `h${level}` as const
|
|
68
|
+
return <Tag {...rest} />
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface TaskCardMedia {
|
|
72
|
+
/** Resolved by the caller — the primitive never builds a URL. */
|
|
73
|
+
src: string
|
|
74
|
+
alt: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface TaskCardCount {
|
|
78
|
+
/** A Lucide icon at `strokeWidth={1.5}` — it sits beside 400-weight text. */
|
|
79
|
+
icon: ReactNode
|
|
80
|
+
value: number
|
|
81
|
+
/** Sentence-case, for the accessible name: "comments", "attachments". */
|
|
82
|
+
label: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface TaskCardAvatar {
|
|
86
|
+
id: string
|
|
87
|
+
name: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface TaskCardDate {
|
|
91
|
+
/** Operator language — "Due", "Target launch", "Launched". Never a schema word. */
|
|
92
|
+
label: string
|
|
93
|
+
value: string
|
|
94
|
+
/** A terminal column reads in `--success-ink` rather than the neutral. */
|
|
95
|
+
terminal?: boolean
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface TaskCardDragProps {
|
|
99
|
+
/** The measured/transformed node. Carries no handlers — see the file header. */
|
|
100
|
+
setNodeRef?: ((node: HTMLElement | null) => void) | undefined
|
|
101
|
+
/**
|
|
102
|
+
* The drag activator, pointer AND keyboard, plus dnd-kit's aria wiring.
|
|
103
|
+
* Rendered as a visible grip — in group 1 when that row exists for its own
|
|
104
|
+
* reasons, otherwise floated into the title row — so a drag starts from one
|
|
105
|
+
* named, focusable control and the card itself stays clickable.
|
|
106
|
+
*/
|
|
107
|
+
activatorProps?: HTMLAttributes<HTMLElement> | undefined
|
|
108
|
+
/** dnd-kit's handle ref, so a finished keyboard drag restores focus to the grip. */
|
|
109
|
+
activatorRef?: ((node: HTMLElement | null) => void) | undefined
|
|
110
|
+
style?: CSSProperties | undefined
|
|
111
|
+
isDragging?: boolean | undefined
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface TaskCardProps {
|
|
115
|
+
/**
|
|
116
|
+
* Document-outline level for the card title. Default 4.
|
|
117
|
+
* Should sit one below the column heading so the outline has no skip.
|
|
118
|
+
*/
|
|
119
|
+
headingLevel?: CardHeadingLevel
|
|
120
|
+
title: string
|
|
121
|
+
eyebrow?: string
|
|
122
|
+
note?: string
|
|
123
|
+
tags?: readonly string[]
|
|
124
|
+
/** Tags shown before a `+N` chip takes over. */
|
|
125
|
+
maxTags?: number
|
|
126
|
+
/**
|
|
127
|
+
* Consumer-rendered chips alongside the tags — Community's LensChip, a
|
|
128
|
+
* priority pill. Group 1 is "what this card is labelled", and a caller with
|
|
129
|
+
* its own chip vocabulary should not have to fork the card to use it.
|
|
130
|
+
*/
|
|
131
|
+
chips?: ReactNode
|
|
132
|
+
media?: readonly TaskCardMedia[]
|
|
133
|
+
/** Declared up front so the card cannot reflow as an image loads. */
|
|
134
|
+
mediaAspectRatio?: string
|
|
135
|
+
/** An embedded sub-panel — option tiles, a mini widget. */
|
|
136
|
+
panel?: ReactNode
|
|
137
|
+
/** Typically a <ProgressLedger>. Any node; the card owns only its slot. */
|
|
138
|
+
ledger?: ReactNode
|
|
139
|
+
date?: TaskCardDate
|
|
140
|
+
avatars?: readonly TaskCardAvatar[]
|
|
141
|
+
maxAvatars?: number
|
|
142
|
+
counts?: readonly TaskCardCount[]
|
|
143
|
+
/**
|
|
144
|
+
* Rendered after the counts at the end of the footer. This is the seam that
|
|
145
|
+
* makes the board primitive generic over its CARD BODY rather than over its
|
|
146
|
+
* data (ADR-146 D2) — Community passes its vote control here, beside the
|
|
147
|
+
* comment count, and neither consumer forks the card.
|
|
148
|
+
*/
|
|
149
|
+
footerEnd?: ReactNode
|
|
150
|
+
/** Overflow menu trigger. Supply an `aria-label`; the card cannot know it. */
|
|
151
|
+
menu?: ReactNode
|
|
152
|
+
/**
|
|
153
|
+
* Opens the item. Makes the title an actionable button AND the whole card a
|
|
154
|
+
* pointer target. Omit for a static card — a card with no `onOpen` gets no
|
|
155
|
+
* pointer cursor and no hover lift, so it never claims an affordance it
|
|
156
|
+
* cannot honour.
|
|
157
|
+
*/
|
|
158
|
+
onOpen?: () => void
|
|
159
|
+
/** Archived / shipped treatment — dimmed, never hidden. */
|
|
160
|
+
dimmed?: boolean
|
|
161
|
+
drag?: TaskCardDragProps
|
|
162
|
+
/** Rendering inside a DragOverlay: no hover lift, a heavier shadow. */
|
|
163
|
+
isOverlay?: boolean
|
|
164
|
+
className?: string
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function TaskCard({
|
|
168
|
+
headingLevel = 4,
|
|
169
|
+
title,
|
|
170
|
+
eyebrow,
|
|
171
|
+
note,
|
|
172
|
+
tags,
|
|
173
|
+
maxTags = 3,
|
|
174
|
+
chips,
|
|
175
|
+
media,
|
|
176
|
+
mediaAspectRatio = '16 / 10',
|
|
177
|
+
panel,
|
|
178
|
+
ledger,
|
|
179
|
+
date,
|
|
180
|
+
avatars,
|
|
181
|
+
maxAvatars = 4,
|
|
182
|
+
counts,
|
|
183
|
+
footerEnd,
|
|
184
|
+
menu,
|
|
185
|
+
onOpen,
|
|
186
|
+
dimmed = false,
|
|
187
|
+
drag,
|
|
188
|
+
isOverlay = false,
|
|
189
|
+
className,
|
|
190
|
+
}: TaskCardProps) {
|
|
191
|
+
const shownTags = tags?.slice(0, maxTags) ?? []
|
|
192
|
+
const hiddenTags = (tags?.length ?? 0) - shownTags.length
|
|
193
|
+
const hasFooter = Boolean(date || avatars?.length || counts?.length || footerEnd)
|
|
194
|
+
const grip = drag?.activatorProps
|
|
195
|
+
// Group 1 is "what this card is LABELLED". A grip is not a label, so a card
|
|
196
|
+
// with no tags, no chips and no menu does not get a group for it — the grip
|
|
197
|
+
// floats into the title row instead. Rendering an otherwise-empty 24px row
|
|
198
|
+
// on every card was pure overhead on the Tasks board, where no card has any
|
|
199
|
+
// of the three.
|
|
200
|
+
const hasTopRow = shownTags.length > 0 || Boolean(chips) || Boolean(menu)
|
|
201
|
+
const gripInTitleRow = Boolean(grip) && !hasTopRow
|
|
202
|
+
const interactive = Boolean(onOpen) && !isOverlay
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The card's POINTER target.
|
|
206
|
+
*
|
|
207
|
+
* The keyboard path is the title `<button>` below — one real control, with
|
|
208
|
+
* the title as its accessible name — so this handler deliberately adds no
|
|
209
|
+
* `tabIndex` and no `role` to the article. A `role="button"` here would take
|
|
210
|
+
* presentational children and erase the grip, the overflow menu and the
|
|
211
|
+
* footer's own controls from assistive tech.
|
|
212
|
+
*
|
|
213
|
+
* Clicks that started on a control inside the card belong to that control:
|
|
214
|
+
* the grip, the menu, a ledger segment, a vote button, a link — and the
|
|
215
|
+
* title button, which calls `onOpen` itself and must not call it twice.
|
|
216
|
+
*/
|
|
217
|
+
const handleCardClick = (event: ReactMouseEvent<HTMLElement>) => {
|
|
218
|
+
if (!onOpen) return
|
|
219
|
+
const origin = event.target
|
|
220
|
+
if (
|
|
221
|
+
origin instanceof Element &&
|
|
222
|
+
origin.closest('button, a, input, select, textarea, [role="button"], [role="menuitem"]')
|
|
223
|
+
) {
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
onOpen()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<article
|
|
231
|
+
ref={drag?.setNodeRef}
|
|
232
|
+
data-interactive={interactive ? 'true' : undefined}
|
|
233
|
+
data-overlay={isOverlay ? 'true' : undefined}
|
|
234
|
+
{...(interactive ? { onClick: handleCardClick } : {})}
|
|
235
|
+
className={cn('ds-board-card flex flex-col', className)}
|
|
236
|
+
style={{
|
|
237
|
+
background: 'rgb(var(--surface-card))',
|
|
238
|
+
// Structure, not depth: near-invisible, but in dark mode it is the
|
|
239
|
+
// only thing separating the card from the column behind it.
|
|
240
|
+
border: '1px solid rgb(var(--border))',
|
|
241
|
+
borderRadius: 'var(--radius-lg)',
|
|
242
|
+
// The SHADOW is not here. It was, and an inline box-shadow outranks
|
|
243
|
+
// every stylesheet rule, so `.ds-board-card:hover`'s --shadow-sm ->
|
|
244
|
+
// --shadow-md step had never once applied: the 1px lift was the whole
|
|
245
|
+
// of the hover feedback and the focus step could not work at all. The
|
|
246
|
+
// resting and overlay shadows now live in styles.css beside the states
|
|
247
|
+
// that step them.
|
|
248
|
+
paddingBlock: 'var(--space-3)',
|
|
249
|
+
gap: 'var(--space-3)',
|
|
250
|
+
opacity: dimmed ? 0.6 : 1,
|
|
251
|
+
...drag?.style,
|
|
252
|
+
}}
|
|
253
|
+
>
|
|
254
|
+
{/* GROUP 1 — drag grip, tags, overflow menu. */}
|
|
255
|
+
{hasTopRow ? (
|
|
256
|
+
<div
|
|
257
|
+
className="flex items-center flex-wrap"
|
|
258
|
+
style={{ gap: 'var(--space-1)', paddingInline: 'var(--space-3)' }}
|
|
259
|
+
>
|
|
260
|
+
{grip ? (
|
|
261
|
+
<CardGrip grip={grip} gripRef={drag?.activatorRef} title={title} />
|
|
262
|
+
) : null}
|
|
263
|
+
{shownTags.map((tag) => (
|
|
264
|
+
<TagChip key={tag}>#{tag.toLowerCase()}</TagChip>
|
|
265
|
+
))}
|
|
266
|
+
{hiddenTags > 0 ? <TagChip>+{hiddenTags}</TagChip> : null}
|
|
267
|
+
{chips}
|
|
268
|
+
{menu ? <span className="ms-auto shrink-0">{menu}</span> : null}
|
|
269
|
+
</div>
|
|
270
|
+
) : null}
|
|
271
|
+
|
|
272
|
+
{/* GROUP 2 — eyebrow, title, note. When group 1 has nothing of its own
|
|
273
|
+
to hold, the drag grip rides here rather than owning a row. */}
|
|
274
|
+
<div
|
|
275
|
+
className="flex items-start"
|
|
276
|
+
style={{ gap: 'var(--space-1)', paddingInline: 'var(--space-3)' }}
|
|
277
|
+
>
|
|
278
|
+
{gripInTitleRow && grip ? (
|
|
279
|
+
<CardGrip grip={grip} gripRef={drag?.activatorRef} title={title} inTitleRow />
|
|
280
|
+
) : null}
|
|
281
|
+
<div className="flex flex-col min-w-0 flex-1" style={{ gap: 'var(--space-1)' }}>
|
|
282
|
+
{eyebrow ? (
|
|
283
|
+
<span
|
|
284
|
+
className="text-[11px] leading-[1.3]"
|
|
285
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
286
|
+
>
|
|
287
|
+
{eyebrow}
|
|
288
|
+
</span>
|
|
289
|
+
) : null}
|
|
290
|
+
{/* `h4`, one level under the column's `h3` — the card is nested inside
|
|
291
|
+
the column, and the heading tree should say so. When the card is
|
|
292
|
+
openable the button lives INSIDE the heading rather than replacing
|
|
293
|
+
it, so the title is still reachable by heading navigation. */}
|
|
294
|
+
{/* No line clamp. A clamped title with no tooltip and no drawer is a
|
|
295
|
+
title the reader cannot finish — and the card is variable-height by
|
|
296
|
+
design, so it has somewhere to put the second and third line. */}
|
|
297
|
+
<CardTitleHeading
|
|
298
|
+
level={headingLevel}
|
|
299
|
+
className="ds-board-card-title text-[15px] font-semibold leading-[1.3]"
|
|
300
|
+
style={{ margin: 0, color: 'rgb(var(--foreground))' }}
|
|
301
|
+
>
|
|
302
|
+
{onOpen ? (
|
|
303
|
+
<button
|
|
304
|
+
type="button"
|
|
305
|
+
onClick={onOpen}
|
|
306
|
+
className="text-start rounded-[var(--radius-sm)] focus-visible:[box-shadow:var(--ring-focus)] focus-visible:outline-none"
|
|
307
|
+
style={{
|
|
308
|
+
background: 'transparent',
|
|
309
|
+
border: 'none',
|
|
310
|
+
padding: 0,
|
|
311
|
+
font: 'inherit',
|
|
312
|
+
color: 'inherit',
|
|
313
|
+
}}
|
|
314
|
+
>
|
|
315
|
+
{title}
|
|
316
|
+
</button>
|
|
317
|
+
) : (
|
|
318
|
+
title
|
|
319
|
+
)}
|
|
320
|
+
</CardTitleHeading>
|
|
321
|
+
{note ? (
|
|
322
|
+
<p
|
|
323
|
+
className="text-[12.5px] leading-[1.45] line-clamp-2"
|
|
324
|
+
style={{ margin: 0, color: 'rgb(var(--text-tertiary))' }}
|
|
325
|
+
>
|
|
326
|
+
{note}
|
|
327
|
+
</p>
|
|
328
|
+
) : null}
|
|
329
|
+
</div>
|
|
330
|
+
</div>
|
|
331
|
+
|
|
332
|
+
{/* GROUP 3 — media. */}
|
|
333
|
+
{media && media[0] ? (
|
|
334
|
+
<div style={{ paddingInline: 'var(--space-2)' }}>
|
|
335
|
+
<CardMedia
|
|
336
|
+
media={media[0]}
|
|
337
|
+
extra={media.length - 1}
|
|
338
|
+
aspectRatio={mediaAspectRatio}
|
|
339
|
+
/>
|
|
340
|
+
</div>
|
|
341
|
+
) : null}
|
|
342
|
+
|
|
343
|
+
{/* GROUP 4 — embedded panel, then the ledger. */}
|
|
344
|
+
{panel ? <div style={{ paddingInline: 'var(--space-2)' }}>{panel}</div> : null}
|
|
345
|
+
{ledger ? <div style={{ paddingInline: 'var(--space-3)' }}>{ledger}</div> : null}
|
|
346
|
+
|
|
347
|
+
{/* GROUP 5 — date chip, avatars, counts. Wraps rather than clips. */}
|
|
348
|
+
{hasFooter ? (
|
|
349
|
+
<div
|
|
350
|
+
className="ds-board-card-footer flex items-center flex-wrap"
|
|
351
|
+
style={{ gap: 'var(--space-2)', paddingInline: 'var(--space-3)' }}
|
|
352
|
+
>
|
|
353
|
+
{date ? <DateChip date={date} /> : null}
|
|
354
|
+
{avatars && avatars.length > 0 ? (
|
|
355
|
+
<AvatarStack avatars={avatars} max={maxAvatars} />
|
|
356
|
+
) : null}
|
|
357
|
+
<span className="ms-auto flex items-center" style={{ gap: 'var(--space-2)' }}>
|
|
358
|
+
{counts?.map((count) => <CountChip key={count.label} count={count} />)}
|
|
359
|
+
{footerEnd}
|
|
360
|
+
</span>
|
|
361
|
+
</div>
|
|
362
|
+
) : null}
|
|
363
|
+
</article>
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* THE drag activator — pointer and keyboard both — rendered as a visible grip.
|
|
369
|
+
*
|
|
370
|
+
* `grip` carries dnd-kit's listeners AND its attributes, so this is the one
|
|
371
|
+
* element a drag can start from and the one element assistive tech is told
|
|
372
|
+
* about. `aria-label` comes after the spread deliberately: dnd-kit's
|
|
373
|
+
* `aria-roledescription` describes the gesture, the label names the card.
|
|
374
|
+
*
|
|
375
|
+
* Lives in group 1 when that row exists for its own reasons, and floats into
|
|
376
|
+
* the title row when it does not — so it never conjures a row of its own.
|
|
377
|
+
*/
|
|
378
|
+
function CardGrip({
|
|
379
|
+
grip,
|
|
380
|
+
gripRef,
|
|
381
|
+
title,
|
|
382
|
+
inTitleRow = false,
|
|
383
|
+
}: {
|
|
384
|
+
grip: HTMLAttributes<HTMLElement>
|
|
385
|
+
gripRef?: ((node: HTMLElement | null) => void) | undefined
|
|
386
|
+
title: string
|
|
387
|
+
inTitleRow?: boolean
|
|
388
|
+
}) {
|
|
389
|
+
return (
|
|
390
|
+
<button
|
|
391
|
+
type="button"
|
|
392
|
+
ref={gripRef}
|
|
393
|
+
{...grip}
|
|
394
|
+
aria-label={`Reorder ${title}`}
|
|
395
|
+
className="ds-board-card-grip inline-grid place-items-center shrink-0 cursor-grab active:cursor-grabbing focus-visible:[box-shadow:var(--ring-focus)] focus-visible:outline-none"
|
|
396
|
+
style={{
|
|
397
|
+
inlineSize: '24px',
|
|
398
|
+
blockSize: '24px',
|
|
399
|
+
marginInlineStart: 'calc(var(--space-1) * -1)',
|
|
400
|
+
// Optical, not mathematical: a 24px box beside a 19.5px first line
|
|
401
|
+
// sits low unless it is nudged up by the difference's half.
|
|
402
|
+
...(inTitleRow ? { marginBlockStart: '-2px' } : {}),
|
|
403
|
+
borderRadius: 'var(--radius-sm)',
|
|
404
|
+
background: 'transparent',
|
|
405
|
+
border: 'none',
|
|
406
|
+
color: 'rgb(var(--text-muted))',
|
|
407
|
+
touchAction: 'none',
|
|
408
|
+
}}
|
|
409
|
+
>
|
|
410
|
+
<GripVertical size={14} strokeWidth={1.5} aria-hidden="true" />
|
|
411
|
+
</button>
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Lens-local label. Inset well on the card, never a filled accent pill. */
|
|
416
|
+
function TagChip({ children }: { children: ReactNode }) {
|
|
417
|
+
return (
|
|
418
|
+
<span
|
|
419
|
+
className="inline-flex items-center text-[11px] font-medium leading-none"
|
|
420
|
+
style={{
|
|
421
|
+
paddingInline: 'var(--space-2)',
|
|
422
|
+
paddingBlock: '4px',
|
|
423
|
+
borderRadius: 'var(--radius-sm)',
|
|
424
|
+
background: 'rgb(var(--surface-inset))',
|
|
425
|
+
color: 'rgb(var(--muted-foreground))',
|
|
426
|
+
}}
|
|
427
|
+
>
|
|
428
|
+
{children}
|
|
429
|
+
</span>
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function DateChip({ date }: { date: TaskCardDate }) {
|
|
434
|
+
return (
|
|
435
|
+
<span
|
|
436
|
+
className="inline-flex items-center text-[11.5px] font-medium leading-none tabular-nums"
|
|
437
|
+
style={{
|
|
438
|
+
gap: '5px',
|
|
439
|
+
paddingInline: 'var(--space-2)',
|
|
440
|
+
paddingBlock: '5px',
|
|
441
|
+
borderRadius: 'var(--radius-sm)',
|
|
442
|
+
border: '1px solid rgb(var(--border))',
|
|
443
|
+
color: date.terminal ? 'rgb(var(--success-ink))' : 'rgb(var(--text-tertiary))',
|
|
444
|
+
}}
|
|
445
|
+
>
|
|
446
|
+
{/* strokeWidth 1.5 — this sits beside 400-weight meta text. */}
|
|
447
|
+
<FlagGlyph />
|
|
448
|
+
<span className="sr-only">{date.label}: </span>
|
|
449
|
+
{date.value}
|
|
450
|
+
</span>
|
|
451
|
+
)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The flag is imported rather than inlined so the icon rule holds even inside
|
|
456
|
+
* `packages/ui`, where an inline `<svg>` would be legal.
|
|
457
|
+
*/
|
|
458
|
+
function FlagGlyph() {
|
|
459
|
+
return <Flag size={12} strokeWidth={1.5} aria-hidden="true" />
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function CountChip({ count }: { count: TaskCardCount }) {
|
|
463
|
+
return (
|
|
464
|
+
<span
|
|
465
|
+
className="inline-flex items-center text-[11.5px] leading-none"
|
|
466
|
+
style={{
|
|
467
|
+
gap: '5px',
|
|
468
|
+
// 24px minimum hit-area height even though it is not a control, so a
|
|
469
|
+
// count sitting beside the overflow menu does not shrink the row.
|
|
470
|
+
minHeight: '24px',
|
|
471
|
+
paddingInline: 'var(--space-2)',
|
|
472
|
+
borderRadius: 'var(--radius-sm)',
|
|
473
|
+
// Bordered, not filled — a count is metadata, not an action.
|
|
474
|
+
border: '1px solid rgb(var(--border))',
|
|
475
|
+
color: 'rgb(var(--text-tertiary))',
|
|
476
|
+
}}
|
|
477
|
+
>
|
|
478
|
+
{count.icon}
|
|
479
|
+
{/* min-width slot + tabular-nums: a new comment must not resize the card. */}
|
|
480
|
+
<span className="tabular-nums" style={{ minInlineSize: '2ch', textAlign: 'end' }}>
|
|
481
|
+
{count.value}
|
|
482
|
+
</span>
|
|
483
|
+
<span className="sr-only">{count.label}</span>
|
|
484
|
+
</span>
|
|
485
|
+
)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function AvatarStack({
|
|
489
|
+
avatars,
|
|
490
|
+
max,
|
|
491
|
+
}: {
|
|
492
|
+
avatars: readonly TaskCardAvatar[]
|
|
493
|
+
max: number
|
|
494
|
+
}) {
|
|
495
|
+
const shown = avatars.slice(0, max)
|
|
496
|
+
const extra = avatars.length - shown.length
|
|
497
|
+
return (
|
|
498
|
+
<span className="inline-flex items-center" style={{ gap: '2px' }}>
|
|
499
|
+
{shown.map((person) => (
|
|
500
|
+
<span
|
|
501
|
+
key={person.id}
|
|
502
|
+
// Its own hue, so two people on a card are distinguishable. Stable
|
|
503
|
+
// per name, because an avatar that changes colour reads as someone
|
|
504
|
+
// else. Opaque by construction — it is a tint-ladder rung.
|
|
505
|
+
className={cn(hueClass(hueForKey(person.id || person.name)), 'inline-grid place-items-center text-[9.5px] font-bold')}
|
|
506
|
+
style={{
|
|
507
|
+
width: '22px',
|
|
508
|
+
height: '22px',
|
|
509
|
+
borderRadius: 'var(--radius-full)',
|
|
510
|
+
background: 'var(--tint-group)',
|
|
511
|
+
border: '1px solid var(--tint-edge)',
|
|
512
|
+
// The INK rung, not the raw hue: `--h` on `--tint-group` measures
|
|
513
|
+
// 2.77:1 (slate) / 3.13:1 (blue) in LIGHT mode at 9.5px/700.
|
|
514
|
+
// `--tint-ink` mixes toward --foreground, so it darkens in light
|
|
515
|
+
// and lightens in dark instead of being tuned for one theme.
|
|
516
|
+
color: 'var(--tint-ink)',
|
|
517
|
+
}}
|
|
518
|
+
title={person.name}
|
|
519
|
+
>
|
|
520
|
+
{initials(person.name)}
|
|
521
|
+
<span className="sr-only">{person.name}</span>
|
|
522
|
+
</span>
|
|
523
|
+
))}
|
|
524
|
+
{extra > 0 ? (
|
|
525
|
+
<span
|
|
526
|
+
className="inline-grid place-items-center text-[10px] font-semibold tabular-nums"
|
|
527
|
+
style={{
|
|
528
|
+
width: '22px',
|
|
529
|
+
height: '22px',
|
|
530
|
+
borderRadius: 'var(--radius-full)',
|
|
531
|
+
background: 'rgb(var(--surface-inset))',
|
|
532
|
+
color: 'rgb(var(--text-tertiary))',
|
|
533
|
+
}}
|
|
534
|
+
>
|
|
535
|
+
+{extra}
|
|
536
|
+
</span>
|
|
537
|
+
) : null}
|
|
538
|
+
</span>
|
|
539
|
+
)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function CardMedia({
|
|
543
|
+
media,
|
|
544
|
+
extra,
|
|
545
|
+
aspectRatio,
|
|
546
|
+
}: {
|
|
547
|
+
media: TaskCardMedia
|
|
548
|
+
extra: number
|
|
549
|
+
aspectRatio: string
|
|
550
|
+
}) {
|
|
551
|
+
const [failed, setFailed] = useState(false)
|
|
552
|
+
return (
|
|
553
|
+
<div
|
|
554
|
+
className="relative w-full overflow-hidden"
|
|
555
|
+
style={{
|
|
556
|
+
aspectRatio,
|
|
557
|
+
borderRadius: 'var(--radius-sm)',
|
|
558
|
+
// Placeholder behind the image, so the box is never empty or collapsed.
|
|
559
|
+
background: 'rgb(var(--surface-inset))',
|
|
560
|
+
// Pure-neutral hairline: a tinted border picks up the surface under it
|
|
561
|
+
// and reads as dirt on the image edge (interface-standards §2).
|
|
562
|
+
border: '1px solid rgb(var(--border))',
|
|
563
|
+
}}
|
|
564
|
+
>
|
|
565
|
+
{failed ? (
|
|
566
|
+
<span
|
|
567
|
+
className="absolute inset-0 grid place-items-center"
|
|
568
|
+
style={{ color: 'rgb(var(--text-muted))' }}
|
|
569
|
+
>
|
|
570
|
+
<ImageOff size={18} strokeWidth={1.5} aria-hidden="true" />
|
|
571
|
+
<span className="sr-only">Image unavailable: {media.alt}</span>
|
|
572
|
+
</span>
|
|
573
|
+
) : (
|
|
574
|
+
<img
|
|
575
|
+
src={media.src}
|
|
576
|
+
alt={media.alt}
|
|
577
|
+
loading="lazy"
|
|
578
|
+
decoding="async"
|
|
579
|
+
onError={() => setFailed(true)}
|
|
580
|
+
className="absolute inset-0 h-full w-full"
|
|
581
|
+
style={{ objectFit: 'cover' }}
|
|
582
|
+
/>
|
|
583
|
+
)}
|
|
584
|
+
{extra > 0 ? (
|
|
585
|
+
<span
|
|
586
|
+
className="absolute text-[11px] font-semibold tabular-nums"
|
|
587
|
+
style={{
|
|
588
|
+
insetBlockEnd: 'var(--space-2)',
|
|
589
|
+
insetInlineEnd: 'var(--space-2)',
|
|
590
|
+
paddingInline: '7px',
|
|
591
|
+
paddingBlock: '3px',
|
|
592
|
+
borderRadius: 'var(--radius-sm)',
|
|
593
|
+
background: 'rgb(var(--media-scrim-label))',
|
|
594
|
+
color: 'rgb(var(--media-on-scrim))',
|
|
595
|
+
}}
|
|
596
|
+
>
|
|
597
|
+
+{extra}
|
|
598
|
+
</span>
|
|
599
|
+
) : null}
|
|
600
|
+
</div>
|
|
601
|
+
)
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function initials(name: string): string {
|
|
605
|
+
const parts = name.trim().split(/\s+/).filter(Boolean)
|
|
606
|
+
if (parts.length === 0) return '?'
|
|
607
|
+
const first = parts[0]?.[0] ?? ''
|
|
608
|
+
const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? '') : ''
|
|
609
|
+
return (first + last).toUpperCase()
|
|
610
|
+
}
|