Skip to content

Commit 56cc958

Browse files
committed
Add tags
1 parent 371a3ee commit 56cc958

File tree

4 files changed

+375
-0
lines changed

4 files changed

+375
-0
lines changed

src/components/tag/Tag.stories.tsx

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import React from "react";
2+
import type { Meta, StoryObj } from "@storybook/react";
3+
import { Tag } from "./Tag";
4+
5+
const meta = {
6+
title: "Components/Tag",
7+
component: Tag,
8+
parameters: {
9+
layout: "centered",
10+
},
11+
tags: ["autodocs"],
12+
argTypes: {
13+
type: {
14+
control: "select",
15+
options: ["guide", "seminar", "tool", "interactive"],
16+
description: "Type of tag (guide, seminar, tool, interactive)",
17+
},
18+
state: {
19+
control: "select",
20+
options: ["default", "hover", "selected"],
21+
description: "Visual state of the tag",
22+
},
23+
children: {
24+
control: "text",
25+
description: "Tag label text",
26+
},
27+
onClick: {
28+
action: "clicked",
29+
description: "Callback function when tag is clicked",
30+
},
31+
},
32+
} satisfies Meta<typeof Tag>;
33+
34+
export default meta;
35+
type Story = StoryObj<typeof meta>;
36+
37+
export const Guide: Story = {
38+
args: {
39+
type: "guide",
40+
state: "default",
41+
children: "Guide",
42+
},
43+
};
44+
45+
export const Seminar: Story = {
46+
args: {
47+
type: "seminar",
48+
state: "default",
49+
children: "Seminar",
50+
},
51+
};
52+
53+
export const Tool: Story = {
54+
args: {
55+
type: "tool",
56+
state: "default",
57+
children: "Tool",
58+
},
59+
};
60+
61+
export const Interactive: Story = {
62+
args: {
63+
type: "interactive",
64+
state: "default",
65+
children: "Interactive",
66+
},
67+
};
68+
69+
export const AllDefault: Story = {
70+
render: () => (
71+
<div className="flex gap-2 flex-wrap">
72+
<Tag type="guide" state="default">
73+
Guide
74+
</Tag>
75+
<Tag type="seminar" state="default">
76+
Seminar
77+
</Tag>
78+
<Tag type="tool" state="default">
79+
Tool
80+
</Tag>
81+
<Tag type="interactive" state="default">
82+
Interactive
83+
</Tag>
84+
</div>
85+
),
86+
parameters: {
87+
docs: {
88+
description: {
89+
story: "All tag types in their default state with white background and colored border.",
90+
},
91+
},
92+
},
93+
};
94+
95+
export const AllHover: Story = {
96+
render: () => (
97+
<div className="flex gap-2 flex-wrap">
98+
<Tag type="guide" state="hover">
99+
Guide
100+
</Tag>
101+
<Tag type="seminar" state="hover">
102+
Seminar
103+
</Tag>
104+
<Tag type="tool" state="hover">
105+
Tool
106+
</Tag>
107+
<Tag type="interactive" state="hover">
108+
Interactive
109+
</Tag>
110+
</div>
111+
),
112+
parameters: {
113+
docs: {
114+
description: {
115+
story: "All tag types in hover state with light colored background.",
116+
},
117+
},
118+
},
119+
};
120+
121+
export const AllSelected: Story = {
122+
render: () => (
123+
<div className="flex gap-2 flex-wrap">
124+
<Tag type="guide" state="selected">
125+
Guide
126+
</Tag>
127+
<Tag type="seminar" state="selected">
128+
Seminar
129+
</Tag>
130+
<Tag type="tool" state="selected">
131+
Tool
132+
</Tag>
133+
<Tag type="interactive" state="selected">
134+
Interactive
135+
</Tag>
136+
</div>
137+
),
138+
parameters: {
139+
docs: {
140+
description: {
141+
story: "All tag types in selected state with solid colored background and white text.",
142+
},
143+
},
144+
},
145+
};
146+
147+
export const AllStatesComparison: Story = {
148+
render: () => (
149+
<div className="flex flex-col gap-4">
150+
<div className="flex flex-col gap-2">
151+
<h3 className="text-sm font-semibold">Default State</h3>
152+
<div className="flex gap-2 flex-wrap">
153+
<Tag type="guide" state="default">
154+
Guide
155+
</Tag>
156+
<Tag type="seminar" state="default">
157+
Seminar
158+
</Tag>
159+
<Tag type="tool" state="default">
160+
Tool
161+
</Tag>
162+
<Tag type="interactive" state="default">
163+
Interactive
164+
</Tag>
165+
</div>
166+
</div>
167+
<div className="flex flex-col gap-2">
168+
<h3 className="text-sm font-semibold">Hover State</h3>
169+
<div className="flex gap-2 flex-wrap">
170+
<Tag type="guide" state="hover">
171+
Guide
172+
</Tag>
173+
<Tag type="seminar" state="hover">
174+
Seminar
175+
</Tag>
176+
<Tag type="tool" state="hover">
177+
Tool
178+
</Tag>
179+
<Tag type="interactive" state="hover">
180+
Interactive
181+
</Tag>
182+
</div>
183+
</div>
184+
<div className="flex flex-col gap-2">
185+
<h3 className="text-sm font-semibold">Selected State</h3>
186+
<div className="flex gap-2 flex-wrap">
187+
<Tag type="guide" state="selected">
188+
Guide
189+
</Tag>
190+
<Tag type="seminar" state="selected">
191+
Seminar
192+
</Tag>
193+
<Tag type="tool" state="selected">
194+
Tool
195+
</Tag>
196+
<Tag type="interactive" state="selected">
197+
Interactive
198+
</Tag>
199+
</div>
200+
</div>
201+
</div>
202+
),
203+
parameters: {
204+
docs: {
205+
description: {
206+
story: "Side-by-side comparison of all tag types across their three states: default (white background with colored border), hover (light colored background), and selected (solid colored background with white text).",
207+
},
208+
},
209+
},
210+
};
211+
212+
export const InteractiveDemo: Story = {
213+
render: () => (
214+
<div className="flex flex-col gap-4">
215+
<div className="flex flex-col gap-2">
216+
<p className="text-sm text-gray-600">
217+
Click to see the state change:
218+
</p>
219+
<Tag
220+
type="guide"
221+
state="default"
222+
onClick={() => alert("Tag clicked!")}
223+
>
224+
Clickable Tag
225+
</Tag>
226+
</div>
227+
</div>
228+
),
229+
parameters: {
230+
docs: {
231+
description: {
232+
story: "Tag with onClick handler to demonstrate interactivity.",
233+
},
234+
},
235+
},
236+
};

