@startsimpli/ui 0.4.93 → 0.4.95
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/package.json +4 -4
- package/src/components/dashboard/DashboardSection.tsx +26 -2
- package/src/components/dashboard/StatTile.tsx +20 -1
- package/src/components/states/Absence.tsx +156 -0
- package/src/components/states/Caveat.tsx +167 -0
- package/src/components/states/EmptyState.tsx +39 -10
- package/src/components/states/Footnote.tsx +171 -0
- package/src/components/states/__tests__/caveat-and-absence.test.tsx +662 -0
- package/src/components/states/index.ts +7 -1
- package/src/components/ui/card.tsx +37 -6
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { render, screen, within } from '@testing-library/react'
|
|
3
|
+
import userEvent from '@testing-library/user-event'
|
|
4
|
+
|
|
5
|
+
import { Caveat } from '../Caveat'
|
|
6
|
+
import { Footnote, FootnoteScope } from '../Footnote'
|
|
7
|
+
import { Absence, ABSENCE_DASH } from '../Absence'
|
|
8
|
+
import { EmptyState } from '../EmptyState'
|
|
9
|
+
import { StatTile } from '../../dashboard/StatTile'
|
|
10
|
+
import { DashboardSection } from '../../dashboard/DashboardSection'
|
|
11
|
+
import { Card, CardHeader, CardTitle } from '../../ui/card'
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Helpers
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Physical-direction utilities break the moment the document flips to RTL.
|
|
19
|
+
* Every class the new primitives emit has to be logical.
|
|
20
|
+
*/
|
|
21
|
+
const PHYSICAL_CLASS =
|
|
22
|
+
/(^|\s)-?(pl|pr|ml|mr|left|right|border-l|border-r|rounded-l|rounded-r|text-left|text-right)(-|$|\s)/
|
|
23
|
+
|
|
24
|
+
function physicalClassesIn(container: HTMLElement): string[] {
|
|
25
|
+
return Array.from(container.querySelectorAll<HTMLElement>('*'))
|
|
26
|
+
.flatMap((el) => Array.from(el.classList))
|
|
27
|
+
.filter((cls) => PHYSICAL_CLASS.test(cls))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('physicalClassesIn (the RTL guard itself)', () => {
|
|
31
|
+
it('catches the physical utilities it exists to ban', () => {
|
|
32
|
+
const { container } = render(
|
|
33
|
+
<div className="pl-2">
|
|
34
|
+
<span className="ml-1 text-left" />
|
|
35
|
+
<span className="border-r" />
|
|
36
|
+
</div>,
|
|
37
|
+
)
|
|
38
|
+
expect(physicalClassesIn(container).sort()).toEqual([
|
|
39
|
+
'border-r',
|
|
40
|
+
'ml-1',
|
|
41
|
+
'pl-2',
|
|
42
|
+
'text-left',
|
|
43
|
+
])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('passes the logical utilities the primitives use', () => {
|
|
47
|
+
const { container } = render(
|
|
48
|
+
<div className="ps-2">
|
|
49
|
+
<span className="ms-1 me-1 text-start start-0" />
|
|
50
|
+
</div>,
|
|
51
|
+
)
|
|
52
|
+
expect(physicalClassesIn(container)).toEqual([])
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Caveat — the disclosure affordance
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
describe('Caveat', () => {
|
|
61
|
+
it('collapses to a marker: the note is present but not visible', () => {
|
|
62
|
+
render(<Caveat subject="Streams reported">Estimates only.</Caveat>)
|
|
63
|
+
|
|
64
|
+
const trigger = screen.getByRole('button')
|
|
65
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
|
66
|
+
expect(screen.getByRole('note', { hidden: true })).not.toBeVisible()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('gives the trigger an accessible name that names what it qualifies', () => {
|
|
70
|
+
render(<Caveat subject="Streams reported">Estimates only.</Caveat>)
|
|
71
|
+
|
|
72
|
+
expect(
|
|
73
|
+
screen.getByRole('button', { name: 'Why? Streams reported' }),
|
|
74
|
+
).toBeInTheDocument()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('renders a real button, not a hover-only affordance', () => {
|
|
78
|
+
render(<Caveat>Estimates only.</Caveat>)
|
|
79
|
+
|
|
80
|
+
const trigger = screen.getByRole('button')
|
|
81
|
+
expect(trigger.tagName).toBe('BUTTON')
|
|
82
|
+
expect(trigger).toHaveAttribute('type', 'button')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('expands on click and exposes the note through aria-describedby', async () => {
|
|
86
|
+
const user = userEvent.setup()
|
|
87
|
+
render(<Caveat subject="Streams">Settlement lags about a month.</Caveat>)
|
|
88
|
+
|
|
89
|
+
const trigger = screen.getByRole('button')
|
|
90
|
+
await user.click(trigger)
|
|
91
|
+
|
|
92
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
|
93
|
+
const note = screen.getByRole('note')
|
|
94
|
+
expect(note).toBeVisible()
|
|
95
|
+
expect(note).toHaveTextContent('Settlement lags about a month.')
|
|
96
|
+
expect(trigger).toHaveAttribute('aria-describedby', note.id)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('does not point a screen reader at the note while it is collapsed', () => {
|
|
100
|
+
render(<Caveat>Estimates only.</Caveat>)
|
|
101
|
+
|
|
102
|
+
expect(screen.getByRole('button')).not.toHaveAttribute('aria-describedby')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('always resolves aria-controls, open or closed', async () => {
|
|
106
|
+
const user = userEvent.setup()
|
|
107
|
+
const { container } = render(<Caveat>Estimates only.</Caveat>)
|
|
108
|
+
|
|
109
|
+
const trigger = screen.getByRole('button')
|
|
110
|
+
const controlled = trigger.getAttribute('aria-controls')!
|
|
111
|
+
expect(container.querySelector(`#${controlled}`)).not.toBeNull()
|
|
112
|
+
|
|
113
|
+
await user.click(trigger)
|
|
114
|
+
expect(container.querySelector(`#${controlled}`)).not.toBeNull()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('opens from the keyboard alone', async () => {
|
|
118
|
+
const user = userEvent.setup()
|
|
119
|
+
render(<Caveat>Estimates only.</Caveat>)
|
|
120
|
+
|
|
121
|
+
await user.tab()
|
|
122
|
+
expect(screen.getByRole('button')).toHaveFocus()
|
|
123
|
+
|
|
124
|
+
await user.keyboard('{Enter}')
|
|
125
|
+
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true')
|
|
126
|
+
|
|
127
|
+
await user.keyboard(' ')
|
|
128
|
+
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('closes on Escape', async () => {
|
|
132
|
+
const user = userEvent.setup()
|
|
133
|
+
render(<Caveat defaultOpen>Estimates only.</Caveat>)
|
|
134
|
+
|
|
135
|
+
const trigger = screen.getByRole('button')
|
|
136
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
|
137
|
+
|
|
138
|
+
await user.type(trigger, '{Escape}')
|
|
139
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('honours defaultOpen', () => {
|
|
143
|
+
render(<Caveat defaultOpen>Estimates only.</Caveat>)
|
|
144
|
+
expect(screen.getByRole('note')).toBeVisible()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('can be driven as a controlled disclosure', async () => {
|
|
148
|
+
const user = userEvent.setup()
|
|
149
|
+
const onOpenChange = jest.fn()
|
|
150
|
+
render(
|
|
151
|
+
<Caveat open={false} onOpenChange={onOpenChange}>
|
|
152
|
+
Estimates only.
|
|
153
|
+
</Caveat>,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
await user.click(screen.getByRole('button'))
|
|
157
|
+
|
|
158
|
+
expect(onOpenChange).toHaveBeenCalledWith(true)
|
|
159
|
+
// Controlled: the parent said closed, so it stayed closed.
|
|
160
|
+
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('renders the label itself when the trigger is text', () => {
|
|
164
|
+
render(
|
|
165
|
+
<Caveat trigger="text" label="Why?" subject="Streams">
|
|
166
|
+
Estimates only.
|
|
167
|
+
</Caveat>,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
expect(screen.getByRole('button')).toHaveTextContent('Why?')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('closes an overlay when the pointer lands outside it', async () => {
|
|
174
|
+
const user = userEvent.setup()
|
|
175
|
+
render(
|
|
176
|
+
<div>
|
|
177
|
+
<Caveat defaultOpen>Estimates only.</Caveat>
|
|
178
|
+
<button type="button">Elsewhere</button>
|
|
179
|
+
</div>,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
await user.click(screen.getByRole('button', { name: 'Elsewhere' }))
|
|
183
|
+
expect(
|
|
184
|
+
screen.getByRole('button', { name: 'Why?' }),
|
|
185
|
+
).toHaveAttribute('aria-expanded', 'false')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('leaves an inline caveat open when the pointer lands outside it', async () => {
|
|
189
|
+
const user = userEvent.setup()
|
|
190
|
+
render(
|
|
191
|
+
<div>
|
|
192
|
+
<Caveat placement="inline" defaultOpen>
|
|
193
|
+
Estimates only.
|
|
194
|
+
</Caveat>
|
|
195
|
+
<button type="button">Elsewhere</button>
|
|
196
|
+
</div>,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
await user.click(screen.getByRole('button', { name: 'Elsewhere' }))
|
|
200
|
+
expect(
|
|
201
|
+
screen.getByRole('button', { name: 'Why?' }),
|
|
202
|
+
).toHaveAttribute('aria-expanded', 'true')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('anchors the overlay to the logical start by default', () => {
|
|
206
|
+
render(<Caveat defaultOpen>Estimates only.</Caveat>)
|
|
207
|
+
expect(screen.getByRole('note').className).toContain('start-0')
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('anchors to the opposite logical edge when it would open off-screen', () => {
|
|
211
|
+
const spy = jest
|
|
212
|
+
.spyOn(Element.prototype, 'getBoundingClientRect')
|
|
213
|
+
.mockReturnValue({
|
|
214
|
+
width: 256,
|
|
215
|
+
height: 60,
|
|
216
|
+
top: 0,
|
|
217
|
+
bottom: 60,
|
|
218
|
+
left: window.innerWidth - 20,
|
|
219
|
+
right: window.innerWidth + 236,
|
|
220
|
+
x: window.innerWidth - 20,
|
|
221
|
+
y: 0,
|
|
222
|
+
toJSON: () => ({}),
|
|
223
|
+
} as DOMRect)
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
render(<Caveat defaultOpen>Estimates only.</Caveat>)
|
|
227
|
+
const note = screen.getByRole('note')
|
|
228
|
+
expect(note.className).toContain('end-0')
|
|
229
|
+
expect(note.className).not.toContain('start-0')
|
|
230
|
+
} finally {
|
|
231
|
+
spy.mockRestore()
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('emits no physical-direction classes', () => {
|
|
236
|
+
const { container } = render(
|
|
237
|
+
<Caveat defaultOpen subject="Streams">
|
|
238
|
+
Estimates only.
|
|
239
|
+
</Caveat>,
|
|
240
|
+
)
|
|
241
|
+
expect(physicalClassesIn(container)).toEqual([])
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// FootnoteScope — a caveat authored per item, said once per section
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
describe('FootnoteScope', () => {
|
|
250
|
+
const SETTLEMENT = 'Streaming settles about a month after play.'
|
|
251
|
+
|
|
252
|
+
it('prints a repeated note once, however many items claim it', () => {
|
|
253
|
+
render(
|
|
254
|
+
<FootnoteScope>
|
|
255
|
+
<p>
|
|
256
|
+
Spotify
|
|
257
|
+
<Footnote note={SETTLEMENT} />
|
|
258
|
+
</p>
|
|
259
|
+
<p>
|
|
260
|
+
Apple
|
|
261
|
+
<Footnote note={SETTLEMENT} />
|
|
262
|
+
</p>
|
|
263
|
+
<p>
|
|
264
|
+
Anghami
|
|
265
|
+
<Footnote note={SETTLEMENT} />
|
|
266
|
+
</p>
|
|
267
|
+
</FootnoteScope>,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
expect(screen.getAllByText(SETTLEMENT, { exact: false })).toHaveLength(1)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('leaves a marker on every item that claimed it', () => {
|
|
274
|
+
const { container } = render(
|
|
275
|
+
<FootnoteScope>
|
|
276
|
+
<p>
|
|
277
|
+
Spotify
|
|
278
|
+
<Footnote note={SETTLEMENT} />
|
|
279
|
+
</p>
|
|
280
|
+
<p>
|
|
281
|
+
Apple
|
|
282
|
+
<Footnote note={SETTLEMENT} />
|
|
283
|
+
</p>
|
|
284
|
+
</FootnoteScope>,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
expect(container.querySelectorAll('[data-footnote="1"]')).toHaveLength(2)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('numbers distinct notes in the order they were claimed', () => {
|
|
291
|
+
render(
|
|
292
|
+
<FootnoteScope>
|
|
293
|
+
<p>
|
|
294
|
+
Streams
|
|
295
|
+
<Footnote note="First note." />
|
|
296
|
+
</p>
|
|
297
|
+
<p>
|
|
298
|
+
Listeners
|
|
299
|
+
<Footnote note="Second note." />
|
|
300
|
+
</p>
|
|
301
|
+
</FootnoteScope>,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
const list = screen.getByRole('list', { name: 'Notes' })
|
|
305
|
+
const items = within(list).getAllByRole('listitem')
|
|
306
|
+
expect(items).toHaveLength(2)
|
|
307
|
+
expect(items[0]).toHaveTextContent('First note.')
|
|
308
|
+
expect(items[1]).toHaveTextContent('Second note.')
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('points each marker at the one printed copy of its note', () => {
|
|
312
|
+
const { container } = render(
|
|
313
|
+
<FootnoteScope>
|
|
314
|
+
<p>
|
|
315
|
+
Streams
|
|
316
|
+
<Footnote note={SETTLEMENT} />
|
|
317
|
+
</p>
|
|
318
|
+
</FootnoteScope>,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
const marker = container.querySelector('[data-footnote="1"]') as HTMLAnchorElement
|
|
322
|
+
const describedBy = marker.getAttribute('aria-describedby')!
|
|
323
|
+
const target = container.querySelector(`#${describedBy}`)
|
|
324
|
+
|
|
325
|
+
expect(marker.getAttribute('href')).toBe(`#${describedBy}`)
|
|
326
|
+
expect(target).toHaveTextContent(SETTLEMENT)
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('names the marker for a screen reader', () => {
|
|
330
|
+
render(
|
|
331
|
+
<FootnoteScope>
|
|
332
|
+
<p>
|
|
333
|
+
Streams
|
|
334
|
+
<Footnote note={SETTLEMENT} />
|
|
335
|
+
</p>
|
|
336
|
+
</FootnoteScope>,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
expect(screen.getByRole('link', { name: 'Note 1' })).toBeInTheDocument()
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('drops the note when the last item claiming it goes away', () => {
|
|
343
|
+
const { rerender } = render(
|
|
344
|
+
<FootnoteScope>
|
|
345
|
+
<p>
|
|
346
|
+
Spotify
|
|
347
|
+
<Footnote note={SETTLEMENT} />
|
|
348
|
+
</p>
|
|
349
|
+
</FootnoteScope>,
|
|
350
|
+
)
|
|
351
|
+
expect(screen.getAllByText(SETTLEMENT, { exact: false })).toHaveLength(1)
|
|
352
|
+
|
|
353
|
+
rerender(
|
|
354
|
+
<FootnoteScope>
|
|
355
|
+
<p>Spotify</p>
|
|
356
|
+
</FootnoteScope>,
|
|
357
|
+
)
|
|
358
|
+
expect(screen.queryByText(SETTLEMENT, { exact: false })).not.toBeInTheDocument()
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
it('renders no list at all when nothing claimed a note', () => {
|
|
362
|
+
render(
|
|
363
|
+
<FootnoteScope>
|
|
364
|
+
<p>Spotify</p>
|
|
365
|
+
</FootnoteScope>,
|
|
366
|
+
)
|
|
367
|
+
expect(screen.queryByRole('list')).not.toBeInTheDocument()
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
it('keeps the information when a Footnote has no scope above it', async () => {
|
|
371
|
+
const user = userEvent.setup()
|
|
372
|
+
render(<Footnote note={SETTLEMENT} subject="Streams" />)
|
|
373
|
+
|
|
374
|
+
const trigger = screen.getByRole('button', { name: 'Note Streams' })
|
|
375
|
+
await user.click(trigger)
|
|
376
|
+
expect(screen.getByRole('note')).toHaveTextContent(SETTLEMENT)
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
it('emits no physical-direction classes', () => {
|
|
380
|
+
const { container } = render(
|
|
381
|
+
<FootnoteScope>
|
|
382
|
+
<p>
|
|
383
|
+
Spotify
|
|
384
|
+
<Footnote note={SETTLEMENT} />
|
|
385
|
+
</p>
|
|
386
|
+
</FootnoteScope>,
|
|
387
|
+
)
|
|
388
|
+
expect(physicalClassesIn(container)).toEqual([])
|
|
389
|
+
})
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
// Absence — tiers of "we do not have this"
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
describe('Absence', () => {
|
|
397
|
+
it('renders a missing value as a dash, never a zero and never a blank', () => {
|
|
398
|
+
const { container } = render(
|
|
399
|
+
<Absence tier="value" title="Anghami has not reported plays" />,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
expect(container.textContent).toContain(ABSENCE_DASH)
|
|
403
|
+
expect(container.textContent).not.toMatch(/\b0\b/)
|
|
404
|
+
expect(container.textContent?.trim()).not.toBe('')
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
it('names what is missing for a screen reader on the value tier', () => {
|
|
408
|
+
render(
|
|
409
|
+
<Absence
|
|
410
|
+
tier="value"
|
|
411
|
+
title="Anghami has not reported plays"
|
|
412
|
+
description="Nothing has been sent for this platform."
|
|
413
|
+
/>,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
expect(
|
|
417
|
+
screen.getByText(
|
|
418
|
+
'Anghami has not reported plays. Nothing has been sent for this platform.',
|
|
419
|
+
),
|
|
420
|
+
).toBeInTheDocument()
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
it('carries the reason behind a disclosure rather than in the body', async () => {
|
|
424
|
+
const user = userEvent.setup()
|
|
425
|
+
render(
|
|
426
|
+
<Absence
|
|
427
|
+
tier="value"
|
|
428
|
+
title="Monthly listeners"
|
|
429
|
+
why="Anghami has not reported this."
|
|
430
|
+
/>,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
expect(screen.queryByText('Anghami has not reported this.')).not.toBeVisible()
|
|
434
|
+
await user.click(screen.getByRole('button'))
|
|
435
|
+
expect(screen.getByText('Anghami has not reported this.')).toBeVisible()
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
it('renders the note tier as one line, not a card', () => {
|
|
439
|
+
render(
|
|
440
|
+
<Absence
|
|
441
|
+
tier="note"
|
|
442
|
+
title="One reading so far"
|
|
443
|
+
description="The next refresh gives us something to compare it against."
|
|
444
|
+
/>,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
const note = screen.getByRole('note')
|
|
448
|
+
expect(note).toHaveAttribute('data-absence', 'note')
|
|
449
|
+
expect(note).toHaveTextContent('One reading so far')
|
|
450
|
+
expect(note.className).not.toContain('shadow')
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
it('draws the note tier as a dashed inset when it stands in for content', () => {
|
|
454
|
+
render(
|
|
455
|
+
<Absence tier="note" inset title="No history yet" description="Nothing recorded." />,
|
|
456
|
+
)
|
|
457
|
+
expect(screen.getByRole('note').className).toContain('border-dashed')
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
it('renders the card tier with its own chrome and a why behind a disclosure', async () => {
|
|
461
|
+
const user = userEvent.setup()
|
|
462
|
+
const { container } = render(
|
|
463
|
+
<Absence
|
|
464
|
+
tier="card"
|
|
465
|
+
title="Your earnings are not connected yet"
|
|
466
|
+
description="There is no monthly figure to show you."
|
|
467
|
+
why="Streams and money are not the same number."
|
|
468
|
+
/>,
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
expect(container.querySelector('[data-absence="card"]')).not.toBeNull()
|
|
472
|
+
expect(
|
|
473
|
+
screen.getByText('Your earnings are not connected yet'),
|
|
474
|
+
).toBeInTheDocument()
|
|
475
|
+
expect(screen.queryByText('Streams and money are not the same number.')).not.toBeVisible()
|
|
476
|
+
|
|
477
|
+
await user.click(screen.getByRole('button'))
|
|
478
|
+
expect(screen.getByText('Streams and money are not the same number.')).toBeVisible()
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
it('renders the page tier as the full surface, with an action', async () => {
|
|
482
|
+
const user = userEvent.setup()
|
|
483
|
+
const onClick = jest.fn()
|
|
484
|
+
render(
|
|
485
|
+
<Absence
|
|
486
|
+
tier="page"
|
|
487
|
+
title="Nothing to show yet"
|
|
488
|
+
description="Your account is not linked to an artist profile."
|
|
489
|
+
action={{ label: 'Talk to MCR', onClick }}
|
|
490
|
+
icon={<span data-testid="icon" />}
|
|
491
|
+
/>,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
const surface = screen.getByRole('status')
|
|
495
|
+
expect(surface).toHaveAttribute('data-absence', 'page')
|
|
496
|
+
expect(screen.getByTestId('icon')).toBeInTheDocument()
|
|
497
|
+
|
|
498
|
+
await user.click(screen.getByRole('button', { name: 'Talk to MCR' }))
|
|
499
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
it('costs a different amount of the page at each tier', () => {
|
|
503
|
+
const value = render(<Absence tier="value" title="Missing" />)
|
|
504
|
+
const page = render(
|
|
505
|
+
<Absence tier="page" title="Missing" description="Nothing here yet." />,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
// A missing tile is a span; a missing screen is a live region.
|
|
509
|
+
expect(value.container.querySelector('[data-absence="value"]')?.tagName).toBe('SPAN')
|
|
510
|
+
expect(page.container.querySelector('[data-absence="page"]')).toHaveAttribute(
|
|
511
|
+
'aria-live',
|
|
512
|
+
'polite',
|
|
513
|
+
)
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
it('says a screen-level sentence once across several absent cards', () => {
|
|
517
|
+
const NOT_CONNECTED = 'Not connected yet — none of the above is a report of zero.'
|
|
518
|
+
|
|
519
|
+
render(
|
|
520
|
+
<FootnoteScope>
|
|
521
|
+
<Absence tier="card" title="Renewals" description="Nothing filed." note={NOT_CONNECTED} />
|
|
522
|
+
<Absence tier="card" title="Register a work" description="Not wired up." note={NOT_CONNECTED} />
|
|
523
|
+
<Absence tier="card" title="Billing" description="Nothing connected." note={NOT_CONNECTED} />
|
|
524
|
+
</FootnoteScope>,
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
expect(screen.getAllByText(NOT_CONNECTED, { exact: false })).toHaveLength(1)
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
it('never renders a spinner in place of an answer', () => {
|
|
531
|
+
const { container } = render(
|
|
532
|
+
<Absence tier="card" title="Renewals" description="Nothing filed." />,
|
|
533
|
+
)
|
|
534
|
+
expect(container.querySelector('.animate-spin')).toBeNull()
|
|
535
|
+
expect(container.querySelector('.animate-pulse')).toBeNull()
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
it('emits no physical-direction classes at any tier', () => {
|
|
539
|
+
const tiers = ['value', 'note', 'card', 'page'] as const
|
|
540
|
+
tiers.forEach((tier) => {
|
|
541
|
+
const { container, unmount } = render(
|
|
542
|
+
<Absence tier={tier} title="Missing" description="Nothing here." why="Nobody reported it." />,
|
|
543
|
+
)
|
|
544
|
+
expect(physicalClassesIn(container)).toEqual([])
|
|
545
|
+
unmount()
|
|
546
|
+
})
|
|
547
|
+
})
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
// ---------------------------------------------------------------------------
|
|
551
|
+
// EmptyState — extended, not replaced
|
|
552
|
+
// ---------------------------------------------------------------------------
|
|
553
|
+
|
|
554
|
+
describe('EmptyState', () => {
|
|
555
|
+
it('draws an optional icon above the heading', () => {
|
|
556
|
+
render(<EmptyState title="Nothing yet" icon={<span data-testid="icon" />} />)
|
|
557
|
+
expect(screen.getByTestId('icon')).toBeInTheDocument()
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
it('renders extra content under the action', () => {
|
|
561
|
+
render(
|
|
562
|
+
<EmptyState title="Nothing yet">
|
|
563
|
+
<span data-testid="extra" />
|
|
564
|
+
</EmptyState>,
|
|
565
|
+
)
|
|
566
|
+
expect(screen.getByTestId('extra')).toBeInTheDocument()
|
|
567
|
+
})
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
// ---------------------------------------------------------------------------
|
|
571
|
+
// The canonical slots: StatTile, CardHeader, DashboardSection
|
|
572
|
+
// ---------------------------------------------------------------------------
|
|
573
|
+
|
|
574
|
+
describe('caveat slots', () => {
|
|
575
|
+
it('StatTile holds a caveat beside the kicker, not in the body', async () => {
|
|
576
|
+
const user = userEvent.setup()
|
|
577
|
+
render(
|
|
578
|
+
<StatTile
|
|
579
|
+
label="Streams reported"
|
|
580
|
+
value="1.2M"
|
|
581
|
+
caveat="Some platforms report a lifetime count."
|
|
582
|
+
/>,
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
const trigger = screen.getByRole('button', { name: 'Why? Streams reported' })
|
|
586
|
+
expect(
|
|
587
|
+
screen.queryByText('Some platforms report a lifetime count.'),
|
|
588
|
+
).not.toBeVisible()
|
|
589
|
+
|
|
590
|
+
await user.click(trigger)
|
|
591
|
+
expect(screen.getByText('Some platforms report a lifetime count.')).toBeVisible()
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
it('StatTile renders exactly as before when no caveat is passed', () => {
|
|
595
|
+
render(<StatTile label="Streams" value="1.2M" />)
|
|
596
|
+
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
|
597
|
+
expect(screen.getByText('1.2M')).toBeInTheDocument()
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
it('CardHeader holds a caveat without disturbing a card that has none', async () => {
|
|
601
|
+
const user = userEvent.setup()
|
|
602
|
+
const plain = render(
|
|
603
|
+
<Card>
|
|
604
|
+
<CardHeader>
|
|
605
|
+
<CardTitle>Earnings</CardTitle>
|
|
606
|
+
</CardHeader>
|
|
607
|
+
</Card>,
|
|
608
|
+
)
|
|
609
|
+
expect(plain.container.querySelectorAll('button')).toHaveLength(0)
|
|
610
|
+
plain.unmount()
|
|
611
|
+
|
|
612
|
+
render(
|
|
613
|
+
<Card>
|
|
614
|
+
<CardHeader caveat="Settlement lags about a month.">
|
|
615
|
+
<CardTitle>Earnings</CardTitle>
|
|
616
|
+
</CardHeader>
|
|
617
|
+
</Card>,
|
|
618
|
+
)
|
|
619
|
+
await user.click(screen.getByRole('button'))
|
|
620
|
+
expect(screen.getByText('Settlement lags about a month.')).toBeVisible()
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
it('DashboardSection prints a tile-authored note once per section', () => {
|
|
624
|
+
const SETTLEMENT = 'Streaming settles about a month after play.'
|
|
625
|
+
|
|
626
|
+
render(
|
|
627
|
+
<DashboardSection title="Earnings">
|
|
628
|
+
<StatTile label="Spotify" value="1.2M" note={SETTLEMENT} />
|
|
629
|
+
<StatTile label="Apple" value="0.8M" note={SETTLEMENT} />
|
|
630
|
+
<StatTile label="Anghami" value="0.1M" note={SETTLEMENT} />
|
|
631
|
+
<StatTile label="Deezer" value="0.4M" note={SETTLEMENT} />
|
|
632
|
+
</DashboardSection>,
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
expect(screen.getAllByText(SETTLEMENT, { exact: false })).toHaveLength(1)
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
it('DashboardSection can opt out of collecting footnotes', () => {
|
|
639
|
+
render(
|
|
640
|
+
<DashboardSection title="Earnings" footnotes={false}>
|
|
641
|
+
<StatTile label="Spotify" value="1.2M" note="Settles late." />
|
|
642
|
+
</DashboardSection>,
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
// With no scope the note falls back to a per-tile disclosure rather than
|
|
646
|
+
// disappearing.
|
|
647
|
+
expect(screen.queryByRole('list')).not.toBeInTheDocument()
|
|
648
|
+
expect(screen.getByRole('button', { name: 'Note Spotify' })).toBeInTheDocument()
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
it('DashboardSection carries a caveat on its heading', () => {
|
|
652
|
+
render(
|
|
653
|
+
<DashboardSection title="Earnings" caveat="Estimates only.">
|
|
654
|
+
<div />
|
|
655
|
+
</DashboardSection>,
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
expect(
|
|
659
|
+
screen.getByRole('button', { name: 'Why? Earnings' }),
|
|
660
|
+
).toBeInTheDocument()
|
|
661
|
+
})
|
|
662
|
+
})
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
export { ErrorState } from './ErrorState'
|
|
2
2
|
export type { ErrorStateProps } from './ErrorState'
|
|
3
|
-
export { EmptyState } from './EmptyState'
|
|
3
|
+
export { EmptyState, StateAction } from './EmptyState'
|
|
4
4
|
export type { EmptyStateProps, EmptyStateAction } from './EmptyState'
|
|
5
|
+
export { Caveat } from './Caveat'
|
|
6
|
+
export type { CaveatProps, CaveatPlacement } from './Caveat'
|
|
7
|
+
export { FootnoteScope, Footnote } from './Footnote'
|
|
8
|
+
export type { FootnoteScopeProps, FootnoteProps } from './Footnote'
|
|
9
|
+
export { Absence, ABSENCE_DASH } from './Absence'
|
|
10
|
+
export type { AbsenceProps, AbsenceTier } from './Absence'
|