Skip to content

Commit 229f35a

Browse files
committed
feat: add Select, Textarea, Switch, and Separator components
- Add Select component with dropdown styling and accessibility - Add Textarea component matching Input patterns - Add Switch toggle component with smooth animations - Add Separator component for horizontal/vertical dividers - All components follow consistent design tokens and patterns - Include Storybook stories for each new component
1 parent 7df2ede commit 229f35a

File tree

9 files changed

+651
-0
lines changed

9 files changed

+651
-0
lines changed

src/primitives/Select.stories.tsx

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import Select from "./Select";
3+
4+
const meta: Meta<typeof Select> = {
5+
title: "Primitives/Select",
6+
component: Select,
7+
tags: ["autodocs"],
8+
argTypes: {
9+
label: {
10+
control: "text",
11+
},
12+
error: {
13+
control: "text",
14+
},
15+
disabled: {
16+
control: "boolean",
17+
},
18+
},
19+
};
20+
21+
export default meta;
22+
type Story = StoryObj<typeof Select>;
23+
24+
export const Default: Story = {
25+
args: {
26+
label: "Choose an option",
27+
options: [
28+
{ value: "", label: "Select..." },
29+
{ value: "option1", label: "Option 1" },
30+
{ value: "option2", label: "Option 2" },
31+
{ value: "option3", label: "Option 3" },
32+
],
33+
},
34+
};
35+
36+
export const WithValue: Story = {
37+
args: {
38+
label: "Selected option",
39+
defaultValue: "option2",
40+
options: [
41+
{ value: "option1", label: "Option 1" },
42+
{ value: "option2", label: "Option 2" },
43+
{ value: "option3", label: "Option 3" },
44+
],
45+
},
46+
};
47+
48+
export const WithError: Story = {
49+
args: {
50+
label: "Required field",
51+
error: "This field is required",
52+
options: [
53+
{ value: "", label: "Select..." },
54+
{ value: "option1", label: "Option 1" },
55+
{ value: "option2", label: "Option 2" },
56+
],
57+
},
58+
};
59+
60+
export const Disabled: Story = {
61+
args: {
62+
label: "Disabled select",
63+
disabled: true,
64+
options: [
65+
{ value: "option1", label: "Option 1" },
66+
{ value: "option2", label: "Option 2" },
67+
],
68+
},
69+
};
70+
71+
export const WithoutLabel: Story = {
72+
args: {
73+
options: [
74+
{ value: "option1", label: "Option 1" },
75+
{ value: "option2", label: "Option 2" },
76+
{ value: "option3", label: "Option 3" },
77+
],
78+
},
79+
};

src/primitives/Select.tsx

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { type SelectHTMLAttributes, forwardRef } from "react";
2+
import {
3+
spacingTokens,
4+
borderTokens,
5+
typographyTokens,
6+
} from "../styles/tokens";
7+
8+
type SelectProps = SelectHTMLAttributes<HTMLSelectElement> & {
9+
label?: string;
10+
error?: string;
11+
options: Array<{ value: string; label: string }>;
12+
};
13+
14+
const Select = forwardRef<HTMLSelectElement, SelectProps>(
15+
({ label, error, options, className = "", ...props }, ref) => {
16+
return (
17+
<div
18+
style={{
19+
display: "flex",
20+
flexDirection: "column",
21+
gap: spacingTokens[2],
22+
}}
23+
>
24+
{label && (
25+
<label
26+
htmlFor={props.id}
27+
style={{
28+
fontSize: typographyTokens.fontSize.sm,
29+
fontWeight: typographyTokens.fontWeight.medium,
30+
color: "var(--color-foreground-primary)",
31+
fontFamily: typographyTokens.fontFamily.sans,
32+
}}
33+
>
34+
{label}
35+
</label>
36+
)}
37+
<div style={{ position: "relative", display: "inline-block" }}>
38+
<select
39+
ref={ref}
40+
id={props.id}
41+
className={className}
42+
style={{
43+
width: "100%",
44+
padding: spacingTokens[3],
45+
paddingRight: spacingTokens[12],
46+
fontSize: typographyTokens.fontSize.base,
47+
fontFamily: typographyTokens.fontFamily.sans,
48+
color: "var(--color-foreground-primary)",
49+
backgroundColor: "var(--color-background-primary)",
50+
border: `${borderTokens.width.base} solid ${
51+
error
52+
? "var(--color-status-error)"
53+
: "var(--color-border-default)"
54+
}`,
55+
borderRadius: borderTokens.radius.md,
56+
outline: "none",
57+
cursor: "pointer",
58+
appearance: "none",
59+
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23718096' d='M6 9L1 4h10z'/%3E%3C/svg%3E")`,
60+
backgroundRepeat: "no-repeat",
61+
backgroundPosition: `right ${spacingTokens[3]} center`,
62+
transition: "border-color 0.2s ease",
63+
}}
64+
onFocus={(e) => {
65+
e.currentTarget.style.borderColor =
66+
"var(--color-interactive-default)";
67+
props.onFocus?.(e);
68+
}}
69+
onBlur={(e) => {
70+
e.currentTarget.style.borderColor = error
71+
? "var(--color-status-error)"
72+
: "var(--color-border-default)";
73+
props.onBlur?.(e);
74+
}}
75+
{...props}
76+
>
77+
{options.map((option) => (
78+
<option key={option.value} value={option.value}>
79+
{option.label}
80+
</option>
81+
))}
82+
</select>
83+
</div>
84+
{error && (
85+
<span
86+
style={{
87+
fontSize: typographyTokens.fontSize.sm,
88+
color: "var(--color-status-error)",
89+
fontFamily: typographyTokens.fontFamily.sans,
90+
}}
91+
>
92+
{error}
93+
</span>
94+
)}
95+
</div>
96+
);
97+
}
98+
);
99+
100+
Select.displayName = "Select";
101+
102+
export default Select;
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import Separator from "./Separator";
3+
import Text from "./Text";
4+
import Stack from "./Stack";
5+
6+
const meta: Meta<typeof Separator> = {
7+
title: "Primitives/Separator",
8+
component: Separator,
9+
tags: ["autodocs"],
10+
argTypes: {
11+
orientation: {
12+
control: "select",
13+
options: ["horizontal", "vertical"],
14+
},
15+
},
16+
};
17+
18+
export default meta;
19+
type Story = StoryObj<typeof Separator>;
20+
21+
export const Horizontal: Story = {
22+
render: () => (
23+
<div style={{ width: "100%", padding: "1rem" }}>
24+
<Text>Content above</Text>
25+
<Separator orientation="horizontal" />
26+
<Text>Content below</Text>
27+
</div>
28+
),
29+
};
30+
31+
export const Vertical: Story = {
32+
render: () => (
33+
<Stack direction="row" gap={4} style={{ height: "100px", padding: "1rem" }}>
34+
<Text>Left content</Text>
35+
<Separator orientation="vertical" />
36+
<Text>Right content</Text>
37+
</Stack>
38+
),
39+
};
40+
41+
export const WithSpacing: Story = {
42+
render: () => (
43+
<div style={{ width: "100%", padding: "1rem" }}>
44+
<Text>First section</Text>
45+
<Separator orientation="horizontal" style={{ margin: "2rem 0" }} />
46+
<Text>Second section</Text>
47+
</div>
48+
),
49+
};
50+
51+
export const InCard: Story = {
52+
render: () => (
53+
<div
54+
style={{
55+
padding: "1.5rem",
56+
border: "1px solid var(--color-border-default)",
57+
borderRadius: "0.5rem",
58+
width: "300px",
59+
}}
60+
>
61+
<Text weight="bold" size="lg">
62+
Card Title
63+
</Text>
64+
<Separator orientation="horizontal" style={{ margin: "1rem 0" }} />
65+
<Text>Card content goes here</Text>
66+
</div>
67+
),
68+
};