src/components/tag/Tag.tsx

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import React, { useState } from "react";
2+
import PropTypes from "prop-types";
3+
import { cn } from "../../utils/cn";
4+
import BookIcon from "../../icons/BookIcon";
5+
import MicIcon from "../../icons/MicIcon";
6+
import ToolIcon from "../../icons/ToolIcon";
7+
import TapCursorIcon from "../../icons/TapCursorIcon";
8+
9+
export type TagType = "guide" | "seminar" | "tool" | "interactive";
10+
export type TagState = "default" | "hover" | "selected";
11+
12+
export interface TagProps {
13+
type: TagType;
14+
state?: TagState;
15+
onClick?: () => void;
16+
className?: string;
17+
children?: React.ReactNode;
18+
selected?: boolean;
19+
onSelectChange?: (selected: boolean) => void;
20+
}
21+
22+
const tagConfig = {
23+
guide: {
24+
color: "#EC4182", // Pink
25+
lightColor: "#F5D7E2", // Light Pink
26+
icon: BookIcon,
27+
},
28+
seminar: {
29+
color: "#7762B9", // Purple
30+
lightColor: "#CABFEF", // Light Purple
31+
icon: MicIcon,
32+
},
33+
tool: {
34+
color: "#396BEB", // Blue
35+
lightColor: "#D1E2F3", // Light Blue
36+
icon: ToolIcon,
37+
},
38+
interactive: {
39+
color: "#CC7400", // Yellow/Orange
40+
lightColor: "#ECD4B5", // Light Yellow
41+
icon: TapCursorIcon,
42+
},
43+
} as const;
44+
45+
export const Tag: React.FC<TagProps> = ({
46+
type,
47+
state,
48+
onClick,
49+
className,
50+
children,
51+
selected: controlledSelected,
52+
onSelectChange,
53+
}) => {
54+
const config = tagConfig[type];
55+
const Icon = config.icon;
56+
57+
const [internalSelected, setInternalSelected] = useState(false);
58+
59+
const isSelected = controlledSelected !== undefined
60+
? controlledSelected
61+
: internalSelected;
62+
63+
const effectiveState = state ?? (isSelected ? "selected" : "default");
64+
const isForcedState = effectiveState === "selected" || effectiveState === "hover";
65+
66+
const handleClick = () => {
67+
if (state === undefined) {
68+
const newSelected = !isSelected;
69+
setInternalSelected(newSelected);
70+
onSelectChange?.(newSelected);
71+
}
72+
onClick?.();
73+
};
74+
75+
const renderIconColor = effectiveState === "selected" ? "white" : config.color;
76+
const renderBackgroundColor = effectiveState === "selected" ? config.color : (effectiveState === "hover" ? config.lightColor : "white");
77+
const renderBorderColor = config.color;
78+
79+
return (
80+
<div
81+
onClick={handleClick}
82+
className={cn(
83+
"inline-flex items-center gap-1 px-2 py-1 transition-all duration-200 cursor-pointer border text-sm rounded-[6px]",
84+
"hover:brightness-95",
85+
className
86+
)}
87+
data-state={effectiveState}
88+
data-type={type}
89+
style={{
90+
backgroundColor: renderBackgroundColor,
91+
borderColor: renderBorderColor,
92+
borderWidth: "1px",
93+
...(!isForcedState && {
94+
"--hover-bg": config.lightColor,
95+
"--hover-border": config.color,
96+
}),
97+
}}
98+
onMouseEnter={(e) => {
99+
if (!isForcedState && !isSelected) {
100+
const target = e.currentTarget;
101+
target.style.backgroundColor = config.lightColor;
102+
target.style.borderColor = config.color;
103+
}
104+
}}
105+
onMouseLeave={(e) => {
106+
if (!isForcedState && !isSelected) {
107+
const target = e.currentTarget;
108+
target.style.backgroundColor = renderBackgroundColor;
109+
target.style.borderColor = renderBorderColor;
110+
}
111+
}}
112+
role="button"
113+
tabIndex={0}
114+
onKeyDown={(e) => {
115+
if (e.key === "Enter" || e.key === " ") {
116+
e.preventDefault();
117+
handleClick();
118+
}
119+
}}
120+
>
121+
<Icon width={16} style={{ color: renderIconColor }} />
122+
<span style={{ color: renderIconColor }}>{children}</span>
123+
</div>
124+
);
125+
};
126+
127+
Tag.propTypes = {
128+
type: PropTypes.oneOf(["guide", "seminar", "tool", "interactive"]).isRequired,
129+
state: PropTypes.oneOf(["default", "hover", "selected"]),
130+
onClick: PropTypes.func,
131+
className: PropTypes.string,
132+
children: PropTypes.node,
133+
selected: PropTypes.bool,
134+
onSelectChange: PropTypes.func,
135+
};
136+

src/components/tag/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from "./Tag";
2+

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export * from "./components/carousel";
44
export * from "./components/select";
55
export * from "./components/banner";
66
export * from "./components/search";
7+
export * from "./components/tag";

0 commit comments

Comments
 (0)