@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/dist/tokens.css
CHANGED
|
@@ -33,6 +33,14 @@
|
|
|
33
33
|
* --destructive(-bg, -foreground)
|
|
34
34
|
* • Folder: --folder-red/amber/emerald/teal/blue/violet/pink/
|
|
35
35
|
* slate/ink (categorical palette for folder accents)
|
|
36
|
+
* • Series: --series-1…8 (categorical CHART palette; ADR-146 D2.
|
|
37
|
+
* theme-INDEPENDENT, and the SLOT ORDER IS LOAD-BEARING
|
|
38
|
+
* — see the block near the end of this file before
|
|
39
|
+
* "tidying" it)
|
|
40
|
+
* • Chart: --chart-grid, --chart-axis, --chart-band (chart
|
|
41
|
+
* structure — gridlines, the darker baseline rule, and
|
|
42
|
+
* the hover band; ADR-146 D3. Theme-tuned, and all
|
|
43
|
+
* three ALREADY CARRY ALPHA — never add a second one)
|
|
36
44
|
* • Spacing: --space-1…12 (4 / 8 / 12 / 16 / 20 / 24 / 32 / 40 / 48)
|
|
37
45
|
* • Radius: --radius-xs/sm/md/lg/xl/2xl/full, --radius (legacy)
|
|
38
46
|
* • Motion: --ease-out, --ease-spring, --ease-in-out,
|
|
@@ -188,6 +196,34 @@
|
|
|
188
196
|
|
|
189
197
|
--border: 255 255 255 / 0.08;
|
|
190
198
|
--border-strong: 255 255 255 / 0.14;
|
|
199
|
+
|
|
200
|
+
/* ============================================================
|
|
201
|
+
CHART STRUCTURE — PUBLIC, theme-tuned (ADR-146 D3).
|
|
202
|
+
The greys a chart needs that are NOT a card border, plus the
|
|
203
|
+
hover-band fill. This is the "structure is required, not banned"
|
|
204
|
+
layer of the data-display recipe (CLAUDE.md §2): gridlines and a
|
|
205
|
+
baseline are what make a plot legible, and deleting them is the
|
|
206
|
+
flat/floating defect, not a cleanup.
|
|
207
|
+
|
|
208
|
+
--chart-axis IS DELIBERATELY DARKER THAN --chart-grid. The
|
|
209
|
+
baseline rule must outrank the gridlines. Every chart in the
|
|
210
|
+
Metoric kit draws it that way (#111111 baseline vs #dfe1e6 grid)
|
|
211
|
+
and it is the single detail that does most for chart legibility.
|
|
212
|
+
It lives as its own token precisely so it cannot drift back to
|
|
213
|
+
"same grey as the grid".
|
|
214
|
+
|
|
215
|
+
ALL THREE ALREADY CARRY AN ALPHA — they composite onto whichever
|
|
216
|
+
panel they sit on, so the panel stays opaque and the structure
|
|
217
|
+
stays translucent. Consume as `rgb(var(--chart-grid))` and NEVER
|
|
218
|
+
as `rgb(var(--chart-grid) / 0.5)`: a second alpha expands to
|
|
219
|
+
`rgb(255 255 255 / 0.07 / 0.5)`, which is invalid CSS and is
|
|
220
|
+
SILENTLY DISCARDED. `__tests__/token-shape.test.ts` fails on
|
|
221
|
+
exactly that shape.
|
|
222
|
+
============================================================ */
|
|
223
|
+
--chart-grid: 255 255 255 / 0.07;
|
|
224
|
+
--chart-axis: 255 255 255 / 0.22;
|
|
225
|
+
--chart-band: 255 255 255 / 0.05;
|
|
226
|
+
|
|
191
227
|
/* Semantic — PUBLIC */
|
|
192
228
|
--success: 16 185 129;
|
|
193
229
|
--success-bg: 16 185 129 / 0.12;
|
|
@@ -527,6 +563,14 @@
|
|
|
527
563
|
|
|
528
564
|
--border: 0 0 0 / 0.12;
|
|
529
565
|
--border-strong: 0 0 0 / 0.20;
|
|
566
|
+
|
|
567
|
+
/* Chart structure — see the :root block for the reasoning. The same
|
|
568
|
+
two invariants hold in light: --chart-axis stays darker than
|
|
569
|
+
--chart-grid, and all three already carry their alpha. */
|
|
570
|
+
--chart-grid: 0 0 0 / 0.08;
|
|
571
|
+
--chart-axis: 0 0 0 / 0.24;
|
|
572
|
+
--chart-band: 0 0 0 / 0.04;
|
|
573
|
+
|
|
530
574
|
/* Bumped from 0.16 → 0.22 — inputs on a white --bg-input (above) sitting
|
|
531
575
|
* inside a Card (--bg-card = 255 / same as input) need a stronger border
|
|
532
576
|
* to delineate. The 0.16 value worked when --bg-input was slate-tinted; on
|
|
@@ -813,3 +857,71 @@
|
|
|
813
857
|
--media-on-scrim: 255 255 255 / 0.88;
|
|
814
858
|
--media-overlay-btn: 255 255 255 / 0.14;
|
|
815
859
|
}
|
|
860
|
+
|
|
861
|
+
/* ============================================================
|
|
862
|
+
CATEGORICAL SERIES PALETTE (theme-INDEPENDENT) — PUBLIC
|
|
863
|
+
------------------------------------------------------------
|
|
864
|
+
The chart series palette (ADR-146 D2). Hues carried over from
|
|
865
|
+
`theme-v2.css`, which designed them at constant lightness ~0.62
|
|
866
|
+
and then never shipped a consumer.
|
|
867
|
+
|
|
868
|
+
THEME-INDEPENDENT ON PURPOSE, and verified rather than assumed:
|
|
869
|
+
lightness 0.62 sits inside BOTH the light band (0.43-0.77) and the
|
|
870
|
+
dark band (0.48-0.67), and all eight clear 3:1 against #ffffff,
|
|
871
|
+
against --surface-frame (#f7f7f9) and against dark --surface-card
|
|
872
|
+
(#1b1b1e). Same precedent as --hierarchy-* and --platform-* above:
|
|
873
|
+
one block, no light/dark override.
|
|
874
|
+
|
|
875
|
+
BARE TRIPLES. The call site owns the alpha — a line is
|
|
876
|
+
`rgb(var(--series-1))` and the area under it is
|
|
877
|
+
`rgb(var(--series-1) / 0.12)`. A baked-in alpha would expand to
|
|
878
|
+
`rgb(2 137 234 / 0.3 / 0.12)`, which is invalid and silently
|
|
879
|
+
dropped, and it would make the token un-shareable between the two
|
|
880
|
+
uses.
|
|
881
|
+
|
|
882
|
+
------------------------------------------------------------
|
|
883
|
+
THE SLOT ORDER IS LOAD-BEARING. DO NOT "TIDY" IT.
|
|
884
|
+
------------------------------------------------------------
|
|
885
|
+
The order was chosen by measurement (the dataviz skill's
|
|
886
|
+
validate_palette.js, adjacent-pair list, both modes), not by eye.
|
|
887
|
+
The order declared in theme-v2.css FAILS — teal against pink is
|
|
888
|
+
ΔE 1.2 for a deuteranope. This order passes all five checks in
|
|
889
|
+
both themes: worst adjacent pair ΔE 13.2 deutan / 21.0 normal,
|
|
890
|
+
identical light and dark. Re-ordering the slots breaks that, and
|
|
891
|
+
nothing in the build will tell you.
|
|
892
|
+
|
|
893
|
+
Three placements are deliberate:
|
|
894
|
+
• BLUE IS SLOT 1, not red. Slot 1 is the single-series default,
|
|
895
|
+
so a red slot 1 would paint every one-series chart in the
|
|
896
|
+
brand accent and destroy accent scarcity. Blue carries no
|
|
897
|
+
product meaning.
|
|
898
|
+
• RED IS SLOT 8. Red is reserved for --accent and
|
|
899
|
+
--destructive. It only enters a chart at eight series, where
|
|
900
|
+
the reader has bigger problems. It stays in the palette so
|
|
901
|
+
the eight-slot contract holds rather than being deleted.
|
|
902
|
+
• GREEN (slot 2) AND RED (slot 8) ARE FAR APART, so a
|
|
903
|
+
two-series chart never accidentally reads as pass/fail.
|
|
904
|
+
|
|
905
|
+
THE SCATTER / ALL-PAIRS CAP. Forms where any two marks can touch
|
|
906
|
+
— scatter, bubble, choropleth, small multiples — take the harder
|
|
907
|
+
all-pairs test, which no ordering of these eight can pass. Those
|
|
908
|
+
forms use ONLY slots 1, 6 and 7 (blue / amber / teal: verified
|
|
909
|
+
all-pairs in both modes, worst ΔE 14.8, all five checks PASS) and
|
|
910
|
+
CAP AT THREE SERIES. A fourth folds into "Other" or facets into
|
|
911
|
+
small multiples. Never generate a ninth hue.
|
|
912
|
+
|
|
913
|
+
Resolution is a frozen literal lookup in the primitive, never a
|
|
914
|
+
constructed name (ADR-146 D8): `--series-${i}` that misses yields
|
|
915
|
+
an undefined property, which makes rgb() invalid, which the
|
|
916
|
+
browser discards in silence with every gate still green.
|
|
917
|
+
============================================================ */
|
|
918
|
+
:root {
|
|
919
|
+
--series-1: 2 137 234; /* blue */
|
|
920
|
+
--series-2: 9 160 93; /* green */
|
|
921
|
+
--series-3: 153 100 229; /* violet */
|
|
922
|
+
--series-4: 113 149 3; /* olive */
|
|
923
|
+
--series-5: 212 73 150; /* pink */
|
|
924
|
+
--series-6: 186 116 4; /* amber */
|
|
925
|
+
--series-7: 14 154 148; /* teal */
|
|
926
|
+
--series-8: 226 74 62; /* red */
|
|
927
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lovett/ui",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Design system primitives, tokens, layouts, and patterns for the Lovett portfolio.",
|
|
@@ -55,6 +55,13 @@
|
|
|
55
55
|
"@dnd-kit/core": "^6.3.1",
|
|
56
56
|
"@dnd-kit/sortable": "^10.0.0",
|
|
57
57
|
"@dnd-kit/utilities": "^3.2.2",
|
|
58
|
+
"@tiptap/core": "3.31.3",
|
|
59
|
+
"@tiptap/extension-mention": "3.31.3",
|
|
60
|
+
"@tiptap/extension-placeholder": "3.31.3",
|
|
61
|
+
"@tiptap/markdown": "3.31.3",
|
|
62
|
+
"@tiptap/pm": "3.31.3",
|
|
63
|
+
"@tiptap/react": "3.31.3",
|
|
64
|
+
"@tiptap/starter-kit": "3.31.3",
|
|
58
65
|
"clsx": "^2.1.1",
|
|
59
66
|
"mammoth": "^1.12.0",
|
|
60
67
|
"react-markdown": "^10.1.0",
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Avatar / AvatarGroup — ADR-147 D14.
|
|
3
|
+
*
|
|
4
|
+
* The contract worth pinning is not "it renders a circle". It is the four
|
|
5
|
+
* things the consolidated forks got wrong or could not do, plus the two D5
|
|
6
|
+
* degradations that a comment thread will hit in production:
|
|
7
|
+
*
|
|
8
|
+
* • one `initials()`, capped at two words, first + last
|
|
9
|
+
* • one tint derivation, stable per identity, `red` never in the set
|
|
10
|
+
* • the monogram stays in the DOM UNDER the photo, so a broken `src`
|
|
11
|
+
* degrades to a letter rather than a coloured hole
|
|
12
|
+
* • `max` and `total` are separate, so 3 of 40 renders from 3 children
|
|
13
|
+
* • a NULL name does not crash and is not fabricated
|
|
14
|
+
*
|
|
15
|
+
* `css: false` in the vitest config, so nothing here asserts a painted value —
|
|
16
|
+
* these are class and DOM contracts. Colour is verified by the measured note
|
|
17
|
+
* in `styles.css` and an eyeball pass in both themes.
|
|
18
|
+
*/
|
|
19
|
+
import { describe, expect, it } from 'vitest'
|
|
20
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
21
|
+
import {
|
|
22
|
+
AVATAR_TINTS,
|
|
23
|
+
Avatar,
|
|
24
|
+
AvatarGroup,
|
|
25
|
+
asAvatarTint,
|
|
26
|
+
avatarTintForKey,
|
|
27
|
+
initials,
|
|
28
|
+
} from '../avatar'
|
|
29
|
+
|
|
30
|
+
describe('initials()', () => {
|
|
31
|
+
it('takes the first and last word, capped at two', () => {
|
|
32
|
+
expect(initials('Marcus Webb')).toBe('MW')
|
|
33
|
+
// The divergence being resolved: community and admin returned 'MJ' here.
|
|
34
|
+
expect(initials('Mary Jane Watson')).toBe('MW')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('takes one letter from a single-word name', () => {
|
|
38
|
+
expect(initials('Cher')).toBe('C')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('uppercases and tolerates ragged whitespace', () => {
|
|
42
|
+
expect(initials(' ada lovelace ')).toBe('AL')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('does not slice a surrogate pair in half', () => {
|
|
46
|
+
// A name whose first character is outside the BMP. `name[0]` returns half a
|
|
47
|
+
// code point and renders as a replacement glyph.
|
|
48
|
+
expect(initials('\u{1D400}lpha Beta')).toBe('\u{1D400}B')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('returns ? for a null, undefined, empty or blank name and never invents one', () => {
|
|
52
|
+
// D5: users.name / given_name / family_name are ALL nullable by policy.
|
|
53
|
+
expect(initials(null)).toBe('?')
|
|
54
|
+
expect(initials(undefined)).toBe('?')
|
|
55
|
+
expect(initials('')).toBe('?')
|
|
56
|
+
expect(initials(' ')).toBe('?')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('avatarTintForKey()', () => {
|
|
61
|
+
it('is stable for the same seed', () => {
|
|
62
|
+
expect(avatarTintForKey('usr_9f2')).toBe(avatarTintForKey('usr_9f2'))
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('never returns red — that is the accent, and an avatar may not spend it', () => {
|
|
66
|
+
expect(AVATAR_TINTS).not.toContain('red')
|
|
67
|
+
const seeds = Array.from({ length: 400 }, (_, i) => `usr_${i}`)
|
|
68
|
+
for (const seed of seeds) {
|
|
69
|
+
expect(AVATAR_TINTS).toContain(avatarTintForKey(seed))
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('hashes the whole string, not the first character', () => {
|
|
74
|
+
// The community/admin fork keyed on `name.charCodeAt(0) % 4`, so every
|
|
75
|
+
// name starting with the same letter collided by construction.
|
|
76
|
+
expect(avatarTintForKey('Alice Adams')).not.toBe(avatarTintForKey('Aaron Ash'))
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('is not degenerate — it reaches most of the palette over a small sample', () => {
|
|
80
|
+
const seen = new Set(
|
|
81
|
+
Array.from({ length: 200 }, (_, i) => avatarTintForKey(`usr_${i}`)),
|
|
82
|
+
)
|
|
83
|
+
expect(seen.size).toBeGreaterThanOrEqual(AVATAR_TINTS.length - 1)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('asAvatarTint()', () => {
|
|
88
|
+
it('passes a known key through', () => {
|
|
89
|
+
expect(asAvatarTint('violet')).toBe('violet')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('rejects anything that is not a key, so an untrusted string cannot reach the DOM', () => {
|
|
93
|
+
// `users.avatar_color` is a nullable TEXT column, so its contents are a
|
|
94
|
+
// string from the database, not a domain value.
|
|
95
|
+
expect(asAvatarTint(null)).toBeUndefined()
|
|
96
|
+
expect(asAvatarTint(undefined)).toBeUndefined()
|
|
97
|
+
expect(asAvatarTint('')).toBeUndefined()
|
|
98
|
+
expect(asAvatarTint('rebeccapurple')).toBeUndefined()
|
|
99
|
+
expect(asAvatarTint('var(--accent)')).toBeUndefined()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('rejects red — it is not in the set, so it cannot be stored into one', () => {
|
|
103
|
+
expect(asAvatarTint('red')).toBeUndefined()
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('Avatar', () => {
|
|
108
|
+
it('names itself for a screen reader and shows the monogram', () => {
|
|
109
|
+
render(<Avatar name="Marcus Webb" />)
|
|
110
|
+
const el = screen.getByRole('img', { name: 'Marcus Webb' })
|
|
111
|
+
expect(el).toHaveTextContent('MW')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('takes a tint prop over the hash, and carries it as a class not a style', () => {
|
|
115
|
+
// Identity colour is DATA (D6). A key, so the token boundary holds — an
|
|
116
|
+
// arbitrary colour string out of the database would not.
|
|
117
|
+
const { container } = render(<Avatar name="Marcus Webb" tint="violet" />)
|
|
118
|
+
expect(container.firstChild).toHaveClass('ds-avatar', 'ds-avatar--violet')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('falls back to the hash when no tint is given, seeded by id in preference to name', () => {
|
|
122
|
+
const { container: byName } = render(<Avatar name="Marcus Webb" />)
|
|
123
|
+
const { container: bySeed } = render(<Avatar name="Marcus Webb" seed="usr_9f2" />)
|
|
124
|
+
expect(byName.firstChild).toHaveClass(`ds-avatar--${avatarTintForKey('Marcus Webb')}`)
|
|
125
|
+
expect(bySeed.firstChild).toHaveClass(`ds-avatar--${avatarTintForKey('usr_9f2')}`)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('gives the monogram its OWN element, not a bare text node', () => {
|
|
129
|
+
// Measured in @lovett/ds: as a bare text node it is an anonymous grid item,
|
|
130
|
+
// `text-box-trim` reaches nothing, and the glyph sat 1.3px high in a 24px
|
|
131
|
+
// avatar. Deleting the span is a silent 1.3px regression, so it is pinned.
|
|
132
|
+
const { container } = render(<Avatar name="Marcus Webb" />)
|
|
133
|
+
const mono = container.querySelector('.ds-avatar__mono')
|
|
134
|
+
expect(mono).not.toBeNull()
|
|
135
|
+
expect(mono).toHaveTextContent('MW')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('renders the photo OVER the monogram, keeping the monogram in the DOM', () => {
|
|
139
|
+
const { container } = render(<Avatar name="Marcus Webb" src="https://x.test/m.png" />)
|
|
140
|
+
expect(container.querySelector('.ds-avatar__img')).not.toBeNull()
|
|
141
|
+
expect(container.querySelector('.ds-avatar__mono')).toHaveTextContent('MW')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('degrades a broken src to the letter rather than a coloured hole', () => {
|
|
145
|
+
const { container } = render(<Avatar name="Marcus Webb" src="https://x.test/404.png" />)
|
|
146
|
+
const img = container.querySelector('.ds-avatar__img')
|
|
147
|
+
expect(img).not.toBeNull()
|
|
148
|
+
if (img) fireEvent.error(img)
|
|
149
|
+
expect(container.querySelector('.ds-avatar__img')).toBeNull()
|
|
150
|
+
expect(screen.getByRole('img', { name: 'Marcus Webb' })).toHaveTextContent('MW')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('re-tries a NEW src after an earlier one failed', () => {
|
|
154
|
+
// A latching boolean would suppress every later photo for the life of the
|
|
155
|
+
// component once any URL 404s. Keyed by the failed src instead.
|
|
156
|
+
const { container, rerender } = render(<Avatar name="M W" src="https://x.test/404.png" />)
|
|
157
|
+
const img = container.querySelector('.ds-avatar__img')
|
|
158
|
+
if (img) fireEvent.error(img)
|
|
159
|
+
expect(container.querySelector('.ds-avatar__img')).toBeNull()
|
|
160
|
+
rerender(<Avatar name="M W" src="https://x.test/ok.png" />)
|
|
161
|
+
expect(container.querySelector('.ds-avatar__img')).not.toBeNull()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('accepts an initial override', () => {
|
|
165
|
+
render(<Avatar name="Edwin Lovett" initial="You" />)
|
|
166
|
+
expect(screen.getByRole('img', { name: 'Edwin Lovett' })).toHaveTextContent('You')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
describe('D5 — degraded identities', () => {
|
|
170
|
+
it('does not crash on a null name, and does not fabricate one', () => {
|
|
171
|
+
render(<Avatar name={null} />)
|
|
172
|
+
const el = screen.getByRole('img', { name: 'Unknown person' })
|
|
173
|
+
expect(el).toHaveTextContent('?')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('treats a blank name the same as a null one', () => {
|
|
177
|
+
render(<Avatar name=" " />)
|
|
178
|
+
expect(screen.getByRole('img', { name: 'Unknown person' })).toHaveTextContent('?')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('gives a null-named person a stable tint rather than throwing', () => {
|
|
182
|
+
const { container } = render(<Avatar name={null} />)
|
|
183
|
+
expect(container.firstChild).toHaveClass(
|
|
184
|
+
`ds-avatar--${avatarTintForKey('Unknown person')}`,
|
|
185
|
+
)
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe('sizing', () => {
|
|
190
|
+
it('is sm by default and carries no size modifier for it', () => {
|
|
191
|
+
const { container } = render(<Avatar name="Marcus Webb" />)
|
|
192
|
+
expect(container.firstChild).not.toHaveClass('ds-avatar--md', 'ds-avatar--lg')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('carries a modifier class for a named size', () => {
|
|
196
|
+
const { container } = render(<Avatar name="Marcus Webb" size="lg" />)
|
|
197
|
+
expect(container.firstChild).toHaveClass('ds-avatar--lg')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('carries a pixel size as custom properties, never as a hue or size class', () => {
|
|
201
|
+
const { container } = render(<Avatar name="Marcus Webb" size={26} />)
|
|
202
|
+
const el = container.firstChild
|
|
203
|
+
expect(el).toHaveStyle({ '--avatar-size': '26px' })
|
|
204
|
+
// 0.36 coefficient — the community fork's, so its call sites keep their
|
|
205
|
+
// rendered type size through the consolidation.
|
|
206
|
+
expect(el).toHaveStyle({ '--avatar-font': '9px' })
|
|
207
|
+
expect(el).not.toHaveClass('ds-avatar--md')
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
describe('accessibility', () => {
|
|
212
|
+
it('takes the ring class only when asked', () => {
|
|
213
|
+
const { container: plain } = render(<Avatar name="A B" />)
|
|
214
|
+
const { container: ringed } = render(<Avatar name="A B" ring />)
|
|
215
|
+
expect(plain.firstChild).not.toHaveClass('ds-avatar--ring')
|
|
216
|
+
expect(ringed.firstChild).toHaveClass('ds-avatar--ring')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('hides itself from the a11y tree when decorative', () => {
|
|
220
|
+
// Every table row and byline renders the name beside the avatar;
|
|
221
|
+
// announcing the same person twice is worse than not announcing them.
|
|
222
|
+
const { container } = render(<Avatar name="Marcus Webb" decorative />)
|
|
223
|
+
expect(container.firstChild).toHaveAttribute('aria-hidden', 'true')
|
|
224
|
+
expect(screen.queryByRole('img')).toBeNull()
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
describe('AvatarGroup', () => {
|
|
230
|
+
const face = (n: string) => <Avatar key={n} name={n} />
|
|
231
|
+
|
|
232
|
+
it('shows every face when there are no more than max', () => {
|
|
233
|
+
render(<AvatarGroup max={3}>{['A One', 'B Two'].map(face)}</AvatarGroup>)
|
|
234
|
+
expect(screen.getAllByRole('img')).toHaveLength(2)
|
|
235
|
+
expect(screen.queryByText(/^\+/)).toBeNull()
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('collapses the tail into a +N', () => {
|
|
239
|
+
render(<AvatarGroup max={2}>{['A One', 'B Two', 'C Three'].map(face)}</AvatarGroup>)
|
|
240
|
+
expect(screen.getByRole('img', { name: '1 more' })).toHaveTextContent('+1')
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('counts from total, so 3 faces render out of 40 without 40 children', () => {
|
|
244
|
+
// The capability no fork had. `total` is separate from the children on
|
|
245
|
+
// purpose: a thread with 40 participants must not mount 40 avatars to
|
|
246
|
+
// throw 37 away.
|
|
247
|
+
render(
|
|
248
|
+
<AvatarGroup max={3} total={40}>
|
|
249
|
+
{['A One', 'B Two', 'C Three'].map(face)}
|
|
250
|
+
</AvatarGroup>,
|
|
251
|
+
)
|
|
252
|
+
expect(screen.getByRole('img', { name: '37 more' })).toHaveTextContent('+37')
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('overlaps by default and lays out spaced when asked', () => {
|
|
256
|
+
const { container: over } = render(<AvatarGroup>{[face('A One')]}</AvatarGroup>)
|
|
257
|
+
const { container: flat } = render(
|
|
258
|
+
<AvatarGroup overlap={false}>{[face('A One')]}</AvatarGroup>,
|
|
259
|
+
)
|
|
260
|
+
expect(over.firstChild).toHaveClass('ds-avatar-group--overlap')
|
|
261
|
+
expect(flat.firstChild).toHaveClass('ds-avatar-group--spaced')
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('sizes the +N chip with the faces', () => {
|
|
265
|
+
const { container } = render(
|
|
266
|
+
<AvatarGroup max={1} total={9} size="md">
|
|
267
|
+
{[face('A One')]}
|
|
268
|
+
</AvatarGroup>,
|
|
269
|
+
)
|
|
270
|
+
expect(container.querySelector('.ds-avatar-group__more')).toHaveClass('ds-avatar--md')
|
|
271
|
+
})
|
|
272
|
+
})
|