+++ @fox/core/components/fox-toast/fox-toast.tsx
... | ... | @@ -0,0 +1,67 @@ |
| 1 | +import type { ReactNode, Ref } from "react"; | |
| 2 | +import { cx } from "../../utils"; | |
| 3 | + | |
| 4 | +export interface FoxToastProps { | |
| 5 | + /** | |
| 6 | + * 알림 문구. | |
| 7 | + * | |
| 8 | + * 이름이 `label`이 아니다 — 다른 컴포넌트의 `label`은 컨트롤에 붙는 이름이지만, 이쪽은 | |
| 9 | + * 조각 안에 담기는 **내용**이라 성격이 다르다. | |
| 10 | + */ | |
| 11 | + message: ReactNode; | |
| 12 | + /** | |
| 13 | + * 보조기술이 읽는 방식을 정한다. | |
| 14 | + * | |
| 15 | + * - `false`(기본): `role="status"` — 하던 말을 끊지 않고 차례가 오면 읽는다. 저장 완료처럼 | |
| 16 | + * 놓쳐도 되는 알림. | |
| 17 | + * - `true`: `role="alert"` — 읽던 것을 끊고 바로 읽는다. 지금 손을 멈춰야 하는 알림에만 | |
| 18 | + * 쓴다. 남발하면 화면 낭독이 계속 끊긴다. | |
| 19 | + */ | |
| 20 | + urgent?: boolean; | |
| 21 | + /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ | |
| 22 | + hidden?: boolean; | |
| 23 | + id?: string; | |
| 24 | + /** 배치 조정용. 띄우는 위치(fixed·bottom 등)를 여기로 준다. */ | |
| 25 | + className?: string; | |
| 26 | + ref?: Ref<HTMLDivElement>; | |
| 27 | +} | |
| 28 | + | |
| 29 | +/** | |
| 30 | + * @fox 토스트. 어두운 판 위에 한 줄짜리 알림을 얹는다. 크기 수식어가 없다 — 시안에 하나뿐이다. | |
| 31 | + * | |
| 32 | + * **상태를 갖지 않는다.** 언제 뜨고 언제 사라질지, 화면 어디에 놓일지는 전부 호출부가 정한다 | |
| 33 | + * (사용자 결정) — 이 조각은 조건부로 렌더하고 `className`으로 자리를 잡아 주면 된다. 타이머와 | |
| 34 | + * 위치를 안에 두면 SCSS만 쓰는 소비자가 그 절반을 못 쓰고, 뒤이어 만들 스낵바와 규칙이 갈린다. | |
| 35 | + * | |
| 36 | + * 상호작용이 없어 `"use client"`가 아니다 — 서버 컴포넌트로 렌더된다. | |
| 37 | + * | |
| 38 | + * ⚠️ 보조기술은 **이미 화면에 있던 live region의 내용이 바뀔 때** 읽는다. 이 요소를 통째로 새로 | |
| 39 | + * 붙이면 브라우저·리더 조합에 따라 읽히지 않을 수 있다. 확실히 읽혀야 하는 자리라면 호출부가 | |
| 40 | + * 빈 `<div role="status">`를 미리 두고 그 안에서 토스트를 갈아 끼운다. | |
| 41 | + * | |
| 42 | + * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` | |
| 43 | + * (또는 개별 파티셜)로 한 번 불러와야 한다. | |
| 44 | + */ | |
| 45 | +export function FoxToast({ | |
| 46 | + message, | |
| 47 | + urgent = false, | |
| 48 | + hidden = false, | |
| 49 | + id, | |
| 50 | + className, | |
| 51 | + ref, | |
| 52 | +}: FoxToastProps) { | |
| 53 | + if (hidden) { | |
| 54 | + return null; | |
| 55 | + } | |
| 56 | + | |
| 57 | + return ( | |
| 58 | + <div | |
| 59 | + ref={ref} | |
| 60 | + id={id} | |
| 61 | + role={urgent ? "alert" : "status"} | |
| 62 | + className={cx("fox-toast", className)} | |
| 63 | + > | |
| 64 | + {message} | |
| 65 | + </div> | |
| 66 | + ); | |
| 67 | +} |
+++ @fox/core/components/fox-toast/index.ts
... | ... | @@ -0,0 +1,1 @@ |
| 1 | +export { FoxToast, type FoxToastProps } from "./fox-toast"; |
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
... | ... | @@ -46,6 +46,7 @@ |
| 46 | 46 |
export * from "./fox-tag-list"; |
| 47 | 47 |
export * from "./fox-text-area"; |
| 48 | 48 |
export * from "./fox-text-button"; |
| 49 |
+export * from "./fox-toast"; |
|
| 49 | 50 |
export * from "./fox-toggle-quantity"; |
| 50 | 51 |
export * from "./fox-toggle-switch"; |
| 51 | 52 |
|
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 |
"use client"; |
| 2 | 2 |
|
| 3 | 3 |
import { FoxInput, FoxInputState, FoxSelect, FoxSelectItem, FoxSelectOption, FoxSelectOptionItem } from "@/@fox/core/components";
|
| 4 |
-import { useState, type CSSProperties, type ReactNode } from "react";
|
|
| 4 |
+import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
|
|
| 5 | 5 |
import { FoxAddress, type FoxAddressSearchResult, type FoxAddressValue } from "../core/components/fox-address";
|
| 6 | 6 |
import { FoxBreadcrumb } from "../core/components/fox-breadcrumb";
|
| 7 | 7 |
import { FoxButton } from "../core/components/fox-button";
|
... | ... | @@ -27,6 +27,7 @@ |
| 27 | 27 |
import { FoxRadioIcon } from "../core/components/fox-radio-icon";
|
| 28 | 28 |
import { FoxTag } from "../core/components/fox-tag";
|
| 29 | 29 |
import { FoxTagList } from "../core/components/fox-tag-list";
|
| 30 |
+import { FoxToast } from "../core/components/fox-toast";
|
|
| 30 | 31 |
import { FoxTextArea, type FoxTextAreaState } from "../core/components/fox-text-area";
|
| 31 | 32 |
import { FoxTextButton } from "../core/components/fox-text-button";
|
| 32 | 33 |
import { FoxToggleSwitch } from "../core/components/fox-toggle-switch";
|
... | ... | @@ -593,6 +594,45 @@ |
| 593 | 594 |
<rect x="2" y="3" width="12" height="11" rx="1.5" /> |
| 594 | 595 |
<path d="M2 6.5h12M5.5 1.5v3M10.5 1.5v3" /> |
| 595 | 596 |
</svg> |
| 597 |
+ ); |
|
| 598 |
+} |
|
| 599 |
+ |
|
| 600 |
+/** |
|
| 601 |
+ * 토스트는 상태를 갖지 않는다 — 언제 뜨고 언제 사라질지, 화면 어디에 놓일지가 전부 호출부 |
|
| 602 |
+ * 몫이라는 것을 보여 주는 예제다. 여기서는 3초 뒤에 스스로 지우고, 자리는 감싼 div가 잡는다. |
|
| 603 |
+ */ |
|
| 604 |
+function ToastDemo() {
|
|
| 605 |
+ const [open, setOpen] = useState(false); |
|
| 606 |
+ |
|
| 607 |
+ useEffect(() => {
|
|
| 608 |
+ if (!open) return; |
|
| 609 |
+ const timer = window.setTimeout(() => setOpen(false), 3000); |
|
| 610 |
+ return () => window.clearTimeout(timer); |
|
| 611 |
+ }, [open]); |
|
| 612 |
+ |
|
| 613 |
+ return ( |
|
| 614 |
+ <> |
|
| 615 |
+ <FoxButton |
|
| 616 |
+ size="md" |
|
| 617 |
+ type="primary" |
|
| 618 |
+ label="토스트 띄우기" |
|
| 619 |
+ onAction={() => setOpen(true)}
|
|
| 620 |
+ disabled={open}
|
|
| 621 |
+ /> |
|
| 622 |
+ {open ? (
|
|
| 623 |
+ <div |
|
| 624 |
+ style={{
|
|
| 625 |
+ position: "fixed", |
|
| 626 |
+ insetInlineStart: "50%", |
|
| 627 |
+ insetBlockEnd: "4rem", |
|
| 628 |
+ transform: "translateX(-50%)", |
|
| 629 |
+ zIndex: 100, |
|
| 630 |
+ }} |
|
| 631 |
+ > |
|
| 632 |
+ <FoxToast message="저장되었습니다." /> |
|
| 633 |
+ </div> |
|
| 634 |
+ ) : null} |
|
| 635 |
+ </> |
|
| 596 | 636 |
); |
| 597 | 637 |
} |
| 598 | 638 |
|
... | ... | @@ -3255,4 +3295,28 @@ |
| 3255 | 3295 |
}, |
| 3256 | 3296 |
], |
| 3257 | 3297 |
}, |
| 3298 |
+ {
|
|
| 3299 |
+ id: "fox-toast", |
|
| 3300 |
+ name: "FoxToast", |
|
| 3301 |
+ description: |
|
| 3302 |
+ "Figma 시안(toast 441:5471) 토스트메세지 — 상태를 갖지 않습니다: 언제 뜨고 언제 사라질지, 화면 어디에 놓일지는 전부 호출부가 정합니다. 타이머와 위치를 안에 두면 SCSS만 쓰는 소비자가 그 절반을 못 쓰고, 뒤이어 만들 스낵바와 규칙이 갈립니다. 배경·글자색이 *-inverse 토큰이라 다크 모드에서는 둘이 함께 뒤집혀 밝은 판 + 어두운 글자가 됩니다.", |
|
| 3303 |
+ variants: [ |
|
| 3304 |
+ {
|
|
| 3305 |
+ label: "기본", |
|
| 3306 |
+ node: <FoxToast message="저장되었습니다." />, |
|
| 3307 |
+ }, |
|
| 3308 |
+ {
|
|
| 3309 |
+ label: "문구가 길면 폭이 내용만큼 늘어납니다 (시안에 상한이 없어 넣지 않았습니다)", |
|
| 3310 |
+ node: <FoxToast message="입력하신 내용을 저장했습니다. 목록에서 확인할 수 있습니다." />, |
|
| 3311 |
+ }, |
|
| 3312 |
+ {
|
|
| 3313 |
+ label: "urgent — 모양은 같고 role만 status에서 alert로 바뀝니다 (읽던 것을 끊고 바로 읽음)", |
|
| 3314 |
+ node: <FoxToast message="네트워크 연결이 끊겼습니다." urgent />, |
|
| 3315 |
+ }, |
|
| 3316 |
+ {
|
|
| 3317 |
+ label: "띄우고 지우는 것은 호출부 몫입니다 — 눌러 보세요 (3초 뒤 사라짐, 화면 하단 고정)", |
|
| 3318 |
+ node: <ToastDemo />, |
|
| 3319 |
+ }, |
|
| 3320 |
+ ], |
|
| 3321 |
+ }, |
|
| 3258 | 3322 |
]; |
+++ @fox/styles/_fox-toast.scss
... | ... | @@ -0,0 +1,53 @@ |
| 1 | +// FoxToast — 시안: 통합관리자페이지 디자인시스템 Figma toast | |
| 2 | +// | |
| 3 | +// 어두운 판 위에 한 줄짜리 알림을 얹는다. 크기 수식어가 없다 — 시안에 하나뿐이다. | |
| 4 | +// | |
| 5 | +// 마크업 계약 (React 밖 소비자용): | |
| 6 | +// <div class="fox-toast" role="status">저장되었습니다.</div> | |
| 7 | +// | |
| 8 | +// 글자를 감싸는 엘리먼트를 두지 않는다 — 안에 들어가는 것이 글자 하나뿐이라 따로 규칙을 | |
| 9 | +// 걸 대상이 없다. 아이콘이나 버튼이 붙는 날이 오면 그때 `__label`을 만든다. | |
| 10 | +// | |
| 11 | +// **띄우는 위치는 여기서 정하지 않는다.** 시안이 조각의 모양만 주고, 화면 어디에 어떻게 | |
| 12 | +// 나타나는지는 쓰는 화면이 정한다(사용자 결정). 호출부가 `className`으로 `position: fixed` | |
| 13 | +// 같은 배치를 준다. | |
| 14 | +// | |
| 15 | +// 배경·글자색이 `*-inverse` 토큰이다 — 라이트에서 어두운 판 + 흰 글자이고, 다크 모드에서는 | |
| 16 | +// 둘이 함께 뒤집혀 밝은 판 + 어두운 글자가 된다. 두 값을 한 쌍으로 골라야 대비가 유지된다. | |
| 17 | +// | |
| 18 | +// 토큰이 아닌 값(FoxButton·FoxTag와 동일한 근거로 승인된 항목): | |
| 19 | +// - line-height / letter-spacing: 시안 텍스트 스타일 body/md의 값이나 Figma가 변수로 | |
| 20 | +// export하지 않는다. 시안의 -0.375px은 15px 기준이라 `-0.025em`으로 적는다 — px으로 | |
| 21 | +// 박으면 글자 크기가 모바일에서 13px으로 줄 때 비율이 어긋난다. | |
| 22 | +// - box-sizing / display / align-items: 구조 규칙. | |
| 23 | +// | |
| 24 | +// ⚠️ 그림자 색: 시안이 두 겹을 각각 `dark/alpha/black10`·`alpha/black10`으로 물려 놓았는데 | |
| 25 | +// 값이 같고(rgba(0,0,0,0.10)) 앞쪽 이름의 토큰은 export에 없다. 그래서 두 겹 모두 | |
| 26 | +// `alpha-black10`을 쓴다 — `fox-select-option`과 같은 선택이다(다크 모드에서도 검은 그림자). | |
| 27 | + | |
| 28 | +@use "@fox/styles/abstracts" as fox; | |
| 29 | + | |
| 30 | +.fox-toast { | |
| 31 | + // 안쪽 여백이 바깥 크기를 늘리지 않게 한다. | |
| 32 | + box-sizing: border-box; | |
| 33 | + display: inline-flex; | |
| 34 | + // 글자 하나뿐이라 지금은 티가 나지 않지만, 시안이 세로 가운데 정렬을 명시한다. | |
| 35 | + align-items: center; | |
| 36 | + padding: fox.padding(5) fox.padding(7); | |
| 37 | + border-radius: fox.radius(4); | |
| 38 | + background: fox.color(surface-neutral-default-inverse); | |
| 39 | + | |
| 40 | + // 시안의 neutral/lv3 — 가까운 그림자와 먼 그림자 두 겹이다. | |
| 41 | + box-shadow: | |
| 42 | + fox.shadow(location2) fox.shadow(location2) fox.shadow(blur3) 0 | |
| 43 | + fox.primitive(alpha-black10), | |
| 44 | + fox.shadow(location4) fox.shadow(location4) fox.shadow(blur4) 0 | |
| 45 | + fox.primitive(alpha-black10); | |
| 46 | + | |
| 47 | + color: fox.color(font-neutral-strong-inverse); | |
| 48 | + font-family: fox.font-family(body); | |
| 49 | + font-size: fox.font-size(body-md); | |
| 50 | + font-weight: fox.font-weight(regular); | |
| 51 | + line-height: 1.5; | |
| 52 | + letter-spacing: -0.025em; | |
| 53 | +} |
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
... | ... | @@ -50,4 +50,5 @@ |
| 50 | 50 |
@use "fox-tab"; |
| 51 | 51 |
@use "fox-tab-item"; |
| 52 | 52 |
@use "fox-spinner"; |
| 53 |
+@use "fox-toast"; |
|
| 53 | 54 |
@use "fox-toggle-quantity"; |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?