Skip to content

Commit 8c89af0

Browse files
committed
add pills
1 parent f9e6035 commit 8c89af0

File tree

4 files changed

+256
-0
lines changed

4 files changed

+256
-0
lines changed
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import React from "react";
2+
import type { Meta, StoryObj } from "@storybook/react";
3+
import { Pill } from "./Pill";
4+
5+
const meta = {
6+
title: "Components/Pill",
7+
component: Pill,
8+
parameters: {
9+
layout: "centered",
10+
},
11+
tags: ["autodocs"],
12+
argTypes: {
13+
state: {
14+
control: "select",
15+
options: ["default", "hover", "selected"],
16+
description: "Visual state of the pill",
17+
},
18+
children: {
19+
control: "text",
20+
description: "Pill label text",
21+
},
22+
onClick: {
23+
action: "clicked",
24+
description: "Callback function when pill is clicked",
25+
},
26+
},
27+
} satisfies Meta<typeof Pill>;
28+
29+
export default meta;
30+
type Story = StoryObj<typeof meta>;
31+
32+
export const Default: Story = {
33+
args: {
34+
state: "default",
35+
children: "Learn",
36+
},
37+
};
38+
39+
export const Hover: Story = {
40+
args: {
41+
state: "hover",
42+
children: "Learn",
43+
},
44+
};
45+
46+
export const Selected: Story = {
47+
args: {
48+
state: "selected",
49+
children: "Learn",
50+
},
51+
};
52+
53+
export const AllStates: Story = {
54+
render: () => (
55+
<div className="flex flex-col gap-4 p-8">
56+
<div className="flex flex-col gap-2">
57+
<h3 className="text-sm font-semibold">Pill : default</h3>
58+
<Pill state="default">Learn</Pill>
59+
</div>
60+
<div className="flex flex-col gap-2">
61+
<h3 className="text-sm font-semibold">Pill : hover</h3>
62+
<Pill state="hover">Learn</Pill>
63+
</div>
64+
<div className="flex flex-col gap-2">
65+
<h3 className="text-sm font-semibold">Pill : Selected</h3>
66+
<Pill state="selected">Learn</Pill>
67+
</div>
68+
</div>
69+
),
70+
parameters: {
71+
docs: {
72+
description: {
73+
story:
74+
"All pill states: default (light background), hover (medium background), and selected (dark background with white text).",
75+
},
76+
},
77+
},
78+
};
79+
80+
export const AllStatesInline: Story = {
81+
render: () => (
82+
<div className="flex gap-4 items-center p-8">
83+
<Pill state="default">Learn</Pill>
84+
<Pill state="hover">Learn</Pill>
85+
<Pill state="selected">Learn</Pill>
86+
</div>
87+
),
88+
parameters: {
89+
docs: {
90+
description: {
91+
story: "All pill states displayed in a row for comparison.",
92+
},
93+
},
94+
},
95+
};
96+
97+
export const InteractiveDemo: Story = {
98+
render: () => (
99+
<div className="flex flex-col gap-4">
100+
<p className="text-sm text-gray-600">Click to toggle the state:</p>
101+
<Pill onClick={() => console.log("Pill clicked!")}>Clickable Pill</Pill>
102+
</div>
103+
),
104+
parameters: {
105+
docs: {
106+
description: {
107+
story:
108+
"Interactive pill that changes state when clicked. Try hovering and clicking!",
109+
},
110+
},
111+
},
112+
};
113+
114+
export const MultiplePills: Story = {
115+
render: () => (
116+
<div className="flex gap-2 flex-wrap">
117+
<Pill>Learn</Pill>
118+
<Pill>Discover</Pill>
119+
<Pill>Explore</Pill>
120+
<Pill>Build</Pill>
121+
<Pill>Create</Pill>
122+
</div>
123+
),
124+
parameters: {
125+
docs: {
126+
description: {
127+
story: "Multiple pills with different text labels.",
128+
},
129+
},
130+
},
131+
};
132+
133+
export const WithCustomText: Story = {
134+
args: {
135+
children: "Custom Text",
136+
},
137+
};
138+

