Skip to content

Commit e458c67

Browse files
mikayelgevorgyan-aiGaroGabrielyanAbrahamyanKaren
authored
feat(Icon): add new Icons catalog story with search (#322)
* feat(Icon Category): - Add new IconsCatalog story with advanced search functionality - Add copy-to-clipboard feature for JSX components * feat(IconCatalog): improve icons catalog with enhanced search and UI - Refactor interface names for better clarity (IIconWithMetadata, IIconCardProps) * feat(IconCatalog): use rem instead of px * fix(Icons): code refactor * fix(Icon): alignment and font-sizies * feat(IconCatalog): show Divider in keywords * fix(Icons): catalog search background change --------- Co-authored-by: Galust <[email protected]> Co-authored-by: abrahamyan.karen <[email protected]>
1 parent 54256cd commit e458c67

File tree

2 files changed

+367
-0
lines changed

2 files changed

+367
-0
lines changed
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
@use "../../../assets/styles/mixins";
2+
3+
.iconCatalog {
4+
&__title {
5+
color: var(--guit-ref-color-magenta-400);
6+
margin-bottom: var(--guit-ref-spacing-medium);
7+
}
8+
9+
&__search {
10+
padding-block: var(--guit-ref-spacing-large);
11+
position: sticky;
12+
top: 0;
13+
background-color: var(--guit-sem-color-border-neutral-1);
14+
z-index: 1;
15+
}
16+
17+
&__grid {
18+
display: grid;
19+
grid-template-columns: repeat(auto-fill, minmax(26rem, 1fr));
20+
gap: var(--guit-ref-spacing-medium);
21+
margin-bottom: var(--guit-ref-spacing-large);
22+
}
23+
24+
&__empty {
25+
text-align: center;
26+
padding: var(--guit-ref-spacing-medium) 0;
27+
}
28+
29+
&_highlight {
30+
background-color: var(--guit-sem-color-background-warning-1);
31+
color: var(--guit-sem-color-foreground-warning-1);
32+
border-radius: var(--guit-ref-border-radius-2xsmall);
33+
white-space: pre-wrap;
34+
}
35+
}
36+
37+
.iconCard {
38+
$this: &;
39+
40+
border: var(--guit-sem-border-default-width) var(--guit-sem-border-default-style);
41+
border-color: var(--guit-sem-color-border-neutral-2);
42+
border-radius: var(--guit-ref-radius-2xsmall);
43+
padding: var(--guit-ref-spacing-small);
44+
text-align: center;
45+
display: flex;
46+
flex-direction: column;
47+
align-items: center;
48+
justify-content: flex-start;
49+
gap: var(--guit-ref-spacing-xsmall);
50+
position: relative;
51+
52+
&__icon {
53+
display: flex;
54+
align-items: center;
55+
justify-content: center;
56+
padding-block: var(--guit-ref-spacing-2xsmall);
57+
}
58+
59+
&__name {
60+
margin-bottom: var(--guit-ref-spacing-xsmall);
61+
}
62+
63+
&__keywords {
64+
display: flex;
65+
flex-wrap: wrap;
66+
width: 100%;
67+
justify-content: center;
68+
}
69+
70+
&__keyword {
71+
display: inline-flex;
72+
align-items: center;
73+
padding-block: var(--guit-ref-spacing-4xsmall);
74+
min-width: var(--guit-sem-dimension-width-medium);
75+
border-radius: var(--guit-ref-radius-4xsmall);
76+
font-size: var(--guit-ref-font-size-2xsmall);
77+
font-weight: var(--guit-ref-font-weight-medium);
78+
color: var(--guit-sem-color-foreground-informative);
79+
white-space: pre-wrap;
80+
81+
&_divider {
82+
margin-inline: var(--guit-ref-spacing-small);
83+
height: var(--guit-ref-sizing-3xsmall);
84+
}
85+
}
86+
87+
&__copy {
88+
position: absolute;
89+
top: var(--guit-ref-spacing-xsmall);
90+
right: var(--guit-ref-spacing-xsmall);
91+
opacity: 0;
92+
}
93+
94+
@include mixins.media-hover {
95+
&:hover {
96+
#{ $this }__copy {
97+
opacity: 1;
98+
}
99+
}
100+
}
101+
}
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import React, { ChangeEvent, ComponentType, FC, ReactNode, useMemo, useState } from "react";
2+
import { Meta, StoryObj } from "@storybook/react";
3+
import classNames from "classnames";
4+
5+
// Icons
6+
import * as Icons from "@geneui/icons";
7+
import { Magnifier } from "@geneui/icons";
8+
import type { Icon } from "@geneui/icons/metadata";
9+
import iconsMetadata from "@geneui/icons/metadata";
10+
11+
// Components
12+
import Copy from "@components/atoms/Copy";
13+
import Divider from "@components/atoms/Divider";
14+
import Text, { ITextProps } from "@components/atoms/Text";
15+
import TextField from "@components/molecules/TextField";
16+
17+
// Styles
18+
import "./Icon.scss";
19+
20+
interface IIconWithMetadata {
21+
name: string;
22+
IconComponent: ComponentType<{ size?: number }>;
23+
metadata: Icon;
24+
}
25+
26+
interface IIconCardProps extends IIconWithMetadata {
27+
searchTerm: string;
28+
}
29+
30+
interface IFindMatches {
31+
textSegment: string;
32+
isMatch: boolean;
33+
index: number;
34+
}
35+
36+
const getMatchPriority = (icon: IIconWithMetadata, searchTerm: string) => {
37+
const iconNameLower = icon.name.toLowerCase();
38+
const searchLower = searchTerm.toLowerCase();
39+
40+
if (iconNameLower === searchLower) {
41+
return 4; // Exact match
42+
}
43+
if (iconNameLower.startsWith(searchLower)) {
44+
return 3; // Start with match
45+
}
46+
if (iconNameLower.includes(searchLower)) {
47+
return 2; // Partial name match
48+
}
49+
if (icon.metadata.keywords?.some((keyword) => keyword.toLowerCase().includes(searchLower))) {
50+
return 1; // Keyword match
51+
}
52+
return 0; // No match
53+
};
54+
55+
const findMatches = (text: string, searchTerm: string): IFindMatches[] => {
56+
if (!searchTerm.trim()) {
57+
return [{ textSegment: text, isMatch: false, index: 0 }];
58+
}
59+
60+
const searchLower = searchTerm.toLowerCase();
61+
const textLower = text.toLowerCase();
62+
63+
const index = textLower.indexOf(searchLower);
64+
65+
if (index === -1) {
66+
return [{ textSegment: text, isMatch: false, index: 0 }];
67+
}
68+
const matchEnd = index + searchTerm.length;
69+
70+
if (index === 0) {
71+
const result = [{ textSegment: text.substring(0, searchTerm.length), isMatch: true, index: 0 }];
72+
73+
if (matchEnd < text.length) {
74+
result.push({ textSegment: text.substring(matchEnd), isMatch: false, index: 1 });
75+
}
76+
77+
return result;
78+
}
79+
80+
const result = [
81+
{ textSegment: text.substring(0, index), isMatch: false, index: 0 },
82+
{ textSegment: text.substring(index, matchEnd), isMatch: true, index: 1 }
83+
];
84+
85+
if (matchEnd < text.length) {
86+
result.push({ textSegment: text.substring(matchEnd), isMatch: false, index: 2 });
87+
}
88+
89+
return result;
90+
};
91+
92+
const highlightName = (
93+
text: string,
94+
searchTerm: string,
95+
variant: ITextProps["variant"] = "labelMediumMedium"
96+
): ReactNode => {
97+
const matches = findMatches(text, searchTerm);
98+
99+
return matches.map(({ textSegment, isMatch, index }) => (
100+
<Text key={index} variant={variant} as="span" className={classNames({ iconCatalog_highlight: isMatch })}>
101+
{textSegment}
102+
</Text>
103+
));
104+
};
105+
106+
// Function to highlight only keywords
107+
const highlightKeywords = (keywords: string[], searchTerm: string): ReactNode => {
108+
if (!searchTerm.trim()) {
109+
return keywords.map((keyword, index) => (
110+
<div key={keyword} className="iconCard__keyword">
111+
<Text variant="labelSmallSemibold" as="span">
112+
{keyword}
113+
</Text>
114+
{keywords.length - 1 > index && <Divider direction="vertical" className="iconCard__keyword_divider" />}
115+
</div>
116+
));
117+
}
118+
119+
return keywords.map((keyword, index) => {
120+
const highlightedText = highlightName(keyword, searchTerm, "labelSmallSemibold");
121+
122+
return (
123+
<div key={keyword} className="iconCard__keyword">
124+
{highlightedText}
125+
{keywords.length - 1 > index && <Divider direction="vertical" className="iconCard__keyword_divider" />}
126+
</div>
127+
);
128+
});
129+
};
130+
131+
const IconCard: FC<IIconCardProps> = ({ name, IconComponent, metadata, searchTerm = "" }) => {
132+
const copyValue = `<${name} />`;
133+
134+
return (
135+
<div className="iconCard">
136+
<div className="iconCard__icon">
137+
<IconComponent size={32} />
138+
</div>
139+
<div className="iconCard__name">{highlightName(name, searchTerm)}</div>
140+
<Divider />
141+
{metadata.keywords && metadata.keywords.length > 0 && (
142+
<div className="iconCard__keywords">{highlightKeywords(metadata?.keywords || [], searchTerm)}</div>
143+
)}
144+
<div className="iconCard__copy">
145+
<Copy value={copyValue} copyTooltipText={`Copy ${copyValue}`} copiedTooltipText="Copied!" />
146+
</div>
147+
</div>
148+
);
149+
};
150+
151+
const IconsCatalogComponent: FC = () => {
152+
const [searchTerm, setSearchTerm] = useState("");
153+
154+
// Map icons metadata to actual icon components
155+
const iconsWithMetadata = useMemo((): IIconWithMetadata[] => {
156+
return Object.entries(iconsMetadata || {})
157+
.map(([iconName, metadata]) => {
158+
const IconComponent = (Icons as Record<string, ComponentType<{ size?: number }>>)[iconName];
159+
return {
160+
name: iconName,
161+
IconComponent,
162+
metadata
163+
};
164+
})
165+
.filter((iconWithComponent): iconWithComponent is IIconWithMetadata => !!iconWithComponent.IconComponent); // Only include icons that exist
166+
}, [iconsMetadata]);
167+
168+
// Filter icons based on search term - exact name match first, then start with match, then partial name, then keywords
169+
const filteredIcons = useMemo((): IIconWithMetadata[] => {
170+
if (!searchTerm.trim()) return iconsWithMetadata;
171+
172+
return iconsWithMetadata
173+
.map((icon) => ({ icon, priority: getMatchPriority(icon, searchTerm.toLowerCase()) }))
174+
.filter((iconWithPriority) => iconWithPriority.priority > 0)
175+
.sort((firstIcon, secondIcon) => secondIcon.priority - firstIcon.priority)
176+
.map((prioritizedIcon) => prioritizedIcon.icon);
177+
}, [iconsWithMetadata, searchTerm]);
178+
179+
return (
180+
<div className="iconCatalog">
181+
<Text as="h1" variant="headingMediumSemibold" className="iconCatalog__title">
182+
@geneui/icons
183+
</Text>
184+
185+
<div className="iconCatalog__search">
186+
<TextField
187+
placeholder="Search icons by name or keywords..."
188+
value={searchTerm}
189+
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearchTerm(e.target.value)}
190+
IconBefore={Magnifier}
191+
clearable
192+
onClear={() => setSearchTerm("")}
193+
helperText={`${filteredIcons.length.toString()} / ${iconsWithMetadata.length.toString()}`}
194+
/>
195+
</div>
196+
197+
<div className="iconCatalog__grid">
198+
{filteredIcons.map((iconItem) => (
199+
<IconCard key={iconItem.metadata.id} {...iconItem} searchTerm={searchTerm} />
200+
))}
201+
</div>
202+
203+
{filteredIcons.length === 0 && searchTerm && (
204+
<div className="iconCatalog__empty">
205+
<Text as="p" variant="bodyMediumSemibold">
206+
No icons found for &#34;{searchTerm}&#34;
207+
</Text>
208+
<Text as="p" variant="bodyMediumRegular">
209+
Try searching with different keywords or browse all icons
210+
</Text>
211+
</div>
212+
)}
213+
</div>
214+
);
215+
};
216+
217+
const meta: Meta = {
218+
title: "Atoms/Icon",
219+
component: IconsCatalogComponent,
220+
argTypes: {},
221+
args: {},
222+
parameters: {
223+
layout: "fullscreen",
224+
docs: {
225+
description: {
226+
component: `
227+
# Icons Catalog
228+
229+
A comprehensive catalog of all available icons from @geneui/icons with advanced search functionality and copy-to-clipboard features.
230+
231+
## Features
232+
233+
- **Search by Name**: Find icons by exact or partial name matches
234+
- **Search by Keywords**: Discover icons using their associated keywords
235+
- **Priority Sorting**: Results are sorted by relevance (exact match → partial match → keyword match)
236+
- **Copy to Clipboard**: Hover over any icon to reveal a copy button that copies the JSX component
237+
- **Responsive Grid**: Icons are displayed in a responsive grid layout
238+
- **Real-time Search**: Instant filtering as you type
239+
240+
## Usage
241+
242+
The Icons Catalog automatically loads all available icons from the @geneui/icons package and provides an intuitive interface for browsing and copying them.
243+
244+
### Copying Icons
245+
246+
1. Hover over any icon card
247+
2. Click the copy button that appears
248+
3. The JSX component (e.g., \`<ArrowUp />\`) will be copied to your clipboard
249+
4. Paste directly into your React code
250+
251+
### Search Tips
252+
253+
- **Exact Match**: Type the exact icon name for instant results
254+
- **Partial Match**: Type part of the icon name to find related icons
255+
- **Keywords**: Use descriptive terms like "arrow", "user", "settings" to find icons by their keywords
256+
`
257+
}
258+
}
259+
}
260+
};
261+
262+
export default meta;
263+
264+
type Story = StoryObj<typeof IconsCatalogComponent>;
265+
266+
export const IconsCatalog: Story = {};

0 commit comments

Comments
 (0)