diff --git a/docs/content/react/components/snackbar.mdx b/docs/content/react/components/snackbar.mdx index 782c13210a..4923b525c8 100644 --- a/docs/content/react/components/snackbar.mdx +++ b/docs/content/react/components/snackbar.mdx @@ -83,6 +83,22 @@ npx @seed-design/cli@latest add ui:snackbar ``` +### Strategy + +Snackbar가 이미 표시 중일 때 새로운 Snackbar를 생성하면, 기본적으로 기존 Snackbar를 즉시 교체합니다 (`immediate`). +큐에 넣고 순차적으로 보여주려면 `strategy: "queued"`를 사용합니다. + +`strategy`는 `SnackbarProvider`에서 기본값을 설정하거나, `create()` 호출 시 개별적으로 지정할 수 있습니다. + + + ```json doc-gen:file + { + "file": "examples/react/snackbar/strategy.tsx", + "codeblock": true + } + ``` + + ### Avoid Overlap `` 컴포넌트를 사용하여 스낵바가 겹치지 않아야 하는 영역을 지정할 수 있습니다. diff --git a/docs/examples/react/snackbar/strategy.tsx b/docs/examples/react/snackbar/strategy.tsx new file mode 100644 index 0000000000..330e512283 --- /dev/null +++ b/docs/examples/react/snackbar/strategy.tsx @@ -0,0 +1,40 @@ +import { ActionButton } from "seed-design/ui/action-button"; +import { Snackbar, SnackbarProvider, useSnackbarAdapter } from "seed-design/ui/snackbar"; + +function Component() { + const adapter = useSnackbarAdapter(); + + return ( +
+ + adapter.create({ + render: () => , + }) + } + > + Immediate (positive) + + + adapter.create({ + strategy: "queued", + render: () => , + }) + } + > + Queued (critical) + +
+ ); +} + +export default function SnackbarStrategy() { + return ( + + + + ); +} diff --git a/examples/stackflow-spa/src/activities/ActivityHome.tsx b/examples/stackflow-spa/src/activities/ActivityHome.tsx index 238cc96648..2b5a1d57e2 100644 --- a/examples/stackflow-spa/src/activities/ActivityHome.tsx +++ b/examples/stackflow-spa/src/activities/ActivityHome.tsx @@ -220,6 +220,28 @@ const ActivityHome: StaticActivityComponentType<"ActivityHome"> = ({ params }) = ), }), }, + { + title: "Snackbar (queued)", + onClick: () => + snackbarAdapter.create({ + strategy: "queued", + render: () => , + }), + }, + { + // 기존 스낵바를 먼저 닫고 다음 tick에 새 스낵바를 띄우는 패턴. + // dismiss 상태 전이가 적용된 뒤에 create가 실행되므로 + // 항상 새 스낵바부터 활성화되는 것을 보장한다. + title: "Snackbar (dismiss+setTimeout workaround)", + onClick: () => { + snackbarAdapter.dismiss(); + setTimeout(() => { + snackbarAdapter.create({ + render: () => , + }); + }, 0); + }, + }, ], }, { diff --git a/packages/react-headless/snackbar/src/Snackbar.tsx b/packages/react-headless/snackbar/src/Snackbar.tsx index cd88aa9a82..935ea3e4c9 100644 --- a/packages/react-headless/snackbar/src/Snackbar.tsx +++ b/packages/react-headless/snackbar/src/Snackbar.tsx @@ -12,11 +12,8 @@ export interface SnackbarRootProviderProps extends UseSnackbarProps { children: React.ReactNode; } -export const SnackbarRootProvider = ({ - children, - pauseOnInteraction, -}: SnackbarRootProviderProps) => { - const api = useSnackbar({ pauseOnInteraction }); +export const SnackbarRootProvider = ({ children, ...props }: SnackbarRootProviderProps) => { + const api = useSnackbar(props); return {children}; }; diff --git a/packages/react-headless/snackbar/src/useSnackbar.test.tsx b/packages/react-headless/snackbar/src/useSnackbar.test.tsx new file mode 100644 index 0000000000..cf3b20d11f --- /dev/null +++ b/packages/react-headless/snackbar/src/useSnackbar.test.tsx @@ -0,0 +1,372 @@ +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterAll, beforeEach, describe, expect, it, jest, mock } from "bun:test"; +import { StrictMode } from "react"; + +import { + SnackbarRegion, + SnackbarRenderer, + SnackbarRoot, + SnackbarRootProvider, + type SnackbarRootProviderProps, +} from "./Snackbar"; +import { useSnackbarContext, type UseSnackbarContext } from "./useSnackbarContext"; + +class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +const originalResizeObserver = window.ResizeObserver; +window.ResizeObserver = ResizeObserver; + +afterAll(() => { + window.ResizeObserver = originalResizeObserver; +}); + +let snackbarApi: UseSnackbarContext; + +function SnackbarControls() { + const api = useSnackbarContext(); + snackbarApi = api; + + return ( +
+ + {api.currentSnackbar && ( + + + + )} + +
+ ); +} + +function setUp(providerProps: Omit = {}) { + return { + user: userEvent.setup({ advanceTimers: (ms) => jest.advanceTimersByTime(ms) }), + ...render( + + + , + ), + }; +} + +function setUpStrict(providerProps: Omit = {}) { + return { + user: userEvent.setup({ advanceTimers: (ms) => jest.advanceTimersByTime(ms) }), + ...render( + + + + + , + ), + }; +} + +function createSnackbar(message: string, options: Record = {}) { + return { + render: () => {message}, + timeout: 5000, + removeDelay: 200, + ...options, + }; +} + +describe("useSnackbar", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + describe("common", () => { + it("should display a single snackbar", () => { + setUp(); + + act(() => { + snackbarApi.create(createSnackbar("Hello")); + }); + + expect(screen.getByText("Hello")).toBeInTheDocument(); + }); + + it("should auto-dismiss after timeout", () => { + setUp(); + + act(() => { + snackbarApi.create(createSnackbar("Auto dismiss", { timeout: 1000, removeDelay: 100 })); + }); + + expect(screen.getByText("Auto dismiss")).toBeInTheDocument(); + + // timeout fires → dismissing state + act(() => jest.advanceTimersByTime(1000)); + // removeDelay fires → inactive state + act(() => jest.advanceTimersByTime(100)); + + expect(screen.queryByText("Auto dismiss")).not.toBeInTheDocument(); + }); + + it("should dismiss on dismiss() call", () => { + setUp(); + + act(() => { + snackbarApi.create(createSnackbar("Dismiss me", { removeDelay: 100 })); + }); + + expect(screen.getByText("Dismiss me")).toBeInTheDocument(); + + act(() => snackbarApi.dismiss()); + act(() => jest.advanceTimersByTime(100)); + + expect(screen.queryByText("Dismiss me")).not.toBeInTheDocument(); + }); + + it("should call onClose exactly once when dismiss() is invoked", () => { + const onClose = mock(() => {}); + setUp(); + + act(() => { + snackbarApi.create(createSnackbar("Bye", { onClose, removeDelay: 100 })); + }); + + act(() => snackbarApi.dismiss()); + act(() => jest.advanceTimersByTime(100)); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + }); + + describe("StrictMode safety", () => { + it("should not lose queued items under StrictMode double-invocation", () => { + setUpStrict({ strategy: "queued" }); + + act(() => { + snackbarApi.create(createSnackbar("A", { timeout: 10000, removeDelay: 1000 })); + snackbarApi.create(createSnackbar("B", { timeout: 10000, removeDelay: 1000 })); + snackbarApi.create(createSnackbar("C", { timeout: 10000, removeDelay: 1000 })); + }); + + expect(screen.getByText("A")).toBeInTheDocument(); + expect(snackbarApi.queue.length).toBe(2); + }); + }); + + describe("immediate (default)", () => { + it("should replace current snackbar with new one", () => { + setUp(); + + act(() => snackbarApi.create(createSnackbar("First", { removeDelay: 100 }))); + expect(screen.getByText("First")).toBeInTheDocument(); + + act(() => snackbarApi.create(createSnackbar("Second"))); + + // First is in dismissing state (exit animation) + // Advance removeDelay to complete exit, then new one enters + act(() => jest.advanceTimersByTime(100)); + + expect(screen.queryByText("First")).not.toBeInTheDocument(); + expect(screen.getByText("Second")).toBeInTheDocument(); + }); + + it("should call onClose of replaced snackbar", () => { + const onClose = mock(() => {}); + setUp(); + + act(() => snackbarApi.create(createSnackbar("First", { onClose, removeDelay: 100 }))); + + act(() => snackbarApi.create(createSnackbar("Second"))); + + // onClose fires during dismissing → inactive transition + act(() => jest.advanceTimersByTime(100)); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("should clear queue on replacement", () => { + setUp({ strategy: "queued" }); + + // Queue up 3 snackbars in queued mode + act(() => snackbarApi.create(createSnackbar("First", { removeDelay: 100 }))); + act(() => { + snackbarApi.create(createSnackbar("Queued A", { strategy: "queued" })); + snackbarApi.create(createSnackbar("Queued B", { strategy: "queued" })); + }); + + expect(screen.getByText("First")).toBeInTheDocument(); + expect(snackbarApi.queue.length).toBe(2); + + // Immediate snackbar clears queue, triggers dismiss of First + act(() => snackbarApi.create(createSnackbar("Urgent", { strategy: "immediate" }))); + + // Advance removeDelay for First to exit, then Urgent enters + act(() => jest.advanceTimersByTime(100)); + + expect(screen.getByText("Urgent")).toBeInTheDocument(); + expect(snackbarApi.queue.length).toBe(0); + }); + + it("should not call onClose of queued snackbars that were dropped without being shown", () => { + const onCloseFirst = mock(() => {}); + const onCloseQueuedA = mock(() => {}); + const onCloseQueuedB = mock(() => {}); + setUp({ strategy: "queued" }); + + act(() => + snackbarApi.create(createSnackbar("First", { onClose: onCloseFirst, removeDelay: 100 })), + ); + act(() => { + snackbarApi.create( + createSnackbar("Queued A", { strategy: "queued", onClose: onCloseQueuedA }), + ); + snackbarApi.create( + createSnackbar("Queued B", { strategy: "queued", onClose: onCloseQueuedB }), + ); + }); + + expect(snackbarApi.queue.length).toBe(2); + + // Immediate snackbar drops the queue (Queued A, B were never shown) + act(() => snackbarApi.create(createSnackbar("Urgent", { strategy: "immediate" }))); + act(() => jest.advanceTimersByTime(100)); + + // First was shown → its onClose fires once + expect(onCloseFirst).toHaveBeenCalledTimes(1); + // Queued A, B never became currentSnackbar → their onClose must not fire + expect(onCloseQueuedA).not.toHaveBeenCalled(); + expect(onCloseQueuedB).not.toHaveBeenCalled(); + }); + + it("should restart timeout after replacement", () => { + setUp(); + + act(() => snackbarApi.create(createSnackbar("First", { timeout: 1000, removeDelay: 100 }))); + + // Advance 800ms (not yet dismissed) + act(() => jest.advanceTimersByTime(800)); + expect(screen.getByText("First")).toBeInTheDocument(); + + // Replace: triggers dismiss of First + act(() => snackbarApi.create(createSnackbar("Second", { timeout: 1000, removeDelay: 100 }))); + + // Advance 100ms removeDelay for First exit → Second enters with fresh timeout + act(() => jest.advanceTimersByTime(100)); + expect(screen.getByText("Second")).toBeInTheDocument(); + + // Advance 800ms — Second should still be visible (fresh timeout = 1000ms) + act(() => jest.advanceTimersByTime(800)); + expect(screen.getByText("Second")).toBeInTheDocument(); + + // Advance remaining 200ms → dismissing, then 100ms removeDelay → gone + act(() => jest.advanceTimersByTime(200)); + act(() => jest.advanceTimersByTime(100)); + expect(screen.queryByText("Second")).not.toBeInTheDocument(); + }); + }); + + describe("queued", () => { + it("should queue snackbars when strategy is queued", () => { + setUp({ strategy: "queued" }); + + act(() => { + snackbarApi.create(createSnackbar("First", { timeout: 1000, removeDelay: 100 })); + snackbarApi.create(createSnackbar("Second")); + }); + + // First is showing, Second is in queue + expect(screen.getByText("First")).toBeInTheDocument(); + expect(screen.queryByText("Second")).not.toBeInTheDocument(); + expect(snackbarApi.queue.length).toBe(1); + + // Dismiss first → second shows + act(() => jest.advanceTimersByTime(1000)); + act(() => jest.advanceTimersByTime(100)); + + expect(screen.queryByText("First")).not.toBeInTheDocument(); + expect(screen.getByText("Second")).toBeInTheDocument(); + }); + }); + + describe("per-snackbar strategy override", () => { + it("should mix queued and immediate overrides in immediate provider", () => { + setUp(); // default: immediate + + // 1. First shows, then Queued-A and Queued-B are created in same act + act(() => { + snackbarApi.create(createSnackbar("First", { timeout: 1000, removeDelay: 100 })); + snackbarApi.create( + createSnackbar("Queued-A", { strategy: "queued", timeout: 1000, removeDelay: 100 }), + ); + snackbarApi.create( + createSnackbar("Queued-B", { strategy: "queued", timeout: 1000, removeDelay: 100 }), + ); + }); + + expect(screen.getByText("First")).toBeInTheDocument(); + expect(snackbarApi.queue.length).toBe(2); + + // 4. Urgent replaces First: dismiss First, queue only Urgent + act(() => snackbarApi.create(createSnackbar("Urgent", { timeout: 1000, removeDelay: 100 }))); + + // First exit animation + act(() => jest.advanceTimersByTime(100)); + + expect(screen.queryByText("First")).not.toBeInTheDocument(); + expect(screen.getByText("Urgent")).toBeInTheDocument(); + expect(snackbarApi.queue.length).toBe(0); + + // 5. After Urgent times out, nothing left + act(() => jest.advanceTimersByTime(1000)); + act(() => jest.advanceTimersByTime(100)); + expect(screen.queryByText("Urgent")).not.toBeInTheDocument(); + }); + + it("should mix queued and immediate overrides in queued provider", () => { + setUp({ strategy: "queued" }); // default: queued + + // 1. First shows, Second and Third queue — all in same act + act(() => { + snackbarApi.create(createSnackbar("First", { timeout: 1000, removeDelay: 100 })); + snackbarApi.create(createSnackbar("Second", { timeout: 1000, removeDelay: 100 })); + snackbarApi.create(createSnackbar("Third", { timeout: 1000, removeDelay: 100 })); + }); + + expect(screen.getByText("First")).toBeInTheDocument(); + expect(snackbarApi.queue.length).toBe(2); + + // 4. Urgent with immediate override: dismiss First, queue only Urgent + act(() => + snackbarApi.create( + createSnackbar("Urgent", { strategy: "immediate", timeout: 1000, removeDelay: 100 }), + ), + ); + + // First exit animation + act(() => jest.advanceTimersByTime(100)); + + expect(screen.queryByText("First")).not.toBeInTheDocument(); + expect(screen.getByText("Urgent")).toBeInTheDocument(); + expect(snackbarApi.queue.length).toBe(0); + + // 5. While Urgent is showing, queue a new one (default queued) + act(() => + snackbarApi.create(createSnackbar("After-Urgent", { timeout: 1000, removeDelay: 100 })), + ); + expect(screen.getByText("Urgent")).toBeInTheDocument(); + expect(snackbarApi.queue.length).toBe(1); + + // 6. Urgent times out → After-Urgent shows from queue + act(() => jest.advanceTimersByTime(1000)); + act(() => jest.advanceTimersByTime(100)); + expect(screen.queryByText("Urgent")).not.toBeInTheDocument(); + expect(screen.getByText("After-Urgent")).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/react-headless/snackbar/src/useSnackbar.ts b/packages/react-headless/snackbar/src/useSnackbar.ts index 219a2eeb7f..8f159bbf9c 100644 --- a/packages/react-headless/snackbar/src/useSnackbar.ts +++ b/packages/react-headless/snackbar/src/useSnackbar.ts @@ -1,6 +1,6 @@ import { ariaAttr, buttonProps, dataAttr, elementProps } from "@seed-design/dom-utils"; import { useSupports } from "@seed-design/react-supports"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useReducer } from "react"; import { useSafeOffset } from "./useSafeOffset"; type SnackbarState = "inactive" | "active" | "persist" | "dismissing"; @@ -11,6 +11,14 @@ interface UseSnackbarStateProps { * @default true */ pauseOnInteraction?: boolean; + + /** + * How to handle multiple snackbars. + * - `"immediate"`: New snackbar replaces the current one instantly. + * - `"queued"`: New snackbar waits in a queue until the current one is dismissed. + * @default "immediate" + */ + strategy?: "immediate" | "queued"; } export interface CreateSnackbarOptions { @@ -34,95 +42,110 @@ export interface CreateSnackbarOptions { * The content to render in the snackbar region */ render: () => React.ReactNode; + + /** + * Override the provider-level strategy for this specific snackbar. + * - `"immediate"`: Replace the current snackbar instantly. + * - `"queued"`: Wait in the queue until the current one is dismissed. + */ + strategy?: "immediate" | "queued"; } -function useSnackbarState({ pauseOnInteraction = true }: UseSnackbarStateProps) { - const [state, setState] = useState("inactive"); - const [queue, setQueue] = useState([]); - const [currentSnackbar, setCurrentSnackbar] = useState(null); +interface ReducerState { + state: SnackbarState; + queue: CreateSnackbarOptions[]; + currentSnackbar: CreateSnackbarOptions | null; +} - const visibleDuration = currentSnackbar?.timeout ?? 4000; - const removeDelay = currentSnackbar?.removeDelay ?? 200; - const visible = state === "active" || state === "persist"; +type ReducerAction = + | { type: "PUSH"; option: CreateSnackbarOptions; strategy: "immediate" | "queued" } + | { type: "ACTIVATE_NEXT" } + | { type: "PAUSE"; pauseOnInteraction: boolean } + | { type: "RESUME" } + | { type: "DISMISS" } + | { type: "REMOVE" }; - // actions - const push = useCallback((option: CreateSnackbarOptions) => { - setQueue((prev) => [...prev, option]); - }, []); - - const pop = useCallback(() => { - setQueue(([snackbar, ...rest]) => { - setCurrentSnackbar(snackbar ?? null); - return rest; - }); - }, []); - - const removeCurrentSnackbar = useCallback(() => { - setCurrentSnackbar(null); - }, []); - - const invokeOnClose = useCallback(() => { - if (currentSnackbar?.onClose) { - currentSnackbar.onClose(); - } - }, [currentSnackbar]); +const initialState: ReducerState = { + state: "inactive", + queue: [], + currentSnackbar: null, +}; - // entry events - useEffect(() => { - if (state === "inactive") { - if (queue.length >= 1) { - pop(); - setState("active"); +function reducer(s: ReducerState, action: ReducerAction): ReducerState { + switch (action.type) { + case "PUSH": { + const { option, strategy } = action; + if (strategy === "immediate") { + switch (s.state) { + case "inactive": + return { state: "active", currentSnackbar: option, queue: [] }; + case "dismissing": + return { ...s, queue: [option] }; + default: + return { ...s, state: "dismissing", queue: [option] }; + } } + if (s.state === "inactive") { + return { state: "active", currentSnackbar: option, queue: [] }; + } + return { ...s, queue: [...s.queue, option] }; } - - if (state === "active") { - const timeout = setTimeout(() => { - setState("dismissing"); - }, visibleDuration); - return () => clearTimeout(timeout); + case "ACTIVATE_NEXT": { + if (s.state !== "inactive" || s.queue.length === 0) return s; + const [first, ...rest] = s.queue; + return { state: "active", currentSnackbar: first, queue: rest }; } + case "PAUSE": + return action.pauseOnInteraction && s.state === "active" ? { ...s, state: "persist" } : s; + case "RESUME": + return s.state === "persist" ? { ...s, state: "active" } : s; + case "DISMISS": + return s.state === "active" || s.state === "persist" ? { ...s, state: "dismissing" } : s; + case "REMOVE": + return { ...s, state: "inactive", currentSnackbar: null }; + } +} + +function useSnackbarState({ + pauseOnInteraction = true, + strategy = "immediate", +}: UseSnackbarStateProps) { + const [{ state, queue, currentSnackbar }, dispatch] = useReducer(reducer, initialState); - if (state === "dismissing") { - const timeout = setTimeout(() => { - setState("inactive"); - invokeOnClose(); - removeCurrentSnackbar(); - }, removeDelay); - return () => clearTimeout(timeout); + const visibleDuration = currentSnackbar?.timeout ?? 4000; + const removeDelay = currentSnackbar?.removeDelay ?? 200; + const visible = state === "active" || state === "persist"; + + useEffect(() => { + switch (state) { + case "inactive": { + if (queue.length >= 1) dispatch({ type: "ACTIVATE_NEXT" }); + break; + } + case "active": { + const timeout = setTimeout(() => dispatch({ type: "DISMISS" }), visibleDuration); + return () => clearTimeout(timeout); + } + case "dismissing": { + const timeout = setTimeout(() => { + currentSnackbar?.onClose?.(); + dispatch({ type: "REMOVE" }); + }, removeDelay); + return () => clearTimeout(timeout); + } } - }, [state, queue, visibleDuration, removeDelay, pop, invokeOnClose, removeCurrentSnackbar]); + }, [state, queue.length, currentSnackbar, visibleDuration, removeDelay]); - // events const events = useMemo( () => ({ push: (option: CreateSnackbarOptions) => { - push(option); - if (state === "inactive") { - pop(); - setState("active"); - } - }, - pause: () => { - if (state === "active") { - if (pauseOnInteraction) { - setState("persist"); - } - } - }, - resume: () => { - if (state === "persist") { - setState("active"); - } - }, - dismiss: () => { - if (state === "active" || state === "persist") { - setState("dismissing"); - invokeOnClose(); - } + dispatch({ type: "PUSH", option, strategy: option.strategy ?? strategy }); }, + pause: () => dispatch({ type: "PAUSE", pauseOnInteraction }), + resume: () => dispatch({ type: "RESUME" }), + dismiss: () => dispatch({ type: "DISMISS" }), }), - [state, push, pop, invokeOnClose, pauseOnInteraction], + [strategy, pauseOnInteraction], ); return useMemo( diff --git a/packages/react/src/components/Snackbar/useSnackbarAdapter.ts b/packages/react/src/components/Snackbar/useSnackbarAdapter.ts index dd17c7b33b..2dde29ccd3 100644 --- a/packages/react/src/components/Snackbar/useSnackbarAdapter.ts +++ b/packages/react/src/components/Snackbar/useSnackbarAdapter.ts @@ -17,6 +17,7 @@ export function useSnackbarAdapter() { removeDelay: options.removeDelay ?? 200, onClose: options.onClose, render: options.render, + strategy: options.strategy, }); }, dismiss: api.dismiss,