src/primitives/Separator.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { type HTMLAttributes } from "react";
2+
import { borderTokens } from "../styles/tokens";
3+
4+
type SeparatorProps = HTMLAttributes<HTMLHRElement> & {
5+
orientation?: "horizontal" | "vertical";
6+
};
7+
8+
export default function Separator({
9+
orientation = "horizontal",
10+
className = "",
11+
style,
12+
...props
13+
}: SeparatorProps) {
14+
const isVertical = orientation === "vertical";
15+
16+
return (
17+
<hr
18+
className={className}
19+
role="separator"
20+
aria-orientation={orientation}
21+
style={{
22+
border: "none",
23+
borderTop: isVertical
24+
? "none"
25+
: `${borderTokens.width.thin} solid var(--color-border-default)`,
26+
borderLeft: !isVertical
27+
? "none"
28+
: `${borderTokens.width.thin} solid var(--color-border-default)`,
29+
width: isVertical ? "1px" : "100%",
30+
height: isVertical ? "100%" : "1px",
31+
margin: 0,
32+
...style,
33+
}}
34+
{...props}
35+
/>
36+
);
37+
}

src/primitives/Switch.stories.tsx

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { useState } from "react";
3+
import Switch from "./Switch";
4+
5+
const meta: Meta<typeof Switch> = {
6+
title: "Primitives/Switch",
7+
component: Switch,
8+
tags: ["autodocs"],
9+
argTypes: {
10+
label: {
11+
control: "text",
12+
},
13+
checked: {
14+
control: "boolean",
15+
},
16+
disabled: {
17+
control: "boolean",
18+
},
19+
},
20+
};
21+
22+
export default meta;
23+
type Story = StoryObj<typeof Switch>;
24+
25+
export const Default: Story = {
26+
args: {
27+
label: "Enable notifications",
28+
},
29+
};
30+
31+
export const Checked: Story = {
32+
args: {
33+
label: "Enable notifications",
34+
checked: true,
35+
},
36+
};
37+
38+
export const Disabled: Story = {
39+
args: {
40+
label: "Disabled switch",
41+
disabled: true,
42+
},
43+
};
44+
45+
export const DisabledChecked: Story = {
46+
args: {
47+
label: "Disabled checked switch",
48+
checked: true,
49+
disabled: true,
50+
},
51+
};
52+
53+
export const WithoutLabel: Story = {
54+
args: {},
55+
};
56+
57+
export const Interactive: Story = {
58+
render: () => {
59+
const [checked, setChecked] = useState(false);
60+
return (
61+
<Switch
62+
label="Toggle me"
63+
checked={checked}
64+
onChange={(e) => setChecked(e.target.checked)}
65+
/>
66+
);
67+
},
68+
};
69+
70+
export const MultipleSwitches: Story = {
71+
render: () => {
72+
const [email, setEmail] = useState(false);
73+
const [sms, setSms] = useState(true);
74+
const [push, setPush] = useState(false);
75+
76+
return (
77+
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
78+
<Switch
79+
label="Email notifications"
80+
checked={email}
81+
onChange={(e) => setEmail(e.target.checked)}
82+
/>
83+
<Switch
84+
label="SMS notifications"
85+
checked={sms}
86+
onChange={(e) => setSms(e.target.checked)}
87+
/>
88+
<Switch
89+
label="Push notifications"
90+
checked={push}
91+
onChange={(e) => setPush(e.target.checked)}
92+
/>
93+
</div>
94+
);
95+
},
96+
};

0 commit comments

Comments
 (0)