@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.95",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"react": "^18.0.0 || ^19.0.0",
|
|
46
46
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
47
47
|
"@startsimpli/api": "^0.5.54",
|
|
48
|
-
"@startsimpli/auth": "^0.4.
|
|
48
|
+
"@startsimpli/auth": "^0.4.35",
|
|
49
49
|
"@startsimpli/i18n": "^0.1.1"
|
|
50
50
|
},
|
|
51
51
|
"peerDependenciesMeta": {
|
|
@@ -120,8 +120,8 @@
|
|
|
120
120
|
"tailwindcss": "^3.4.19",
|
|
121
121
|
"tsup": "^8.5.1",
|
|
122
122
|
"typescript": "^6.0.3",
|
|
123
|
-
"@startsimpli/
|
|
124
|
-
"@startsimpli/
|
|
123
|
+
"@startsimpli/realtime": "0.0.2",
|
|
124
|
+
"@startsimpli/i18n": "0.1.1"
|
|
125
125
|
},
|
|
126
126
|
"keywords": [
|
|
127
127
|
"ui",
|
|
@@ -1,19 +1,40 @@
|
|
|
1
1
|
import * as React from 'react'
|
|
2
2
|
import { cn } from '../../lib/utils'
|
|
3
3
|
import { TYPE_SCALE } from '../../theme/type-scale'
|
|
4
|
+
import { Caveat } from '../states/Caveat'
|
|
5
|
+
import { FootnoteScope } from '../states/Footnote'
|
|
4
6
|
|
|
5
7
|
export interface DashboardSectionProps {
|
|
6
8
|
title: string
|
|
7
9
|
description?: string
|
|
8
10
|
action?: React.ReactNode
|
|
11
|
+
/**
|
|
12
|
+
* A qualification of everything in this section. Collapses to a marker beside
|
|
13
|
+
* the heading and expands on demand.
|
|
14
|
+
*/
|
|
15
|
+
caveat?: React.ReactNode
|
|
16
|
+
/**
|
|
17
|
+
* Collect the notes the section's items claim and print each distinct one
|
|
18
|
+
* once, underneath them. On by default — a caveat authored per item is a
|
|
19
|
+
* caveat said per section, not per item. Pass `false` to opt a section out.
|
|
20
|
+
*/
|
|
21
|
+
footnotes?: boolean
|
|
9
22
|
children: React.ReactNode
|
|
10
23
|
className?: string
|
|
11
24
|
}
|
|
12
25
|
|
|
26
|
+
/**
|
|
27
|
+
* A titled band of a dashboard, and the scope its items' footnotes collect into.
|
|
28
|
+
*
|
|
29
|
+
* The scope is the whole point of the default: an item can author the caveat it
|
|
30
|
+
* knows about without anyone counting how many other items say the same thing.
|
|
31
|
+
*/
|
|
13
32
|
export function DashboardSection({
|
|
14
33
|
title,
|
|
15
34
|
description,
|
|
16
35
|
action,
|
|
36
|
+
caveat,
|
|
37
|
+
footnotes = true,
|
|
17
38
|
children,
|
|
18
39
|
className = '',
|
|
19
40
|
}: DashboardSectionProps) {
|
|
@@ -24,7 +45,10 @@ export function DashboardSection({
|
|
|
24
45
|
{/* `heading` is the section step of the type scale; the colour comes
|
|
25
46
|
from the theme, so this reads on a dark tenant as well as a light
|
|
26
47
|
one. `text-gray-900`/`text-gray-600` used to hard-code light. */}
|
|
27
|
-
<h2 className={cn(TYPE_SCALE.heading, 'text-foreground')}>
|
|
48
|
+
<h2 className={cn(TYPE_SCALE.heading, 'text-foreground')}>
|
|
49
|
+
{title}
|
|
50
|
+
{caveat ? <Caveat subject={title}>{caveat}</Caveat> : null}
|
|
51
|
+
</h2>
|
|
28
52
|
{description && (
|
|
29
53
|
<p className={cn(TYPE_SCALE.muted, 'mt-1 text-muted-foreground')}>
|
|
30
54
|
{description}
|
|
@@ -33,7 +57,7 @@ export function DashboardSection({
|
|
|
33
57
|
</div>
|
|
34
58
|
{action && <div className="flex-shrink-0">{action}</div>}
|
|
35
59
|
</div>
|
|
36
|
-
{children}
|
|
60
|
+
{footnotes ? <FootnoteScope>{children}</FootnoteScope> : children}
|
|
37
61
|
</div>
|
|
38
62
|
)
|
|
39
63
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import * as React from 'react'
|
|
2
2
|
import { cn } from '../../lib/utils'
|
|
3
|
+
import { Caveat } from '../states/Caveat'
|
|
4
|
+
import { Footnote } from '../states/Footnote'
|
|
3
5
|
import { AreaTrend } from './AreaTrend'
|
|
4
6
|
import { DeltaPill, type DeltaPillProps } from './DeltaPill'
|
|
5
7
|
|
|
@@ -20,6 +22,17 @@ export interface StatTileProps {
|
|
|
20
22
|
sparklineColor?: string
|
|
21
23
|
/** Numeral scale — `lg` (text-4xl) by default, `xl` for a hero figure. */
|
|
22
24
|
size?: StatTileSize
|
|
25
|
+
/**
|
|
26
|
+
* A qualification of this figure. Collapses to a marker beside the kicker and
|
|
27
|
+
* expands on demand, so it never competes with the number for weight.
|
|
28
|
+
*/
|
|
29
|
+
caveat?: React.ReactNode
|
|
30
|
+
/**
|
|
31
|
+
* A caveat that belongs to the whole section rather than this tile. Inside a
|
|
32
|
+
* `FootnoteScope` it is printed once under the section, however many tiles
|
|
33
|
+
* carry it.
|
|
34
|
+
*/
|
|
35
|
+
note?: string
|
|
23
36
|
/** Extra content below the delta. */
|
|
24
37
|
children?: React.ReactNode
|
|
25
38
|
className?: string
|
|
@@ -45,6 +58,8 @@ export function StatTile({
|
|
|
45
58
|
sparkline,
|
|
46
59
|
sparklineColor,
|
|
47
60
|
size = 'lg',
|
|
61
|
+
caveat,
|
|
62
|
+
note,
|
|
48
63
|
children,
|
|
49
64
|
className,
|
|
50
65
|
}: StatTileProps) {
|
|
@@ -57,8 +72,12 @@ export function StatTile({
|
|
|
57
72
|
className,
|
|
58
73
|
)}
|
|
59
74
|
>
|
|
60
|
-
|
|
75
|
+
{/* The kicker is the canonical home for a caveat: beside the thing it
|
|
76
|
+
qualifies, at the weight of a label rather than a number. */}
|
|
77
|
+
<p className="text-start text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
61
78
|
{label}
|
|
79
|
+
{caveat ? <Caveat subject={label}>{caveat}</Caveat> : null}
|
|
80
|
+
{note ? <Footnote note={note} subject={label} /> : null}
|
|
62
81
|
</p>
|
|
63
82
|
|
|
64
83
|
<div className="mt-2 flex items-center justify-between gap-4">
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cn } from '../../lib/utils'
|
|
3
|
+
import { Card, CardContent } from '../ui/card'
|
|
4
|
+
import { Caveat } from './Caveat'
|
|
5
|
+
import { Footnote } from './Footnote'
|
|
6
|
+
import { EmptyState, StateAction, type EmptyStateAction } from './EmptyState'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How much is missing, and therefore how much room saying so is worth.
|
|
10
|
+
*
|
|
11
|
+
* - `value` — one figure inside a tile that is otherwise alive. An em dash.
|
|
12
|
+
* - `note` — one line, no chrome, under or beside data that is present.
|
|
13
|
+
* - `card` — a card slot with nothing behind it at all.
|
|
14
|
+
* - `page` — the whole surface has nothing to show.
|
|
15
|
+
*
|
|
16
|
+
* The ladder exists because a missing tile and a missing screen are not the
|
|
17
|
+
* same event, and rendering them identically makes a dashboard read as a
|
|
18
|
+
* disclaimer document.
|
|
19
|
+
*/
|
|
20
|
+
export type AbsenceTier = 'value' | 'note' | 'card' | 'page'
|
|
21
|
+
|
|
22
|
+
/** What stands in for a figure nobody reported. Never a 0, never a blank. */
|
|
23
|
+
export const ABSENCE_DASH = '—'
|
|
24
|
+
|
|
25
|
+
export interface AbsenceProps {
|
|
26
|
+
tier: AbsenceTier
|
|
27
|
+
/**
|
|
28
|
+
* What is missing, in a phrase. On the `value` tier it is not drawn — it is
|
|
29
|
+
* the screen-reader text behind the dash, so the absence is never silent.
|
|
30
|
+
*/
|
|
31
|
+
title: string
|
|
32
|
+
/** One line of what. Never a number and never a zero — those mean something else. */
|
|
33
|
+
description?: string
|
|
34
|
+
/**
|
|
35
|
+
* Why it is missing, where that is known ("Anghami has not reported this").
|
|
36
|
+
* Carried by a disclosure, so the reason is available without being shouted.
|
|
37
|
+
*/
|
|
38
|
+
why?: React.ReactNode
|
|
39
|
+
/**
|
|
40
|
+
* A sentence that belongs to the whole screen rather than this card. Inside a
|
|
41
|
+
* `FootnoteScope` it is printed once, however many absences claim it.
|
|
42
|
+
*/
|
|
43
|
+
note?: string
|
|
44
|
+
/** `page` tier: drawn above the heading. */
|
|
45
|
+
icon?: React.ReactNode
|
|
46
|
+
/** What the reader can do about it, where there is something. */
|
|
47
|
+
action?: EmptyStateAction
|
|
48
|
+
/** `note` tier: a dashed inset instead of a bare line, for a gap inside a card. */
|
|
49
|
+
inset?: boolean
|
|
50
|
+
className?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* One vocabulary for "we do not have this", graded by how much is missing.
|
|
55
|
+
*
|
|
56
|
+
* Every tier says WHAT is absent and, where it is known, WHY — and none of them
|
|
57
|
+
* renders a 0, a blank cell or a spinner that never ends, because a number
|
|
58
|
+
* nobody reported and a number that is zero are different statements.
|
|
59
|
+
*/
|
|
60
|
+
export function Absence({
|
|
61
|
+
tier,
|
|
62
|
+
title,
|
|
63
|
+
description,
|
|
64
|
+
why,
|
|
65
|
+
note,
|
|
66
|
+
icon,
|
|
67
|
+
action,
|
|
68
|
+
inset,
|
|
69
|
+
className,
|
|
70
|
+
}: AbsenceProps) {
|
|
71
|
+
const caveat = why ? (
|
|
72
|
+
<Caveat subject={title} placement={tier === 'value' ? 'overlay' : 'inline'}>
|
|
73
|
+
{why}
|
|
74
|
+
</Caveat>
|
|
75
|
+
) : null
|
|
76
|
+
const footnote = note ? <Footnote note={note} subject={title} /> : null
|
|
77
|
+
|
|
78
|
+
if (tier === 'value') {
|
|
79
|
+
return (
|
|
80
|
+
<span
|
|
81
|
+
data-absence="value"
|
|
82
|
+
className={cn('inline-flex items-baseline text-muted-foreground', className)}
|
|
83
|
+
>
|
|
84
|
+
<span aria-hidden="true" className="tabular-nums">
|
|
85
|
+
{ABSENCE_DASH}
|
|
86
|
+
</span>
|
|
87
|
+
<span className="sr-only">
|
|
88
|
+
{description ? `${title}. ${description}` : title}
|
|
89
|
+
</span>
|
|
90
|
+
{caveat}
|
|
91
|
+
{footnote}
|
|
92
|
+
</span>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (tier === 'note') {
|
|
97
|
+
return (
|
|
98
|
+
<div
|
|
99
|
+
role="note"
|
|
100
|
+
data-absence="note"
|
|
101
|
+
className={cn(
|
|
102
|
+
'max-w-prose text-start text-sm leading-relaxed text-muted-foreground',
|
|
103
|
+
inset && 'rounded-lg border border-dashed border-border p-4',
|
|
104
|
+
className,
|
|
105
|
+
)}
|
|
106
|
+
>
|
|
107
|
+
<span className={cn('font-medium text-foreground/80', inset && 'block')}>
|
|
108
|
+
{title}
|
|
109
|
+
</span>
|
|
110
|
+
{description ? (
|
|
111
|
+
<span className={cn(inset ? 'mt-1 block' : 'ms-1')}>{description}</span>
|
|
112
|
+
) : null}
|
|
113
|
+
{caveat}
|
|
114
|
+
{footnote}
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (tier === 'card') {
|
|
120
|
+
return (
|
|
121
|
+
<Card data-absence="card" className={cn('text-start', className)}>
|
|
122
|
+
<CardContent className="space-y-1.5 p-5">
|
|
123
|
+
<p className="text-sm font-semibold text-foreground">
|
|
124
|
+
{title}
|
|
125
|
+
{footnote}
|
|
126
|
+
</p>
|
|
127
|
+
{description && (
|
|
128
|
+
<p className="max-w-prose text-sm leading-relaxed text-muted-foreground">
|
|
129
|
+
{description}
|
|
130
|
+
</p>
|
|
131
|
+
)}
|
|
132
|
+
{caveat}
|
|
133
|
+
{action && <StateAction action={action} className="mt-2" />}
|
|
134
|
+
</CardContent>
|
|
135
|
+
</Card>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<EmptyState
|
|
141
|
+
title={title}
|
|
142
|
+
description={description}
|
|
143
|
+
action={action}
|
|
144
|
+
icon={icon}
|
|
145
|
+
className={cn('text-center', className)}
|
|
146
|
+
data-absence="page"
|
|
147
|
+
>
|
|
148
|
+
{(caveat || footnote) && (
|
|
149
|
+
<span className="mt-4 inline-flex items-center text-xs text-muted-foreground">
|
|
150
|
+
{caveat}
|
|
151
|
+
{footnote}
|
|
152
|
+
</span>
|
|
153
|
+
)}
|
|
154
|
+
</EmptyState>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { cn } from '../../lib/utils'
|
|
5
|
+
|
|
6
|
+
export type CaveatPlacement = 'overlay' | 'inline'
|
|
7
|
+
|
|
8
|
+
export interface CaveatProps {
|
|
9
|
+
/** The qualification itself. It stays out of the layout until it is asked for. */
|
|
10
|
+
children: React.ReactNode
|
|
11
|
+
/** Trigger wording. Two words at most — it sits beside a number, not above it. */
|
|
12
|
+
label?: string
|
|
13
|
+
/** What the caveat qualifies. Folded into the trigger's accessible name. */
|
|
14
|
+
subject?: string
|
|
15
|
+
/** `marker` (default) draws a small round glyph; `text` draws the label itself. */
|
|
16
|
+
trigger?: 'marker' | 'text'
|
|
17
|
+
/**
|
|
18
|
+
* `overlay` (default) floats the note over the layout, so a caveat can sit in
|
|
19
|
+
* a tight row without pushing the row apart. `inline` opens it in flow, for
|
|
20
|
+
* surfaces that already have the room.
|
|
21
|
+
*/
|
|
22
|
+
placement?: CaveatPlacement
|
|
23
|
+
/** Open on first paint. Ignored when `open` is supplied. */
|
|
24
|
+
defaultOpen?: boolean
|
|
25
|
+
/** Controlled open state. */
|
|
26
|
+
open?: boolean
|
|
27
|
+
onOpenChange?: (open: boolean) => void
|
|
28
|
+
className?: string
|
|
29
|
+
panelClassName?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A caveat that collapses to a marker and expands on demand.
|
|
34
|
+
*
|
|
35
|
+
* The affordance exists so a qualification ("these are estimates", "settlement
|
|
36
|
+
* lags about a month") can live next to the figure it qualifies without being
|
|
37
|
+
* set at the same weight as the figure. It is a disclosure widget, not a
|
|
38
|
+
* tooltip: the trigger is a real button carrying `aria-expanded` and
|
|
39
|
+
* `aria-controls`, it is reachable and operable from the keyboard, and the note
|
|
40
|
+
* is never hover-only.
|
|
41
|
+
*
|
|
42
|
+
* The panel is always in the DOM and hidden when closed, so `aria-controls`
|
|
43
|
+
* always resolves; `aria-describedby` is attached only while it is open, so a
|
|
44
|
+
* screen reader is never pointed at hidden text.
|
|
45
|
+
*/
|
|
46
|
+
export function Caveat({
|
|
47
|
+
children,
|
|
48
|
+
label = 'Why?',
|
|
49
|
+
subject,
|
|
50
|
+
trigger = 'marker',
|
|
51
|
+
placement = 'overlay',
|
|
52
|
+
defaultOpen = false,
|
|
53
|
+
open,
|
|
54
|
+
onOpenChange,
|
|
55
|
+
className,
|
|
56
|
+
panelClassName,
|
|
57
|
+
}: CaveatProps) {
|
|
58
|
+
const generatedId = React.useId().replace(/:/g, '')
|
|
59
|
+
const panelId = `caveat-${generatedId}`
|
|
60
|
+
const rootRef = React.useRef<HTMLSpanElement>(null)
|
|
61
|
+
const panelRef = React.useRef<HTMLSpanElement>(null)
|
|
62
|
+
const [flipped, setFlipped] = React.useState(false)
|
|
63
|
+
|
|
64
|
+
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
|
|
65
|
+
const isControlled = open !== undefined
|
|
66
|
+
const isOpen = isControlled ? open : uncontrolledOpen
|
|
67
|
+
|
|
68
|
+
const setOpen = React.useCallback(
|
|
69
|
+
(next: boolean) => {
|
|
70
|
+
if (!isControlled) setUncontrolledOpen(next)
|
|
71
|
+
onOpenChange?.(next)
|
|
72
|
+
},
|
|
73
|
+
[isControlled, onOpenChange],
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
// A caveat on the last tile of a row would otherwise open off the edge of the
|
|
77
|
+
// screen. Anchor it to the opposite logical edge instead — measured, so it
|
|
78
|
+
// works the same whichever way the document reads.
|
|
79
|
+
React.useLayoutEffect(() => {
|
|
80
|
+
if (!isOpen || placement !== 'overlay') {
|
|
81
|
+
setFlipped(false)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
const panel = panelRef.current
|
|
85
|
+
if (!panel) return
|
|
86
|
+
const rect = panel.getBoundingClientRect()
|
|
87
|
+
if (rect.width === 0) return
|
|
88
|
+
const viewport = window.innerWidth
|
|
89
|
+
if (rect.right > viewport - 8 || rect.left < 8) setFlipped(true)
|
|
90
|
+
}, [isOpen, placement])
|
|
91
|
+
|
|
92
|
+
// An overlay sits on top of the layout, so a click elsewhere should put it
|
|
93
|
+
// away. An inline note is part of the page and stays until it is dismissed.
|
|
94
|
+
React.useEffect(() => {
|
|
95
|
+
if (!isOpen || placement !== 'overlay') return
|
|
96
|
+
function onPointerDown(event: MouseEvent) {
|
|
97
|
+
const root = rootRef.current
|
|
98
|
+
if (root && !root.contains(event.target as Node)) setOpen(false)
|
|
99
|
+
}
|
|
100
|
+
document.addEventListener('mousedown', onPointerDown)
|
|
101
|
+
return () => document.removeEventListener('mousedown', onPointerDown)
|
|
102
|
+
}, [isOpen, placement, setOpen])
|
|
103
|
+
|
|
104
|
+
const accessibleName = subject ? `${label} ${subject}` : label
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<span
|
|
108
|
+
ref={rootRef}
|
|
109
|
+
className={cn(
|
|
110
|
+
'text-start',
|
|
111
|
+
placement === 'overlay' ? 'relative inline-block' : 'inline',
|
|
112
|
+
className,
|
|
113
|
+
)}
|
|
114
|
+
onKeyDown={(event) => {
|
|
115
|
+
if (event.key === 'Escape' && isOpen) {
|
|
116
|
+
event.stopPropagation()
|
|
117
|
+
setOpen(false)
|
|
118
|
+
}
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
aria-expanded={isOpen}
|
|
124
|
+
aria-controls={panelId}
|
|
125
|
+
aria-describedby={isOpen ? panelId : undefined}
|
|
126
|
+
data-state={isOpen ? 'open' : 'closed'}
|
|
127
|
+
onClick={() => setOpen(!isOpen)}
|
|
128
|
+
className={cn(
|
|
129
|
+
'ms-1 inline-flex shrink-0 items-center justify-center align-middle text-muted-foreground transition-colors',
|
|
130
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
|
131
|
+
trigger === 'marker'
|
|
132
|
+
? 'h-4 w-4 rounded-full border border-border text-[10px] font-semibold leading-none hover:bg-accent hover:text-accent-foreground'
|
|
133
|
+
: 'gap-1 rounded text-xs font-medium underline underline-offset-2 hover:text-foreground',
|
|
134
|
+
)}
|
|
135
|
+
>
|
|
136
|
+
{trigger === 'marker' ? (
|
|
137
|
+
<>
|
|
138
|
+
<span aria-hidden="true">?</span>
|
|
139
|
+
<span className="sr-only">{accessibleName}</span>
|
|
140
|
+
</>
|
|
141
|
+
) : (
|
|
142
|
+
<>
|
|
143
|
+
<span>{label}</span>
|
|
144
|
+
{subject ? <span className="sr-only">{subject}</span> : null}
|
|
145
|
+
</>
|
|
146
|
+
)}
|
|
147
|
+
</button>
|
|
148
|
+
|
|
149
|
+
<span
|
|
150
|
+
ref={panelRef}
|
|
151
|
+
id={panelId}
|
|
152
|
+
role="note"
|
|
153
|
+
hidden={!isOpen}
|
|
154
|
+
className={cn(
|
|
155
|
+
'rounded-md border border-border bg-popover p-2 text-start text-xs font-normal normal-case leading-relaxed tracking-normal text-popover-foreground shadow-sm',
|
|
156
|
+
placement === 'overlay' && 'absolute top-full z-20 mt-1 w-64 max-w-[min(16rem,80vw)]',
|
|
157
|
+
placement === 'overlay' && (flipped ? 'end-0' : 'start-0'),
|
|
158
|
+
placement === 'inline' && 'mt-2 max-w-prose',
|
|
159
|
+
isOpen ? 'block' : 'hidden',
|
|
160
|
+
panelClassName,
|
|
161
|
+
)}
|
|
162
|
+
>
|
|
163
|
+
{children}
|
|
164
|
+
</span>
|
|
165
|
+
</span>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
@@ -6,18 +6,48 @@ export interface EmptyStateAction {
|
|
|
6
6
|
onClick: () => void
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
export interface EmptyStateProps
|
|
9
|
+
export interface EmptyStateProps
|
|
10
|
+
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'title' | 'action'> {
|
|
10
11
|
title: string
|
|
11
12
|
description?: string
|
|
12
13
|
action?: EmptyStateAction
|
|
14
|
+
/** Drawn above the heading. Decorative — the heading carries the meaning. */
|
|
15
|
+
icon?: React.ReactNode
|
|
16
|
+
/** Anything that belongs under the action, e.g. a caveat or a footnote marker. */
|
|
17
|
+
children?: React.ReactNode
|
|
13
18
|
className?: string
|
|
14
19
|
}
|
|
15
20
|
|
|
21
|
+
/** The one button shape every state surface uses, so none of them redraws it. */
|
|
22
|
+
export function StateAction({
|
|
23
|
+
action,
|
|
24
|
+
className,
|
|
25
|
+
}: {
|
|
26
|
+
action: EmptyStateAction
|
|
27
|
+
className?: string
|
|
28
|
+
}) {
|
|
29
|
+
return (
|
|
30
|
+
<button
|
|
31
|
+
onClick={action.onClick}
|
|
32
|
+
className={cn(
|
|
33
|
+
'inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground shadow hover:bg-primary/90 h-9 px-4 py-2 transition-colors',
|
|
34
|
+
className
|
|
35
|
+
)}
|
|
36
|
+
aria-label={action.label}
|
|
37
|
+
>
|
|
38
|
+
{action.label}
|
|
39
|
+
</button>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
16
43
|
export function EmptyState({
|
|
17
44
|
title,
|
|
18
45
|
description,
|
|
19
46
|
action,
|
|
47
|
+
icon,
|
|
48
|
+
children,
|
|
20
49
|
className,
|
|
50
|
+
...rest
|
|
21
51
|
}: EmptyStateProps) {
|
|
22
52
|
return (
|
|
23
53
|
<div
|
|
@@ -27,22 +57,21 @@ export function EmptyState({
|
|
|
27
57
|
)}
|
|
28
58
|
role="status"
|
|
29
59
|
aria-live="polite"
|
|
60
|
+
{...rest}
|
|
30
61
|
>
|
|
62
|
+
{icon && (
|
|
63
|
+
<div className="mb-4 text-muted-foreground" aria-hidden="true">
|
|
64
|
+
{icon}
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
31
67
|
<h3 className="text-xl font-semibold text-foreground mb-3">{title}</h3>
|
|
32
68
|
{description && (
|
|
33
69
|
<p className="text-base text-muted-foreground mb-8 max-w-md leading-relaxed">
|
|
34
70
|
{description}
|
|
35
71
|
</p>
|
|
36
72
|
)}
|
|
37
|
-
{action &&
|
|
38
|
-
|
|
39
|
-
onClick={action.onClick}
|
|
40
|
-
className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground shadow hover:bg-primary/90 h-9 px-4 py-2 transition-colors"
|
|
41
|
-
aria-label={action.label}
|
|
42
|
-
>
|
|
43
|
-
{action.label}
|
|
44
|
-
</button>
|
|
45
|
-
)}
|
|
73
|
+
{action && <StateAction action={action} />}
|
|
74
|
+
{children}
|
|
46
75
|
</div>
|
|
47
76
|
)
|
|
48
77
|
}
|