@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.
@@ -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'