mirror of
https://github.com/hanzoai/o11y-icons.git
synced 2026-11-03 02:09:25 +00:00
445 lines
13 KiB
TypeScript
445 lines
13 KiB
TypeScript
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
import type React from 'react';
|
|
import { useMemo, useState } from 'react';
|
|
import type { IconSize } from '../lib/icon-config';
|
|
|
|
type GallerySizePreset = IconSize | 'custom';
|
|
|
|
const iconModules = (
|
|
import.meta as unknown as { glob: (p: string, o?: { eager?: boolean }) => Record<string, unknown> }
|
|
).glob('../lib/icons/*.tsx', { eager: true }) as Record<
|
|
string,
|
|
{ default: React.ComponentType<{ size?: string | number; color?: string }> }
|
|
>;
|
|
|
|
function getIconEntries(): [
|
|
string,
|
|
React.ComponentType<{ size?: string | number; color?: string }>,
|
|
][] {
|
|
const entries: [string, React.ComponentType<{ size?: string | number; color?: string }>][] = [];
|
|
for (const [path, mod] of Object.entries(iconModules)) {
|
|
if (!mod?.default) continue;
|
|
const name = path.replace(/^.*\/([^/]+)\.tsx$/, '$1');
|
|
if (name === 'index') continue;
|
|
entries.push([name, mod.default]);
|
|
}
|
|
return entries.sort(([a], [b]) => a.localeCompare(b));
|
|
}
|
|
|
|
const meta: Meta = {
|
|
title: 'Icons / Gallery',
|
|
parameters: {
|
|
layout: 'fullscreen',
|
|
docs: {
|
|
description: {
|
|
component:
|
|
'Browse and search all icons. For install and usage (fixed sizes, custom size), see **Docs** in the sidebar.',
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
export default meta;
|
|
|
|
type Story = StoryObj;
|
|
|
|
function normalizeIconName(name: string): string {
|
|
return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
|
|
}
|
|
|
|
type Theme = 'light' | 'dark';
|
|
|
|
function getThemeStyles(theme: Theme) {
|
|
const isDark = theme === 'dark';
|
|
return {
|
|
page: {
|
|
fontFamily: "'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif",
|
|
background: isDark
|
|
? 'linear-gradient(180deg, #0f1114 0%, #1a1d21 100%)'
|
|
: 'linear-gradient(180deg, #f8f9fb 0%, #f0f2f5 100%)',
|
|
minHeight: '100vh',
|
|
padding: '32px 24px 48px',
|
|
color: isDark ? '#e5e7eb' : '#1a1d21',
|
|
},
|
|
title: {
|
|
fontSize: 22,
|
|
fontWeight: 600,
|
|
color: isDark ? '#f3f4f6' : '#1a1d21',
|
|
margin: 0,
|
|
letterSpacing: '-0.02em',
|
|
},
|
|
subtitle: {
|
|
fontSize: 14,
|
|
color: isDark ? '#9ca3af' : '#5c6370',
|
|
margin: '6px 0 0',
|
|
lineHeight: 1.45,
|
|
},
|
|
toolbar: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 16,
|
|
flexWrap: 'wrap' as const,
|
|
marginBottom: 24,
|
|
padding: '16px 20px',
|
|
background: isDark ? '#1f2328' : '#fff',
|
|
borderRadius: 12,
|
|
boxShadow: isDark ? '0 1px 3px rgba(0,0,0,0.3)' : '0 1px 3px rgba(0,0,0,0.06)',
|
|
border: isDark ? '1px solid #2d3238' : '1px solid transparent',
|
|
},
|
|
search: {
|
|
flex: '1 1 260px',
|
|
minWidth: 0,
|
|
padding: '10px 14px 10px 36px',
|
|
fontSize: 14,
|
|
border: isDark ? '1px solid #374151' : '1px solid #e2e5e9',
|
|
borderRadius: 8,
|
|
background: isDark ? '#111318' : '#fafbfc',
|
|
color: isDark ? '#e5e7eb' : '#1a1d21',
|
|
outline: 'none',
|
|
transition: 'border-color 0.15s, box-shadow 0.15s',
|
|
},
|
|
searchFocus: {
|
|
borderColor: '#3b82f6',
|
|
boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.2)',
|
|
},
|
|
sizeLabel: {
|
|
fontSize: 13,
|
|
fontWeight: 500,
|
|
color: isDark ? '#d1d5db' : '#374151',
|
|
},
|
|
sizeSelect: {
|
|
padding: '8px 12px',
|
|
fontSize: 13,
|
|
border: isDark ? '1px solid #374151' : '1px solid #e2e5e9',
|
|
borderRadius: 8,
|
|
background: isDark ? '#111318' : '#fff',
|
|
color: isDark ? '#e5e7eb' : '#1a1d21',
|
|
cursor: 'pointer',
|
|
},
|
|
count: {
|
|
fontSize: 13,
|
|
color: isDark ? '#9ca3af' : '#6b7280',
|
|
marginLeft: 'auto',
|
|
},
|
|
countNum: {
|
|
fontWeight: 600,
|
|
color: isDark ? '#d1d5db' : '#374151',
|
|
},
|
|
card: {
|
|
display: 'flex',
|
|
flexDirection: 'column' as const,
|
|
alignItems: 'center',
|
|
padding: '20px 12px 16px',
|
|
background: isDark ? '#1f2328' : '#fff',
|
|
borderRadius: 12,
|
|
boxShadow: isDark ? '0 1px 3px rgba(0,0,0,0.3)' : '0 1px 3px rgba(0,0,0,0.06)',
|
|
border: isDark ? '1px solid #2d3238' : '1px solid rgba(0,0,0,0.04)',
|
|
transition: 'box-shadow 0.2s, transform 0.15s',
|
|
cursor: 'default',
|
|
},
|
|
cardHoverShadow: isDark ? '0 4px 12px rgba(0,0,0,0.4)' : '0 4px 12px rgba(0,0,0,0.08)',
|
|
cardDefaultShadow: isDark ? '0 1px 3px rgba(0,0,0,0.3)' : '0 1px 3px rgba(0,0,0,0.06)',
|
|
iconCell: {
|
|
width: 56,
|
|
height: 56,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginBottom: 12,
|
|
color: isDark ? '#FFFFFF' : '#2A2E37',
|
|
flexShrink: 0,
|
|
},
|
|
iconName: {
|
|
fontSize: 11,
|
|
color: isDark ? '#9ca3af' : '#4b5563',
|
|
textAlign: 'center' as const,
|
|
wordBreak: 'break-word' as const,
|
|
lineHeight: 1.35,
|
|
fontFamily: "'SF Mono', 'Monaco', 'Inconsolata', monospace",
|
|
},
|
|
emptyState: {
|
|
textAlign: 'center' as const,
|
|
padding: '48px 24px',
|
|
background: isDark ? '#1f2328' : '#fff',
|
|
borderRadius: 12,
|
|
boxShadow: isDark ? '0 1px 3px rgba(0,0,0,0.3)' : '0 1px 3px rgba(0,0,0,0.06)',
|
|
},
|
|
emptyTitle: {
|
|
fontSize: 16,
|
|
fontWeight: 500,
|
|
color: isDark ? '#d1d5db' : '#374151',
|
|
margin: '0 0 8px',
|
|
},
|
|
emptyText: {
|
|
fontSize: 14,
|
|
color: isDark ? '#9ca3af' : '#6b7280',
|
|
margin: 0,
|
|
lineHeight: 1.5,
|
|
},
|
|
noIconsTitle: {
|
|
fontSize: 18,
|
|
fontWeight: 600,
|
|
color: isDark ? '#f3f4f6' : '#1a1d21',
|
|
margin: '0 0 12px',
|
|
},
|
|
noIconsText: {
|
|
fontSize: 14,
|
|
color: isDark ? '#9ca3af' : '#5c6370',
|
|
margin: 0,
|
|
lineHeight: 1.6,
|
|
},
|
|
code: {
|
|
padding: '2px 6px',
|
|
background: isDark ? '#374151' : '#e5e7eb',
|
|
borderRadius: 4,
|
|
fontSize: 13,
|
|
fontFamily: "'SF Mono', Monaco, monospace",
|
|
color: isDark ? '#e5e7eb' : '#1a1d21',
|
|
},
|
|
themeToggle: {
|
|
display: 'flex',
|
|
border: isDark ? '1px solid #374151' : '1px solid #e2e5e9',
|
|
borderRadius: 8,
|
|
overflow: 'hidden' as const,
|
|
},
|
|
themeButton: (active: boolean) => ({
|
|
padding: '8px 12px',
|
|
fontSize: 13,
|
|
border: 'none',
|
|
background: active ? (isDark ? '#374151' : '#e5e7eb') : 'transparent',
|
|
color: active ? (isDark ? '#fff' : '#1a1d21') : isDark ? '#9ca3af' : '#6b7280',
|
|
cursor: 'pointer',
|
|
fontWeight: active ? 600 : 400,
|
|
}),
|
|
};
|
|
}
|
|
|
|
const styles = {
|
|
container: { maxWidth: 1200, margin: '0 auto' as const },
|
|
header: { marginBottom: 28 },
|
|
grid: {
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
|
|
gap: 20,
|
|
},
|
|
noIconsBox: {
|
|
padding: 32,
|
|
maxWidth: 520,
|
|
margin: '0 auto' as const,
|
|
textAlign: 'center' as const,
|
|
},
|
|
};
|
|
|
|
export const Gallery: Story = {
|
|
parameters: {
|
|
chromatic: { disableSnapshot: true },
|
|
},
|
|
render: function IconsGallery() {
|
|
const [search, setSearch] = useState('');
|
|
const [sizePreset, setSizePreset] = useState<GallerySizePreset>('lg');
|
|
const [customPxInput, setCustomPxInput] = useState('24');
|
|
const [searchFocused, setSearchFocused] = useState(false);
|
|
const [theme, setTheme] = useState<Theme>('light');
|
|
const [copiedName, setCopiedName] = useState<string | null>(null);
|
|
|
|
const themeStyles = useMemo(() => getThemeStyles(theme), [theme]);
|
|
const customPx = Math.min(128, Math.max(8, Number(customPxInput) || 16));
|
|
const iconColor = theme === 'dark' ? '#FFFFFF' : '#2A2E37';
|
|
|
|
const copySnippet = (componentName: string) => {
|
|
const sizeProp = sizePreset === 'custom' ? `size={${customPx}}` : `size="${sizePreset}"`;
|
|
const snippet = `<${componentName} ${sizeProp} color="${iconColor}" />`;
|
|
void navigator.clipboard.writeText(snippet).then(() => {
|
|
setCopiedName(componentName);
|
|
setTimeout(() => setCopiedName(null), 1500);
|
|
});
|
|
};
|
|
const iconSize: string | number = sizePreset === 'custom' ? customPx : sizePreset;
|
|
|
|
const iconEntries = useMemo(() => getIconEntries(), []);
|
|
|
|
const filtered = useMemo(() => {
|
|
if (!search.trim()) return iconEntries;
|
|
const q = search.toLowerCase().trim();
|
|
return iconEntries.filter(
|
|
([name]) => name.toLowerCase().includes(q) || normalizeIconName(name).includes(q),
|
|
);
|
|
}, [iconEntries, search]);
|
|
|
|
const cellSize = 56;
|
|
|
|
if (iconEntries.length === 0) {
|
|
return (
|
|
<div style={themeStyles.page}>
|
|
<div style={styles.container}>
|
|
<div style={styles.noIconsBox}>
|
|
<h1 style={themeStyles.noIconsTitle}>No icons generated yet</h1>
|
|
<p style={themeStyles.noIconsText}>
|
|
Run <code style={themeStyles.code}>pnpm run svgr</code> (or{' '}
|
|
<code style={themeStyles.code}>pnpm run storybook</code>) to generate icon components from{' '}
|
|
<code style={themeStyles.code}>assets/*.svg</code> into{' '}
|
|
<code style={themeStyles.code}>src/</code>.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={themeStyles.page}>
|
|
<style>{`
|
|
.gallery-icon-cell svg path,
|
|
.gallery-icon-cell svg circle,
|
|
.gallery-icon-cell svg line {
|
|
stroke: currentColor !important;
|
|
}
|
|
.gallery-icon-cell svg path[fill],
|
|
.gallery-icon-cell svg circle[fill] {
|
|
fill: currentColor !important;
|
|
}
|
|
`}</style>
|
|
<div style={styles.container}>
|
|
<header style={styles.header}>
|
|
<h1 style={themeStyles.title}>Icon gallery</h1>
|
|
<p style={themeStyles.subtitle}>
|
|
Browse and search all {iconEntries.length} icons. Filter by name and adjust size below.
|
|
</p>
|
|
</header>
|
|
|
|
<div style={themeStyles.toolbar}>
|
|
<input
|
|
type="search"
|
|
placeholder="Search by name (e.g. arrow, user, star)…"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
onFocus={() => setSearchFocused(true)}
|
|
onBlur={() => setSearchFocused(false)}
|
|
aria-label="Search icons"
|
|
style={{
|
|
...themeStyles.search,
|
|
...(searchFocused ? themeStyles.searchFocus : {}),
|
|
}}
|
|
/>
|
|
<fieldset
|
|
style={{ ...themeStyles.themeToggle, border: 'none', margin: 0, padding: 0 }}
|
|
aria-label="Theme"
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => setTheme('light')}
|
|
style={themeStyles.themeButton(theme === 'light')}
|
|
>
|
|
Light
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setTheme('dark')}
|
|
style={themeStyles.themeButton(theme === 'dark')}
|
|
>
|
|
Dark
|
|
</button>
|
|
</fieldset>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<span style={themeStyles.sizeLabel}>Size</span>
|
|
<select
|
|
value={sizePreset}
|
|
onChange={(e) => setSizePreset(e.target.value as GallerySizePreset)}
|
|
aria-label="Icon size"
|
|
style={themeStyles.sizeSelect}
|
|
>
|
|
<option value="xs">XS (10px)</option>
|
|
<option value="sm">SM (12px)</option>
|
|
<option value="md">MD (14px)</option>
|
|
<option value="lg">LG (16px)</option>
|
|
<option value="xl">XL (18px)</option>
|
|
<option value="2xl">2XL (20px)</option>
|
|
<option value="3xl">3XL (24px)</option>
|
|
<option value="4xl">4XL (28px)</option>
|
|
<option value="5xl">5XL (32px)</option>
|
|
<option value="custom">Custom</option>
|
|
</select>
|
|
{sizePreset === 'custom' && (
|
|
<input
|
|
type="number"
|
|
min={8}
|
|
max={128}
|
|
value={customPxInput}
|
|
onChange={(e) => setCustomPxInput(e.target.value)}
|
|
onBlur={() => {
|
|
const n = Number(customPxInput);
|
|
if (!Number.isFinite(n) || n < 8 || n > 128) {
|
|
setCustomPxInput(String(Math.min(128, Math.max(8, n || 16))));
|
|
}
|
|
}}
|
|
aria-label="Custom size (px)"
|
|
style={{
|
|
...themeStyles.sizeSelect,
|
|
width: 72,
|
|
}}
|
|
/>
|
|
)}
|
|
{sizePreset === 'custom' && (
|
|
<span style={{ fontSize: 13, color: theme === 'dark' ? '#9ca3af' : '#6b7280' }}>px</span>
|
|
)}
|
|
</label>
|
|
<span style={themeStyles.count}>
|
|
<span style={themeStyles.countNum}>{filtered.length}</span>
|
|
{' / '}
|
|
{iconEntries.length} icons
|
|
</span>
|
|
</div>
|
|
|
|
{filtered.length === 0 ? (
|
|
<div style={themeStyles.emptyState}>
|
|
<p style={themeStyles.emptyTitle}>No matches</p>
|
|
<p style={themeStyles.emptyText}>
|
|
No icons match "{search}". Try a different term.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div style={styles.grid}>
|
|
{filtered.map(([name, Icon]) => (
|
|
<button
|
|
key={name}
|
|
type="button"
|
|
onClick={() => copySnippet(name)}
|
|
style={{
|
|
...themeStyles.card,
|
|
cursor: 'pointer',
|
|
font: 'inherit',
|
|
textAlign: 'left',
|
|
}}
|
|
aria-label={`Copy ${name} component`}
|
|
title="Click to copy component"
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.boxShadow = themeStyles.cardHoverShadow;
|
|
e.currentTarget.style.transform = 'translateY(-1px)';
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.boxShadow = themeStyles.cardDefaultShadow;
|
|
e.currentTarget.style.transform = 'none';
|
|
}}
|
|
>
|
|
<div
|
|
className="gallery-icon-cell"
|
|
style={{
|
|
...themeStyles.iconCell,
|
|
width: cellSize,
|
|
height: cellSize,
|
|
color: iconColor,
|
|
}}
|
|
>
|
|
<Icon size={iconSize} color={iconColor} />
|
|
</div>
|
|
<span style={themeStyles.iconName}>{copiedName === name ? 'Copied!' : name}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
};
|