민혜린 민혜린 08-18
feat: 디자인시스템 FoxToast 추가
@8338303bfeeb9a5e3bad33db1bdcc46a1ae23e99
 
@fox/core/components/fox-toast/fox-toast.tsx (added)
+++ @fox/core/components/fox-toast/fox-toast.tsx
@@ -0,0 +1,67 @@
+import type { ReactNode, Ref } from "react";
+import { cx } from "../../utils";
+
+export interface FoxToastProps {
+  /**
+   * 알림 문구.
+   *
+   * 이름이 `label`이 아니다 — 다른 컴포넌트의 `label`은 컨트롤에 붙는 이름이지만, 이쪽은
+   * 조각 안에 담기는 **내용**이라 성격이 다르다.
+   */
+  message: ReactNode;
+  /**
+   * 보조기술이 읽는 방식을 정한다.
+   *
+   * - `false`(기본): `role="status"` — 하던 말을 끊지 않고 차례가 오면 읽는다. 저장 완료처럼
+   *   놓쳐도 되는 알림.
+   * - `true`: `role="alert"` — 읽던 것을 끊고 바로 읽는다. 지금 손을 멈춰야 하는 알림에만
+   *   쓴다. 남발하면 화면 낭독이 계속 끊긴다.
+   */
+  urgent?: boolean;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  id?: string;
+  /** 배치 조정용. 띄우는 위치(fixed·bottom 등)를 여기로 준다. */
+  className?: string;
+  ref?: Ref<HTMLDivElement>;
+}
+
+/**
+ * @fox 토스트. 어두운 판 위에 한 줄짜리 알림을 얹는다. 크기 수식어가 없다 — 시안에 하나뿐이다.
+ *
+ * **상태를 갖지 않는다.** 언제 뜨고 언제 사라질지, 화면 어디에 놓일지는 전부 호출부가 정한다
+ * (사용자 결정) — 이 조각은 조건부로 렌더하고 `className`으로 자리를 잡아 주면 된다. 타이머와
+ * 위치를 안에 두면 SCSS만 쓰는 소비자가 그 절반을 못 쓰고, 뒤이어 만들 스낵바와 규칙이 갈린다.
+ *
+ * 상호작용이 없어 `"use client"`가 아니다 — 서버 컴포넌트로 렌더된다.
+ *
+ * ⚠️ 보조기술은 **이미 화면에 있던 live region의 내용이 바뀔 때** 읽는다. 이 요소를 통째로 새로
+ * 붙이면 브라우저·리더 조합에 따라 읽히지 않을 수 있다. 확실히 읽혀야 하는 자리라면 호출부가
+ * 빈 `<div role="status">`를 미리 두고 그 안에서 토스트를 갈아 끼운다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxToast({
+  message,
+  urgent = false,
+  hidden = false,
+  id,
+  className,
+  ref,
+}: FoxToastProps) {
+  if (hidden) {
+    return null;
+  }
+
+  return (
+    <div
+      ref={ref}
+      id={id}
+      role={urgent ? "alert" : "status"}
+      className={cx("fox-toast", className)}
+    >
+      {message}
+    </div>
+  );
+}
 
@fox/core/components/fox-toast/index.ts (added)
+++ @fox/core/components/fox-toast/index.ts
@@ -0,0 +1,1 @@
+export { FoxToast, type FoxToastProps } from "./fox-toast";
@fox/core/components/index.ts
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
@@ -46,6 +46,7 @@
 export * from "./fox-tag-list";
 export * from "./fox-text-area";
 export * from "./fox-text-button";
+export * from "./fox-toast";
 export * from "./fox-toggle-quantity";
 export * from "./fox-toggle-switch";
 
@fox/dev-test/component-registry.tsx
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
@@ -1,7 +1,7 @@
 "use client";
 
 import { FoxInput, FoxInputState, FoxSelect, FoxSelectItem, FoxSelectOption, FoxSelectOptionItem } from "@/@fox/core/components";
-import { useState, type CSSProperties, type ReactNode } from "react";
+import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
 import { FoxAddress, type FoxAddressSearchResult, type FoxAddressValue } from "../core/components/fox-address";
 import { FoxBreadcrumb } from "../core/components/fox-breadcrumb";
 import { FoxButton } from "../core/components/fox-button";
@@ -27,6 +27,7 @@
 import { FoxRadioIcon } from "../core/components/fox-radio-icon";
 import { FoxTag } from "../core/components/fox-tag";
 import { FoxTagList } from "../core/components/fox-tag-list";
+import { FoxToast } from "../core/components/fox-toast";
 import { FoxTextArea, type FoxTextAreaState } from "../core/components/fox-text-area";
 import { FoxTextButton } from "../core/components/fox-text-button";
 import { FoxToggleSwitch } from "../core/components/fox-toggle-switch";
@@ -593,6 +594,45 @@
       <rect x="2" y="3" width="12" height="11" rx="1.5" />
       <path d="M2 6.5h12M5.5 1.5v3M10.5 1.5v3" />
     </svg>
+  );
+}
+
+/**
+ * 토스트는 상태를 갖지 않는다 — 언제 뜨고 언제 사라질지, 화면 어디에 놓일지가 전부 호출부
+ * 몫이라는 것을 보여 주는 예제다. 여기서는 3초 뒤에 스스로 지우고, 자리는 감싼 div가 잡는다.
+ */
+function ToastDemo() {
+  const [open, setOpen] = useState(false);
+
+  useEffect(() => {
+    if (!open) return;
+    const timer = window.setTimeout(() => setOpen(false), 3000);
+    return () => window.clearTimeout(timer);
+  }, [open]);
+
+  return (
+    <>
+      <FoxButton
+        size="md"
+        type="primary"
+        label="토스트 띄우기"
+        onAction={() => setOpen(true)}
+        disabled={open}
+      />
+      {open ? (
+        <div
+          style={{
+            position: "fixed",
+            insetInlineStart: "50%",
+            insetBlockEnd: "4rem",
+            transform: "translateX(-50%)",
+            zIndex: 100,
+          }}
+        >
+          <FoxToast message="저장되었습니다." />
+        </div>
+      ) : null}
+    </>
   );
 }
 