src/components/pill/Pill.tsx

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import React, { useState } from "react";
2+
import PropTypes from "prop-types";
3+
import { cn } from "../../utils/cn";
4+
5+
export type PillState = "default" | "hover" | "selected";
6+
7+
export interface PillProps {
8+
state?: PillState;
9+
onClick?: () => void;
10+
className?: string;
11+
children?: React.ReactNode;
12+
selected?: boolean;
13+
onSelectChange?: (selected: boolean) => void;
14+
}
15+
16+
// Figma design colors
17+
const pillColors = {
18+
default: {
19+
backgroundColor: "transparent",
20+
textColor: "rgba(32, 30, 30, 0.6)", // #201E1E at 60% opacity
21+
borderColor: "rgba(32, 30, 30, 0.6)", // #201E1E at 60% opacity
22+
},
23+
hover: {
24+
backgroundColor: "rgba(169, 164, 155, 0.3)", // #A9A49B at 30% opacity
25+
textColor: "rgba(32, 30, 30, 0.6)", // #201E1E at 60% opacity
26+
borderColor: "rgba(32, 30, 30, 0.6)", // #201E1E at 60% opacity
27+
},
28+
selected: {
29+
backgroundColor: "#201E1E", // Solid dark color
30+
textColor: "#F6F0E6", // Light beige text
31+
borderColor: "#201E1E", // Solid dark border
32+
},
33+
} as const;
34+
35+
export const Pill: React.FC<PillProps> = ({
36+
state,
37+
onClick,
38+
className,
39+
children,
40+
selected: controlledSelected,
41+
onSelectChange,
42+
}) => {
43+
const [internalSelected, setInternalSelected] = useState(false);
44+
45+
const isSelected =
46+
controlledSelected !== undefined ? controlledSelected : internalSelected;
47+
48+
const effectiveState = state ?? (isSelected ? "selected" : "default");
49+
const isForcedState =
50+
effectiveState === "selected" || effectiveState === "hover";
51+
52+
const handleClick = () => {
53+
if (state === undefined) {
54+
const newSelected = !isSelected;
55+
setInternalSelected(newSelected);
56+
onSelectChange?.(newSelected);
57+
}
58+
onClick?.();
59+
};
60+
61+
const colors = pillColors[effectiveState];
62+
63+
return (
64+
<div
65+
onClick={handleClick}
66+
className={cn(
67+
"inline-flex items-center justify-center px-2 py-1 transition-all duration-200 cursor-pointer border text-sm font-medium",
68+
className,
69+
)}
70+
style={{
71+
backgroundColor: colors.backgroundColor,
72+
borderColor: colors.borderColor,
73+
color: colors.textColor,
74+
borderWidth: "1px",
75+
borderRadius: "12px",
76+
}}
77+
data-state={effectiveState}
78+
onMouseEnter={(e) => {
79+
if (!isForcedState && !isSelected) {
80+
const target = e.currentTarget;
81+
target.style.backgroundColor = pillColors.hover.backgroundColor;
82+
target.style.borderColor = pillColors.hover.borderColor;
83+
}
84+
}}
85+
onMouseLeave={(e) => {
86+
if (!isForcedState && !isSelected) {
87+
const target = e.currentTarget;
88+
target.style.backgroundColor = colors.backgroundColor;
89+
target.style.borderColor = colors.borderColor;
90+
}
91+
}}
92+
role="button"
93+
tabIndex={0}
94+
onKeyDown={(e) => {
95+
if (e.key === "Enter" || e.key === " ") {
96+
e.preventDefault();
97+
handleClick();
98+
}
99+
}}
100+
>
101+
<span>{children}</span>
102+
</div>
103+
);
104+
};
105+
106+
Pill.propTypes = {
107+
state: PropTypes.oneOf(["default", "hover", "selected"]),
108+
onClick: PropTypes.func,
109+
className: PropTypes.string,
110+
children: PropTypes.node,
111+
selected: PropTypes.bool,
112+
onSelectChange: PropTypes.func,
113+
};
114+

src/components/pill/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { Pill } from "./Pill";
2+
export type { PillProps, PillState } from "./Pill";
3+

src/index.ts

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

0 commit comments

Comments
 (0)