= {
+ render: (args) => {
+ return (
+
+
+ 낮은 명암비
+
+
+ 높은 명암비
+
+
+ );
+ },
+ argTypes: {
+ name: {
+ control: false,
+ },
+ muted: {
+ control: false,
+ },
+ },
+};
+
+export const WithHeading: StoryObj = {
+ render: (args) => {
+ return (
+
+
+ 프로필
+
+ );
+ },
+ argTypes: {
+ name: {
+ control: false,
+ },
+ },
+};
diff --git a/src/components/Icon/Icon.test.tsx b/src/components/Icon/Icon.test.tsx
new file mode 100644
index 0000000..4e6c210
--- /dev/null
+++ b/src/components/Icon/Icon.test.tsx
@@ -0,0 +1,44 @@
+import { composeStories } from "@storybook/react";
+import { render } from "@testing-library/react";
+import { expect, test } from "vitest";
+import * as stories from "./Icon.stories";
+
+const { Basic } = composeStories(stories);
+
+test("renders an svg element", () => {
+ const { container } = render();
+
+ expect(container.querySelector("svg")).toBeInTheDocument();
+});
+
+test.each([
+ ["xs", "w_1em h_1em"],
+ ["sm", "w_1.25em h_1.25em"],
+ ["md", "w_1.5em h_1.5em"],
+ ["lg", "w_1.875em h_1.875em"],
+ ["xl", "w_2.25em h_2.25em"],
+] as const)('applies the correct class for size="%s"', (size, className) => {
+ const { container } = render();
+
+ expect(container.querySelector("svg")).toHaveClass(className);
+});
+
+test.each([
+ ["neutral", "c_text"],
+ ["accent", "c_text.accent"],
+ ["danger", "c_text.danger"],
+ ["warning", "c_text.warning"],
+] as const)('applies the correct class for tone="%s"', (tone, className) => {
+ const { container } = render();
+
+ expect(container.querySelector("svg")).toHaveClass(className);
+});
+
+test.each([
+ [false, "c_text"],
+ [true, "c_text.muted"],
+] as const)("applies the correct class for muted={%s}", (muted, className) => {
+ const { container } = render();
+
+ expect(container.querySelector("svg")).toHaveClass(className);
+});
diff --git a/src/components/Icon/Icon.tsx b/src/components/Icon/Icon.tsx
new file mode 100644
index 0000000..364a0ba
--- /dev/null
+++ b/src/components/Icon/Icon.tsx
@@ -0,0 +1,116 @@
+import { css, cva } from "../../../styled-system/css";
+import type { Tone } from "../../tokens/colors";
+import { type IconName, icons } from "../../tokens/iconography";
+export interface IconProps {
+ /** 이름 */
+ name: IconName;
+ /** 색조 */
+ tone?: Tone;
+ /** 크기 */
+ size?: "xs" | "sm" | "md" | "lg" | "xl";
+ /** 명암비 낮출지 */
+ muted?: boolean;
+}
+
+/**
+ * - `name` 속성으로 어떤 모양의 아이콘을 사용할지 지정할 수 있습니다.
+ * - 아이콘의 기본 크기는 부모 요소에서 설정한 글자 크기의 1.5배이며, `size` 속성을 통해서 크기를 변경할 수 있습니다.
+ * - 아이콘의 기본 색상은 부모 요소에서 설정한 글자 색상과 동일하며, `tone` 속성과 `muted` 속성을 통해서 색상을 변경할 수 있습니다.
+ */
+export const Icon = ({
+ name,
+ size,
+ tone,
+ muted = false,
+ ...rest
+}: IconProps) => {
+ const Tag = icons[name];
+
+ return (
+
+ );
+};
+
+const sizeStyles = cva({
+ variants: {
+ size: {
+ xs: {
+ width: "1em",
+ height: "1em",
+ },
+ sm: {
+ width: "1.25em",
+ height: "1.25em",
+ },
+ md: {
+ width: "1.5em",
+ height: "1.5em",
+ },
+ lg: {
+ width: "1.875em",
+ height: "1.875em",
+ },
+ xl: {
+ width: "2.25em",
+ height: "2.25em",
+ },
+ },
+ },
+ defaultVariants: {
+ size: "md",
+ },
+});
+
+const colorStyles = cva({
+ compoundVariants: [
+ {
+ muted: false,
+ tone: "neutral",
+ css: { color: "text" },
+ },
+ {
+ muted: false,
+ tone: "accent",
+ css: { color: "text.accent" },
+ },
+ {
+ muted: false,
+ tone: "danger",
+ css: { color: "text.danger" },
+ },
+ {
+ muted: false,
+ tone: "warning",
+ css: { color: "text.warning" },
+ },
+ {
+ muted: true,
+ tone: "neutral",
+ css: { color: "text.muted" },
+ },
+ {
+ muted: true,
+ tone: "accent",
+ css: { color: "text.muted.accent" },
+ },
+ {
+ muted: true,
+ tone: "danger",
+ css: { color: "text.muted.danger" },
+ },
+ {
+ muted: true,
+ tone: "warning",
+ css: { color: "text.muted.warning" },
+ },
+ ],
+});
diff --git a/src/components/Icon/index.tsx b/src/components/Icon/index.tsx
new file mode 100644
index 0000000..8968625
--- /dev/null
+++ b/src/components/Icon/index.tsx
@@ -0,0 +1 @@
+export { Icon as Text } from "./Icon";
diff --git a/src/tokens/iconography.tsx b/src/tokens/iconography.tsx
index 75f5ff8..4dae8c5 100644
--- a/src/tokens/iconography.tsx
+++ b/src/tokens/iconography.tsx
@@ -1,4 +1,9 @@
import {
+ Check,
+ ChevronDown,
+ ChevronLeft,
+ ChevronRight,
+ CircleAlert,
Clock,
Info,
MessageCircle,
@@ -6,14 +11,15 @@ import {
Moon,
Search,
Sun,
+ Star,
User,
+ X,
} from "lucide-react";
import type { FunctionComponent, ComponentProps, SVGProps } from "react";
import Discord from "../assets/Discord.svg?react";
import GitHub from "../assets/GitHub.svg?react";
import LinkedIn from "../assets/LinkedIn.svg?react";
import Medium from "../assets/Medium.svg?react";
-import X from "../assets/X.svg?react";
import YouTube from "../assets/YouTube.svg?react";
function createBrandIcon(Icon: FunctionComponent>) {
@@ -23,6 +29,11 @@ function createBrandIcon(Icon: FunctionComponent>) {
}
export const icons = {
+ check: Check,
+ chevronDown: ChevronDown,
+ chevronLeft: ChevronLeft,
+ chevronRight: ChevronRight,
+ circleAlert: CircleAlert,
clock: Clock,
info: Info,
chat: MessageCircle,
@@ -30,12 +41,13 @@ export const icons = {
moon: Moon,
search: Search,
sun: Sun,
+ star: Star,
user: User,
+ x: X,
Discord: createBrandIcon(Discord),
GitHub: createBrandIcon(GitHub),
LinkedIn: createBrandIcon(LinkedIn),
Medium: createBrandIcon(Medium),
- X: createBrandIcon(X),
YouTube: createBrandIcon(YouTube),
};