@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/avatar.tsx
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Avatar / AvatarGroup — a person.
|
|
3
|
+
*
|
|
4
|
+
* ADR-147 D14. Photo when there is one, initials on an identity tint when
|
|
5
|
+
* there is not.
|
|
6
|
+
*
|
|
7
|
+
* ## Why this exists
|
|
8
|
+
*
|
|
9
|
+
* Four forks of "a circle with someone's initials in it" shipped independently
|
|
10
|
+
* and disagreed on every axis (ADR-147 Context):
|
|
11
|
+
*
|
|
12
|
+
* • `task-card.tsx` `AvatarStack` — whole-string hash mod 7, first+last
|
|
13
|
+
* initial, opaque tint-ladder fill. *(lives on `feat/tasks-lens`)*
|
|
14
|
+
* • `components/ui/community/primitives.tsx` — first-CHARACTER hash mod 4
|
|
15
|
+
* over `--info/--success/--warning/--accent`, translucent `@0.14` fill.
|
|
16
|
+
* • `components/ui/admin/primitives.tsx` — byte-identical clone of it.
|
|
17
|
+
* • `lenses/assets/folders/folder-row.tsx` — no hash at all: every owner
|
|
18
|
+
* rendered in `--accent`.
|
|
19
|
+
*
|
|
20
|
+
* So the same person was a different colour on three screens, one screen gave
|
|
21
|
+
* everybody the same colour, and two of the four spent the view's single
|
|
22
|
+
* accent moment on a face.
|
|
23
|
+
*
|
|
24
|
+
* ## The API is `@lovett/ds`'s, for four measured reasons (D14)
|
|
25
|
+
*
|
|
26
|
+
* 1. **`tint` is a prop** — which colour belongs to which person is DATA (it
|
|
27
|
+
* travels with the user record: `users.avatar_color`, D6), not theme. The
|
|
28
|
+
* hash is the documented FALLBACK for until that column is populated, and
|
|
29
|
+
* it lives in exactly one function (`avatarTintForKey`) rather than four.
|
|
30
|
+
* 2. **The initial stays in the DOM behind the photo**, so a broken `src`
|
|
31
|
+
* degrades to a legible letter rather than a coloured hole. No fork even
|
|
32
|
+
* accepted a `src`.
|
|
33
|
+
* 3. **`--avatar-ring`** takes the HOST SURFACE's colour from a CSS variable,
|
|
34
|
+
* so a stack on a card and a stack in an inset well both read correctly
|
|
35
|
+
* with no variant prop. Set it with one of the `.ds-avatar-on-*` classes.
|
|
36
|
+
* 4. **`max` and `total` are separate**, so three faces render out of forty
|
|
37
|
+
* without passing forty children.
|
|
38
|
+
*
|
|
39
|
+
* ## Two deliberate divergences from ds
|
|
40
|
+
*
|
|
41
|
+
* • **`tint` is a palette KEY, not a colour string.** ds passes a raw colour
|
|
42
|
+
* and puts it straight in `style.background`. Here that would walk an
|
|
43
|
+
* arbitrary literal out of the database into app code, which is the exact
|
|
44
|
+
* thing CLAUDE.md §4's token boundary exists to stop — and a raw colour
|
|
45
|
+
* arrives with no ink or edge partner, so nothing can promise it is
|
|
46
|
+
* readable. A key resolves to a full contrast-tuned rung set in both
|
|
47
|
+
* themes. Same shape as `FolderCard`'s `colorKey` (ADR-035 D6).
|
|
48
|
+
* • **The initials are not white.** ds hardcodes a white literal because its
|
|
49
|
+
* tint is a saturated data colour. Ours is an 18% mix toward the card, so
|
|
50
|
+
* the glyph takes the ink rung — see the measured note in `styles.css`.
|
|
51
|
+
*
|
|
52
|
+
* ## D5 — nothing here fabricates a name
|
|
53
|
+
*
|
|
54
|
+
* `users.name`, `given_name` and `family_name` are ALL nullable by standing
|
|
55
|
+
* policy (`0014_sso_names.sql:13-16`: store NULL rather than invent). So
|
|
56
|
+
* `name` is typed `string | null`: still required (the caller must say what it
|
|
57
|
+
* has), still allowed to be nothing. A missing name renders `?` on a stable
|
|
58
|
+
* tint and is announced as "Unknown person" — honest, not invented, and it
|
|
59
|
+
* does not crash.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import { Children, useState } from 'react'
|
|
63
|
+
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'
|
|
64
|
+
import { FOLDER_COLOR_KEYS, type FolderColorKey } from './folder-card'
|
|
65
|
+
import { cn } from './lib/utils'
|
|
66
|
+
|
|
67
|
+
/* ── TINT ─────────────────────────────────────────────────────────────────── */
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The identity hues an avatar may take: every `--folder-*` key except `red`.
|
|
71
|
+
*
|
|
72
|
+
* `red` is excluded on purpose — `--folder-red` is byte-identical to
|
|
73
|
+
* `--accent` in light mode, so a red face would spend the view's single accent
|
|
74
|
+
* moment on a person (CLAUDE.md §Design principles → Color & emphasis).
|
|
75
|
+
*
|
|
76
|
+
* Deliberately the same set, in the same order, as the tasks board's
|
|
77
|
+
* `BOARD_HUE_KEYS` (`feat/tasks-lens`, `hue.ts`), so that when that branch
|
|
78
|
+
* merges its `AvatarStack` can adopt this primitive without any avatar
|
|
79
|
+
* changing colour.
|
|
80
|
+
*/
|
|
81
|
+
export type AvatarTint = Exclude<FolderColorKey, 'red'>
|
|
82
|
+
|
|
83
|
+
/** All avatar tints, in palette order. */
|
|
84
|
+
export const AVATAR_TINTS: readonly AvatarTint[] = FOLDER_COLOR_KEYS.filter(
|
|
85
|
+
(key): key is AvatarTint => key !== 'red',
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Parse a stored colour into a tint, at the boundary.
|
|
90
|
+
*
|
|
91
|
+
* `users.avatar_color` (ADR-147 D6) is a nullable TEXT column: a string from
|
|
92
|
+
* the database, not a domain value. This is the one place it is validated —
|
|
93
|
+
* anything that is not a known key (an old value, a hand-edited row, a hex
|
|
94
|
+
* somebody wrote by hand) becomes `undefined`, and `Avatar` then falls back to
|
|
95
|
+
* the hash rather than rendering an unknown class or an untrusted literal.
|
|
96
|
+
*
|
|
97
|
+
* Returns `undefined` rather than a default so the caller can spread it
|
|
98
|
+
* conditionally under `exactOptionalPropertyTypes`, and so "no colour stored"
|
|
99
|
+
* and "slate was stored" stay distinguishable.
|
|
100
|
+
*/
|
|
101
|
+
export function asAvatarTint(value: string | null | undefined): AvatarTint | undefined {
|
|
102
|
+
if (value == null) return undefined
|
|
103
|
+
return AVATAR_TINTS.find((tint) => tint === value)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A stable tint for an arbitrary identity string.
|
|
108
|
+
*
|
|
109
|
+
* **This is the fallback, not the plan.** ADR-147 D6 adds `users.avatar_color`
|
|
110
|
+
* so identity colour becomes data that travels with the user record; until it
|
|
111
|
+
* is populated, this derives one. Deterministic, so the same person is the
|
|
112
|
+
* same colour on every render and every machine — an avatar that changes
|
|
113
|
+
* colour reads as a different person. A display hash, not a distribution or
|
|
114
|
+
* security primitive.
|
|
115
|
+
*
|
|
116
|
+
* Same algorithm and same key order as `hueForKey` on `feat/tasks-lens`; when
|
|
117
|
+
* that branch merges, the two collapse into one function.
|
|
118
|
+
*/
|
|
119
|
+
export function avatarTintForKey(seed: string): AvatarTint {
|
|
120
|
+
let hash = 0
|
|
121
|
+
for (let i = 0; i < seed.length; i += 1) {
|
|
122
|
+
hash = (hash * 31 + seed.charCodeAt(i)) | 0
|
|
123
|
+
}
|
|
124
|
+
const index = Math.abs(hash) % AVATAR_TINTS.length
|
|
125
|
+
return AVATAR_TINTS[index] ?? 'slate'
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/* ── INITIALS ─────────────────────────────────────────────────────────────── */
|
|
129
|
+
|
|
130
|
+
/** First whole glyph, so a surrogate pair or a combining mark is not sliced. */
|
|
131
|
+
function firstGlyph(word: string): string {
|
|
132
|
+
return [...word][0] ?? ''
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The monogram for a display name — **first and last word, capped at two**.
|
|
137
|
+
*
|
|
138
|
+
* The one helper, replacing three that disagreed: `task-card` took first+last,
|
|
139
|
+
* community and admin took the first two words ("Mary Jane Watson" → `MW` here
|
|
140
|
+
* and `MJ` there), and `folder-row` took a server-supplied string with no cap
|
|
141
|
+
* at all.
|
|
142
|
+
*
|
|
143
|
+
* First+last wins because the family name is the more identifying letter, and
|
|
144
|
+
* because it matches the tasks board, which is the only fork whose colour and
|
|
145
|
+
* geometry are already correct.
|
|
146
|
+
*
|
|
147
|
+
* Returns `?` for a null, empty or whitespace-only name (D5). It never
|
|
148
|
+
* fabricates.
|
|
149
|
+
*/
|
|
150
|
+
export function initials(name: string | null | undefined): string {
|
|
151
|
+
const words = (name ?? '').trim().split(/\s+/).filter(Boolean)
|
|
152
|
+
if (words.length === 0) return '?'
|
|
153
|
+
const first = firstGlyph(words[0] ?? '')
|
|
154
|
+
const last = words.length > 1 ? firstGlyph(words[words.length - 1] ?? '') : ''
|
|
155
|
+
return (first + last).toUpperCase()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Announced when a display name is null or blank. Honest, never invented. */
|
|
159
|
+
const UNKNOWN_PERSON = 'Unknown person'
|
|
160
|
+
|
|
161
|
+
/* ── AVATAR ───────────────────────────────────────────────────────────────── */
|
|
162
|
+
|
|
163
|
+
export type AvatarSize = 'sm' | 'md' | 'lg'
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The two custom properties that carry a caller-supplied diameter into the
|
|
167
|
+
* CSS. Declared rather than cast: `as` on a style object is exactly the escape
|
|
168
|
+
* hatch CLAUDE.md asks us not to reach for.
|
|
169
|
+
*/
|
|
170
|
+
interface AvatarSizeVars extends CSSProperties {
|
|
171
|
+
'--avatar-size'?: string
|
|
172
|
+
'--avatar-font'?: string
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Coefficient for a TWO-character monogram, taken from the community fork
|
|
177
|
+
* (`Math.round(size * 0.36)`), so its nine existing call sites keep the type
|
|
178
|
+
* size they render today. The three named sizes carry their own measured
|
|
179
|
+
* values in `styles.css` instead of deriving from this.
|
|
180
|
+
*/
|
|
181
|
+
function sizeVars(px: number): AvatarSizeVars {
|
|
182
|
+
return { '--avatar-size': `${px}px`, '--avatar-font': `${Math.round(px * 0.36)}px` }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface AvatarProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'color'> {
|
|
186
|
+
/**
|
|
187
|
+
* Display name. Drives the monogram and the accessible name.
|
|
188
|
+
*
|
|
189
|
+
* Nullable on purpose (D5) — every name column in `users` is nullable by
|
|
190
|
+
* policy, and a component that cannot be handed a null forces every caller
|
|
191
|
+
* to invent a string.
|
|
192
|
+
*/
|
|
193
|
+
name: string | null
|
|
194
|
+
/**
|
|
195
|
+
* Identity tint. Pass `users.avatar_color` once D6's column is populated;
|
|
196
|
+
* omit it and a stable tint is derived from `seed ?? name`.
|
|
197
|
+
*/
|
|
198
|
+
tint?: AvatarTint
|
|
199
|
+
/** Photo. Falls back to the initials if it fails to load, or is absent. */
|
|
200
|
+
src?: string | null
|
|
201
|
+
/**
|
|
202
|
+
* `sm` (22) · `md` (32) · `lg` (52), or a raw pixel diameter.
|
|
203
|
+
*
|
|
204
|
+
* The number exists because the community and admin surfaces render avatars
|
|
205
|
+
* at 20, 24, 26 and 34 — none of which is on any scale. It keeps those call
|
|
206
|
+
* sites pixel-identical through the consolidation instead of making a
|
|
207
|
+
* colour refactor also a layout one. New code should use a named size.
|
|
208
|
+
*/
|
|
209
|
+
size?: AvatarSize | number
|
|
210
|
+
/** Override the derived monogram — e.g. `"You"`, or a server-supplied one. */
|
|
211
|
+
initial?: string
|
|
212
|
+
/**
|
|
213
|
+
* Separating ring in the HOST SURFACE's colour, for overlapping faces.
|
|
214
|
+
* Reads `--avatar-ring`, which `.ds-avatar-on-card` / `-frame` / `-inset`
|
|
215
|
+
* set; defaults to the card surface.
|
|
216
|
+
*/
|
|
217
|
+
ring?: boolean
|
|
218
|
+
/**
|
|
219
|
+
* Hash seed when `tint` is absent. Prefer a stable user id — a name can
|
|
220
|
+
* change, and the colour should not change with it.
|
|
221
|
+
*/
|
|
222
|
+
seed?: string
|
|
223
|
+
/**
|
|
224
|
+
* Hide from the accessibility tree. Pass it when the name is already
|
|
225
|
+
* rendered beside the avatar (a table row, a byline), so a screen reader
|
|
226
|
+
* does not announce the same person twice.
|
|
227
|
+
*/
|
|
228
|
+
decorative?: boolean
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function Avatar({
|
|
232
|
+
name,
|
|
233
|
+
tint,
|
|
234
|
+
src,
|
|
235
|
+
size = 'sm',
|
|
236
|
+
initial,
|
|
237
|
+
ring,
|
|
238
|
+
seed,
|
|
239
|
+
decorative,
|
|
240
|
+
className,
|
|
241
|
+
style,
|
|
242
|
+
...rest
|
|
243
|
+
}: AvatarProps) {
|
|
244
|
+
// Keyed by the src that failed rather than a bare boolean, so swapping in a
|
|
245
|
+
// new photo re-tries it. A boolean latches: once one URL 404s, every later
|
|
246
|
+
// one is suppressed for the life of the component.
|
|
247
|
+
const [failedSrc, setFailedSrc] = useState<string | null>(null)
|
|
248
|
+
|
|
249
|
+
const trimmed = name?.trim() ?? ''
|
|
250
|
+
const label = trimmed === '' ? UNKNOWN_PERSON : trimmed
|
|
251
|
+
const glyph = initial ?? initials(name)
|
|
252
|
+
const hue = tint ?? avatarTintForKey(seed ?? label)
|
|
253
|
+
const showImage = src != null && src !== '' && failedSrc !== src
|
|
254
|
+
const numeric = typeof size === 'number'
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<span
|
|
258
|
+
{...(decorative
|
|
259
|
+
? { 'aria-hidden': true }
|
|
260
|
+
: { role: 'img', 'aria-label': label, title: label })}
|
|
261
|
+
className={cn(
|
|
262
|
+
'ds-avatar',
|
|
263
|
+
`ds-avatar--${hue}`,
|
|
264
|
+
!numeric && size !== 'sm' && `ds-avatar--${size}`,
|
|
265
|
+
ring && 'ds-avatar--ring',
|
|
266
|
+
className,
|
|
267
|
+
)}
|
|
268
|
+
style={numeric ? { ...sizeVars(size), ...style } : style}
|
|
269
|
+
{...rest}
|
|
270
|
+
>
|
|
271
|
+
{/*
|
|
272
|
+
The initial stays in the DOM BEHIND the photo, so a broken `src`
|
|
273
|
+
degrades to something legible instead of a coloured hole.
|
|
274
|
+
|
|
275
|
+
IT NEEDS ITS OWN ELEMENT. As a bare text node it is an anonymous grid
|
|
276
|
+
item; `text-box-trim` is not inherited and a grid container has no line
|
|
277
|
+
boxes of its own, so the optical-centring rule on `.ds-avatar` reaches
|
|
278
|
+
nothing at all. Measured in @lovett/ds: the glyph sat 1.3px high in a
|
|
279
|
+
24px avatar. A span is the element the trim can actually apply to.
|
|
280
|
+
*/}
|
|
281
|
+
<span className="ds-avatar__mono">{glyph}</span>
|
|
282
|
+
{showImage ? (
|
|
283
|
+
<img className="ds-avatar__img" src={src} alt="" onError={() => setFailedSrc(src)} />
|
|
284
|
+
) : null}
|
|
285
|
+
</span>
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/* ── AVATAR GROUP ─────────────────────────────────────────────────────────── */
|
|
290
|
+
|
|
291
|
+
export interface AvatarGroupProps extends HTMLAttributes<HTMLSpanElement> {
|
|
292
|
+
/** Show at most this many faces; the rest collapse into a `+N`. */
|
|
293
|
+
max?: number
|
|
294
|
+
/**
|
|
295
|
+
* Total participants, when more exist than were passed as children — so a
|
|
296
|
+
* thread with forty people renders three faces and `+37` without the caller
|
|
297
|
+
* mounting forty avatars to throw thirty-seven away.
|
|
298
|
+
*/
|
|
299
|
+
total?: number
|
|
300
|
+
/** Size of the `+N` chip. Match the faces. */
|
|
301
|
+
size?: AvatarSize | number
|
|
302
|
+
/**
|
|
303
|
+
* Faces overlap and take a separating ring (default). `false` lays them
|
|
304
|
+
* side by side with a gap — what the tasks board card does, where the row
|
|
305
|
+
* is already tight and an overlap reads as a stack of one blob.
|
|
306
|
+
*/
|
|
307
|
+
overlap?: boolean
|
|
308
|
+
children: ReactNode
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function AvatarGroup({
|
|
312
|
+
max = 3,
|
|
313
|
+
total,
|
|
314
|
+
size = 'sm',
|
|
315
|
+
overlap = true,
|
|
316
|
+
className,
|
|
317
|
+
children,
|
|
318
|
+
...rest
|
|
319
|
+
}: AvatarGroupProps) {
|
|
320
|
+
const faces = Children.toArray(children)
|
|
321
|
+
const shown = faces.slice(0, max)
|
|
322
|
+
const hidden = (total ?? faces.length) - shown.length
|
|
323
|
+
const numeric = typeof size === 'number'
|
|
324
|
+
|
|
325
|
+
return (
|
|
326
|
+
<span
|
|
327
|
+
className={cn(
|
|
328
|
+
'ds-avatar-group',
|
|
329
|
+
overlap ? 'ds-avatar-group--overlap' : 'ds-avatar-group--spaced',
|
|
330
|
+
className,
|
|
331
|
+
)}
|
|
332
|
+
{...rest}
|
|
333
|
+
>
|
|
334
|
+
{shown}
|
|
335
|
+
{hidden > 0 ? (
|
|
336
|
+
<span
|
|
337
|
+
role="img"
|
|
338
|
+
aria-label={`${hidden} more`}
|
|
339
|
+
className={cn(
|
|
340
|
+
'ds-avatar-group__more',
|
|
341
|
+
!numeric && size !== 'sm' && `ds-avatar--${size}`,
|
|
342
|
+
)}
|
|
343
|
+
style={numeric ? sizeVars(size) : undefined}
|
|
344
|
+
>
|
|
345
|
+
{/* tabular-nums lives in the CSS: +9 becoming +10 must not shift the
|
|
346
|
+
faces to its left. */}
|
|
347
|
+
+{hidden}
|
|
348
|
+
</span>
|
|
349
|
+
) : null}
|
|
350
|
+
</span>
|
|
351
|
+
)
|
|
352
|
+
}
|