@@ -3255,4 +3295,28 @@
       },
     ],
   },
+  {
+    id: "fox-toast",
+    name: "FoxToast",
+    description:
+      "Figma 시안(toast 441:5471) 토스트메세지 — 상태를 갖지 않습니다: 언제 뜨고 언제 사라질지, 화면 어디에 놓일지는 전부 호출부가 정합니다. 타이머와 위치를 안에 두면 SCSS만 쓰는 소비자가 그 절반을 못 쓰고, 뒤이어 만들 스낵바와 규칙이 갈립니다. 배경·글자색이 *-inverse 토큰이라 다크 모드에서는 둘이 함께 뒤집혀 밝은 판 + 어두운 글자가 됩니다.",
+    variants: [
+      {
+        label: "기본",
+        node: <FoxToast message="저장되었습니다." />,
+      },
+      {
+        label: "문구가 길면 폭이 내용만큼 늘어납니다 (시안에 상한이 없어 넣지 않았습니다)",
+        node: <FoxToast message="입력하신 내용을 저장했습니다. 목록에서 확인할 수 있습니다." />,
+      },
+      {
+        label: "urgent — 모양은 같고 role만 status에서 alert로 바뀝니다 (읽던 것을 끊고 바로 읽음)",
+        node: <FoxToast message="네트워크 연결이 끊겼습니다." urgent />,
+      },
+      {
+        label: "띄우고 지우는 것은 호출부 몫입니다 — 눌러 보세요 (3초 뒤 사라짐, 화면 하단 고정)",
+        node: <ToastDemo />,
+      },
+    ],
+  },
 ];
 
@fox/styles/_fox-toast.scss (added)
+++ @fox/styles/_fox-toast.scss
@@ -0,0 +1,53 @@
+// FoxToast — 시안: 통합관리자페이지 디자인시스템 Figma toast
+//
+// 어두운 판 위에 한 줄짜리 알림을 얹는다. 크기 수식어가 없다 — 시안에 하나뿐이다.
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-toast" role="status">저장되었습니다.</div>
+//
+// 글자를 감싸는 엘리먼트를 두지 않는다 — 안에 들어가는 것이 글자 하나뿐이라 따로 규칙을
+// 걸 대상이 없다. 아이콘이나 버튼이 붙는 날이 오면 그때 `__label`을 만든다.
+//
+// **띄우는 위치는 여기서 정하지 않는다.** 시안이 조각의 모양만 주고, 화면 어디에 어떻게
+// 나타나는지는 쓰는 화면이 정한다(사용자 결정). 호출부가 `className`으로 `position: fixed`
+// 같은 배치를 준다.
+//
+// 배경·글자색이 `*-inverse` 토큰이다 — 라이트에서 어두운 판 + 흰 글자이고, 다크 모드에서는
+// 둘이 함께 뒤집혀 밝은 판 + 어두운 글자가 된다. 두 값을 한 쌍으로 골라야 대비가 유지된다.
+//
+// 토큰이 아닌 값(FoxButton·FoxTag와 동일한 근거로 승인된 항목):
+//   - line-height / letter-spacing: 시안 텍스트 스타일 body/md의 값이나 Figma가 변수로
+//     export하지 않는다. 시안의 -0.375px은 15px 기준이라 `-0.025em`으로 적는다 — px으로
+//     박으면 글자 크기가 모바일에서 13px으로 줄 때 비율이 어긋난다.
+//   - box-sizing / display / align-items: 구조 규칙.
+//
+// ⚠️ 그림자 색: 시안이 두 겹을 각각 `dark/alpha/black10`·`alpha/black10`으로 물려 놓았는데
+// 값이 같고(rgba(0,0,0,0.10)) 앞쪽 이름의 토큰은 export에 없다. 그래서 두 겹 모두
+// `alpha-black10`을 쓴다 — `fox-select-option`과 같은 선택이다(다크 모드에서도 검은 그림자).
+
+@use "@fox/styles/abstracts" as fox;
+
+.fox-toast {
+  // 안쪽 여백이 바깥 크기를 늘리지 않게 한다.
+  box-sizing: border-box;
+  display: inline-flex;
+  // 글자 하나뿐이라 지금은 티가 나지 않지만, 시안이 세로 가운데 정렬을 명시한다.
+  align-items: center;
+  padding: fox.padding(5) fox.padding(7);
+  border-radius: fox.radius(4);
+  background: fox.color(surface-neutral-default-inverse);
+
+  // 시안의 neutral/lv3 — 가까운 그림자와 먼 그림자 두 겹이다.
+  box-shadow:
+    fox.shadow(location2) fox.shadow(location2) fox.shadow(blur3) 0
+      fox.primitive(alpha-black10),
+    fox.shadow(location4) fox.shadow(location4) fox.shadow(blur4) 0
+      fox.primitive(alpha-black10);
+
+  color: fox.color(font-neutral-strong-inverse);
+  font-family: fox.font-family(body);
+  font-size: fox.font-size(body-md);
+  font-weight: fox.font-weight(regular);
+  line-height: 1.5;
+  letter-spacing: -0.025em;
+}
@fox/styles/components.scss
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
@@ -50,4 +50,5 @@
 @use "fox-tab";
 @use "fox-tab-item";
 @use "fox-spinner";
+@use "fox-toast";
 @use "fox-toggle-quantity";
Add a comment
List