fix: 토스트가 화면에 보이도록 FoxToast로 바꾼다
components/ui/toast.tsx와 FeedbackHost가 bg-surface·shadow-toast·fixed inset-x-0 같은 Tailwind 클래스로 그려져 있었는데 이 프로젝트에는 Tailwind가 없다. 정의된 곳이 없어 토스트가 문서 맨 아래에 12px짜리 투명한 줄로 붙었다 — showToast를 부르는 39곳이 전부 아무것도 보이지 않는 상태였다. - @fox의 FoxToast로 그린다(사용자 확정). 계열 색은 시안에 없어 모양이 하나다. 대신 danger만 urgent로 올려 보조기술이 읽던 것을 끊고 바로 읽게 한다. - 위치는 호출부 책임이라(@fox 규칙) feedback-host.module.scss가 상단 가운데로 잡는다. - FoxModal은
@ad7f0e88c199fd5da8fbbe6865cd556d308250b7
+++ app/_components/feedback-host.module.scss
... | ... | @@ -0,0 +1,58 @@ |
| 1 | +// 전역 피드백(토스트·스피너)이 화면에서 차지하는 자리. | |
| 2 | +// | |
| 3 | +// FoxToast·FoxSpinner는 모양만 갖고 어디에 뜰지는 호출부가 정한다(@fox 규칙) — 그 배치를 | |
| 4 | +// 여기서 준다. | |
| 5 | +// | |
| 6 | +// 이슈: FoxModal은 `<dialog>.showModal()`이라 브라우저 top layer에 올라간다. 보통 요소는 | |
| 7 | +// z-index를 아무리 올려도 그 위에 그릴 수 없어, 모달을 띄운 채 저장에 실패하면 토스트가 | |
| 8 | +// 모달 뒤에 가려진다. 그래서 토스트도 popover로 같은 top layer에 얹는다 — popover의 기본 | |
| 9 | +// 스타일(가운데 정렬·테두리·배경)을 아래에서 전부 지우는 이유다. | |
| 10 | + | |
| 11 | +@use "@fox/styles/abstracts" as fox; | |
| 12 | + | |
| 13 | +.toastLayer { | |
| 14 | + position: fixed; | |
| 15 | + inset-block-start: fox.padding(7); | |
| 16 | + inset-block-end: auto; | |
| 17 | + inset-inline: 0; | |
| 18 | + inline-size: auto; | |
| 19 | + max-inline-size: none; | |
| 20 | + block-size: auto; | |
| 21 | + max-block-size: none; | |
| 22 | + margin: 0; | |
| 23 | + padding-inline: fox.padding(6); | |
| 24 | + border: 0; | |
| 25 | + background: none; | |
| 26 | + color: inherit; | |
| 27 | + overflow: visible; | |
| 28 | + // 읽고 지나가는 자리라 아래 화면 조작을 막지 않는다. | |
| 29 | + pointer-events: none; | |
| 30 | +} | |
| 31 | + | |
| 32 | +.toastLayer:popover-open { | |
| 33 | + display: flex; | |
| 34 | + flex-direction: column; | |
| 35 | + align-items: center; | |
| 36 | + gap: fox.gap(3); | |
| 37 | +} | |
| 38 | + | |
| 39 | +// popover가 없는 브라우저 — top layer는 못 쓰지만 자리는 잡는다. | |
| 40 | +@supports not selector(:popover-open) { | |
| 41 | + .toastLayer { | |
| 42 | + display: flex; | |
| 43 | + flex-direction: column; | |
| 44 | + align-items: center; | |
| 45 | + gap: fox.gap(3); | |
| 46 | + z-index: 50; | |
| 47 | + } | |
| 48 | +} | |
| 49 | + | |
| 50 | +.spinnerOverlay { | |
| 51 | + position: fixed; | |
| 52 | + inset: 0; | |
| 53 | + z-index: 50; | |
| 54 | + display: flex; | |
| 55 | + align-items: center; | |
| 56 | + justify-content: center; | |
| 57 | + background: fox.color(background-dim); | |
| 58 | +} |
--- app/_components/feedback-host.tsx
+++ app/_components/feedback-host.tsx
... | ... | @@ -1,29 +1,52 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 |
+import { useEffect, useRef } from 'react';
|
|
| 3 | 4 |
import { FoxButton } from '@fox/core/components/fox-button';
|
| 4 | 5 |
import { FoxModal } from '@fox/core/components/fox-modal';
|
| 6 |
+import { FoxToast } from '@fox/core/components/fox-toast';
|
|
| 5 | 7 |
import { Spinner } from '@/components/ui/spinner';
|
| 6 |
-import { Toast } from '@/components/ui/toast';
|
|
| 7 | 8 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 9 |
+import styles from './feedback-host.module.scss'; |
|
| 8 | 10 |
|
| 9 | 11 |
/** |
| 10 |
- * FeedbackProvider의 상태를 design 레인의 Toast·Alert·Spinner 비주얼에 연결만 하는 역할 — |
|
| 12 |
+ * FeedbackProvider의 상태를 @fox 비주얼(Toast·Modal·Spinner)에 연결만 하는 역할 — |
|
| 11 | 13 |
* 위치·스택·표시 타이머 등 정책은 FeedbackProvider가 가지고, 이 컴포넌트는 마운트만 담당한다. |
| 14 |
+ * |
|
| 15 |
+ * 계열(success/danger/…)은 색으로 드러나지 않는다 — @fox 시안의 토스트가 하나뿐이라 |
|
| 16 |
+ * (`_fox-toast.scss`) 모양이 갈리지 않는다. 대신 `danger`만 `urgent`로 올려 보조기술이 |
|
| 17 |
+ * 읽던 것을 끊고 바로 읽게 한다. |
|
| 12 | 18 |
*/ |
| 13 | 19 |
export function FeedbackHost() {
|
| 14 | 20 |
const { toasts, alert, spinnerCount, hideAlert } = useFeedback();
|
| 21 |
+ const toastLayerRef = useRef<HTMLDivElement>(null); |
|
| 22 |
+ const hasToasts = toasts.length > 0; |
|
| 23 |
+ |
|
| 24 |
+ // 모달 위에서도 보이도록 top layer에 얹는다(feedback-host.module.scss의 이슈 주석 참고). |
|
| 25 |
+ useEffect(() => {
|
|
| 26 |
+ const layer = toastLayerRef.current; |
|
| 27 |
+ if (!layer || typeof layer.showPopover !== 'function') {
|
|
| 28 |
+ return; |
|
| 29 |
+ } |
|
| 30 |
+ |
|
| 31 |
+ const isOpen = layer.matches(':popover-open');
|
|
| 32 |
+ if (hasToasts && !isOpen) {
|
|
| 33 |
+ layer.showPopover(); |
|
| 34 |
+ } else if (!hasToasts && isOpen) {
|
|
| 35 |
+ layer.hidePopover(); |
|
| 36 |
+ } |
|
| 37 |
+ }, [hasToasts]); |
|
| 15 | 38 |
|
| 16 | 39 |
return ( |
| 17 | 40 |
<> |
| 18 |
- {toasts.length > 0 && (
|
|
| 19 |
- <div className="fixed inset-x-0 top-4 z-50 flex flex-col items-center gap-2 px-4"> |
|
| 20 |
- {toasts.map((toast) => (
|
|
| 21 |
- <Toast key={toast.id} variant={toast.variant}>
|
|
| 22 |
- {toast.message}
|
|
| 23 |
- </Toast> |
|
| 24 |
- ))} |
|
| 25 |
- </div> |
|
| 26 |
- )} |
|
| 41 |
+ <div ref={toastLayerRef} popover="manual" className={styles.toastLayer}>
|
|
| 42 |
+ {toasts.map((toast) => (
|
|
| 43 |
+ <FoxToast |
|
| 44 |
+ key={toast.id}
|
|
| 45 |
+ message={toast.message}
|
|
| 46 |
+ urgent={toast.variant === 'danger'}
|
|
| 47 |
+ /> |
|
| 48 |
+ ))} |
|
| 49 |
+ </div> |
|
| 27 | 50 |
|
| 28 | 51 |
{alert && (
|
| 29 | 52 |
<FoxModal |
... | ... | @@ -47,7 +70,7 @@ |
| 47 | 70 |
)} |
| 48 | 71 |
|
| 49 | 72 |
{spinnerCount > 0 && (
|
| 50 |
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-scrim"> |
|
| 73 |
+ <div className={styles.spinnerOverlay}>
|
|
| 51 | 74 |
<Spinner size="lg" tone="inverse" /> |
| 52 | 75 |
</div> |
| 53 | 76 |
)} |
--- components/ui/toast.tsx
... | ... | @@ -1,30 +0,0 @@ |
| 1 | -import type { ReactNode } from "react"; | |
| 2 | - | |
| 3 | -type ToastVariant = "info" | "success" | "danger" | "warning"; | |
| 4 | - | |
| 5 | -const ACCENT_CLASSES: Record<ToastVariant, string> = { | |
| 6 | - info: "border-l-primary", | |
| 7 | - success: "border-l-success", | |
| 8 | - danger: "border-l-danger", | |
| 9 | - warning: "border-l-warning", | |
| 10 | -}; | |
| 11 | - | |
| 12 | -interface ToastProps { | |
| 13 | - variant?: ToastVariant; | |
| 14 | - children: ReactNode; | |
| 15 | -} | |
| 16 | - | |
| 17 | -/** | |
| 18 | - * 알림 비주얼(토스트 1개). 위치·스택·표시 타이머는 호출부(feature) 책임 — | |
| 19 | - * 이 컴포넌트는 상태를 가지지 않는다. | |
| 20 | - */ | |
| 21 | -export function Toast({ variant = "info", children }: ToastProps) { | |
| 22 | - return ( | |
| 23 | - <div | |
| 24 | - role="status" | |
| 25 | - className={`animate-toast-in rounded-lg border-l-4 bg-surface px-4 py-3 text-body-md text-foreground shadow-toast ${ACCENT_CLASSES[variant]}`} | |
| 26 | - > | |
| 27 | - {children} | |
| 28 | - </div> | |
| 29 | - ); | |
| 30 | -} |
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?