Skip to content

Commit dabcf46

Browse files
committed
feat(web): add shared EmptyState component with variants
- Create reusable EmptyState component with no-data, no-results, error, loading variants - Add decorative icon, quick start guide, action buttons, and tips support - Include preset configurations for common empty states (bookmarks, catalogue, etc.) - Follows GraphEmptyState pattern for consistency
1 parent 50f8169 commit dabcf46

File tree

1 file changed

+287
-0
lines changed

1 file changed

+287
-0
lines changed
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
/**
2+
* EmptyState component
3+
* Provides consistent empty state visuals across the application
4+
* @module EmptyState
5+
*/
6+
7+
import {
8+
Box,
9+
Button,
10+
ButtonProps,
11+
Card,
12+
Group,
13+
Stack,
14+
Text,
15+
ThemeIcon,
16+
Title,
17+
} from '@mantine/core';
18+
import {
19+
IconAlertCircle,
20+
IconCircleNumber1,
21+
IconCircleNumber2,
22+
IconCircleNumber3,
23+
IconDatabase,
24+
IconFilter,
25+
IconSearch,
26+
IconX,
27+
} from '@tabler/icons-react';
28+
29+
import { ICON_SIZE } from '@/config/style-constants';
30+
31+
export type EmptyStateVariant =
32+
| 'no-data' // No data exists at all
33+
| 'no-results' // Data exists but filters returned nothing
34+
| 'error' // Error state
35+
| 'loading'; // Loading state (for skeleton screens)
36+
37+
export interface EmptyStateAction extends Omit<ButtonProps, 'children'> {
38+
label: string;
39+
}
40+
41+
export interface EmptyStateProps {
42+
/** Which empty state variant to display */
43+
variant: EmptyStateVariant;
44+
/** Icon to display (defaults based on variant) */
45+
icon?: React.ReactNode;
46+
/** Headline text */
47+
title: string;
48+
/** Optional description text */
49+
description?: string;
50+
/** Optional action buttons */
51+
actions?: EmptyStateAction[];
52+
/** Optional tips/suggestions to display */
53+
tips?: string[];
54+
/** Optional quick start guide steps */
55+
quickStart?: { step: string; detail: string }[];
56+
}
57+
58+
/**
59+
* Large decorative icon with a subtle background
60+
* @param root0
61+
* @param root0.children
62+
* @param root0.color
63+
*/
64+
const DecorativeIcon = ({ children, color = 'blue' }: { children: React.ReactNode; color?: 'blue' | 'gray' | 'red' }) => (
65+
<Box
66+
style={{
67+
width: 120,
68+
height: 120,
69+
borderRadius: '50%',
70+
backgroundColor: `var(--mantine-color-${color}-light)`,
71+
display: 'flex',
72+
alignItems: 'center',
73+
justifyContent: 'center',
74+
margin: '0 auto',
75+
}}
76+
>
77+
<ThemeIcon
78+
variant="light"
79+
color={color}
80+
size={80}
81+
radius="xl"
82+
style={{ border: 'none', background: 'transparent' }}
83+
>
84+
{children}
85+
</ThemeIcon>
86+
</Box>
87+
);
88+
89+
/**
90+
* Default icons for each variant
91+
*/
92+
const DEFAULT_ICONS: Record<EmptyStateVariant, React.ReactNode> = {
93+
'no-data': <IconDatabase size={ICON_SIZE.HERO_LG} stroke={1.2} />,
94+
'no-results': <IconFilter size={ICON_SIZE.HERO_LG} stroke={1.2} />,
95+
'error': <IconAlertCircle size={ICON_SIZE.HERO_LG} stroke={1.2} />,
96+
'loading': <IconSearch size={ICON_SIZE.HERO_LG} stroke={1.2} />,
97+
};
98+
99+
const DEFAULT_COLORS: Record<EmptyStateVariant, 'blue' | 'gray' | 'red'> = {
100+
'no-data': 'blue',
101+
'no-results': 'gray',
102+
'error': 'red',
103+
'loading': 'blue',
104+
};
105+
106+
/**
107+
* EmptyState displays consistent, helpful empty states throughout the application
108+
* @param root0
109+
* @param root0.variant
110+
* @param root0.icon
111+
* @param root0.title
112+
* @param root0.description
113+
* @param root0.actions
114+
* @param root0.tips
115+
* @param root0.quickStart
116+
*/
117+
export const EmptyState = ({
118+
variant,
119+
icon,
120+
title,
121+
description,
122+
actions,
123+
tips,
124+
quickStart,
125+
}: EmptyStateProps) => {
126+
const displayIcon = icon ?? DEFAULT_ICONS[variant];
127+
const iconColor = DEFAULT_COLORS[variant];
128+
129+
return (
130+
<Stack align="center" gap="xl" py="xl">
131+
<DecorativeIcon color={iconColor}>{displayIcon}</DecorativeIcon>
132+
133+
<Stack align="center" gap="xs" maw={600}>
134+
<Title order={2} ta="center">
135+
{title}
136+
</Title>
137+
{description && (
138+
<Text c="dimmed" ta="center" size="lg">
139+
{description}
140+
</Text>
141+
)}
142+
</Stack>
143+
144+
{/* Quick start guide */}
145+
{quickStart && quickStart.length > 0 && (
146+
<Card withBorder radius="md" p="lg" maw={500} w="100%">
147+
<Stack gap="md">
148+
<Text fw={500} size="sm" c="dimmed" tt="uppercase">
149+
Quick Start
150+
</Text>
151+
<Stack gap="sm">
152+
{quickStart.map((item, index) => (
153+
<Group key={index} gap="sm">
154+
<ThemeIcon color="blue" size={24} radius="xl">
155+
{index === 0 && <IconCircleNumber1 size={ICON_SIZE.MD} />}
156+
{index === 1 && <IconCircleNumber2 size={ICON_SIZE.MD} />}
157+
{index === 2 && <IconCircleNumber3 size={ICON_SIZE.MD} />}
158+
{index > 2 && <Text size="sm" fw={700}>{index + 1}</Text>}
159+
</ThemeIcon>
160+
<Text size="sm">
161+
<Text span fw={500}>
162+
{item.step}
163+
</Text>
164+
{item.detail && (
165+
<>
166+
{' '}{item.detail}
167+
</>
168+
)}
169+
</Text>
170+
</Group>
171+
))}
172+
</Stack>
173+
</Stack>
174+
</Card>
175+
)}
176+
177+
{/* Action buttons */}
178+
{actions && actions.length > 0 && (
179+
<Group gap="sm">
180+
{actions.map((action, index) => (
181+
<Button
182+
key={index}
183+
{...action}
184+
>
185+
{action.label}
186+
</Button>
187+
))}
188+
</Group>
189+
)}
190+
191+
{/* Tips */}
192+
{tips && tips.length > 0 && (
193+
<Card withBorder radius="md" p="md" maw={500} w="100%" bg="var(--mantine-color-gray-light)">
194+
<Stack gap="xs">
195+
{tips.map((tip, index) => (
196+
<Group key={index} gap="xs">
197+
<ThemeIcon variant="light" color="blue" size="sm" radius="xl">
198+
<IconSearch size={ICON_SIZE.XS} />
199+
</ThemeIcon>
200+
<Text size="xs" c="dimmed">
201+
{tip}
202+
</Text>
203+
</Group>
204+
))}
205+
</Stack>
206+
</Card>
207+
)}
208+
</Stack>
209+
);
210+
};
211+
212+
/**
213+
* Preset configurations for common empty states
214+
*/
215+
export const EmptyStatePresets = {
216+
/**
217+
* No data in bookmarks/history/catalogue
218+
* @param props
219+
*/
220+
noBookmarks: (props?: Partial<EmptyStateProps>): EmptyStateProps => ({
221+
variant: 'no-data',
222+
title: 'No Bookmarks Yet',
223+
description: 'Bookmark entities to build your research collection.',
224+
quickStart: [
225+
{ step: 'Search for entities', detail: 'Find authors, works, or institutions' },
226+
{ step: 'Click bookmark icon', detail: 'Add to your collection' },
227+
{ step: 'Access anytime', detail: 'View in Bookmarks or Catalogue' },
228+
],
229+
...props,
230+
}),
231+
232+
/**
233+
* No results after filtering
234+
* @param filterCount
235+
*/
236+
noFilteredResults: (filterCount = 0): EmptyStateProps => ({
237+
variant: 'no-results',
238+
title: 'No Matching Results',
239+
description: filterCount > 0
240+
? `Adjust your ${filterCount} filter${filterCount > 1 ? 's' : ''} to see more results.`
241+
: 'Try adjusting your search or filters.',
242+
actions: [
243+
{ label: 'Clear Filters', variant: 'light', color: 'gray', leftSection: <IconX size={ICON_SIZE.SM} /> },
244+
],
245+
}),
246+
247+
/**
248+
* No search results
249+
* @param query
250+
*/
251+
noSearchResults: (query?: string): EmptyStateProps => ({
252+
variant: 'no-results',
253+
title: 'No Results Found',
254+
description: query
255+
? `No matches found for "${query}"`
256+
: 'Try different keywords or check your spelling.',
257+
tips: [
258+
'Use fewer words to broaden your search',
259+
'Check for typos or alternate spellings',
260+
'Try searching by ID instead',
261+
],
262+
}),
263+
264+
/** Empty catalogue */
265+
noCatalogue: (): EmptyStateProps => ({
266+
variant: 'no-data',
267+
title: 'Your Catalogue is Empty',
268+
description: 'Entities you view are automatically cached for offline access.',
269+
quickStart: [
270+
{ step: 'Browse OpenAlex', detail: 'Explore the academic database' },
271+
{ step: 'View entity details', detail: 'Pages are cached automatically' },
272+
{ step: 'Access offline', detail: 'View cached entities anytime' },
273+
],
274+
}),
275+
276+
/** Graph no entities */
277+
noGraphEntities: (): EmptyStateProps => ({
278+
variant: 'no-data',
279+
title: 'No Entities to Display',
280+
description: 'Enable data sources to populate your graph visualization.',
281+
quickStart: [
282+
{ step: 'Toggle sources', detail: 'Enable bookmarks, history, or catalogue' },
283+
{ step: 'Entities appear', detail: 'Nodes show your data sources' },
284+
{ step: 'Explore relationships', detail: 'Click nodes to visualize connections' },
285+
],
286+
}),
287+
};

0 commit comments

Comments
 (0)