BLK-87Blocks

トースト通知(取り消しつき・重なりの整列)

保存・送信・削除の結果を画面の右上(狭い画面では下端)に数秒だけ出すトースト通知。続けて届いた通知は新しいものを手前に重ね、奥のものは縁だけを覗かせ、ポインタを載せるかフォーカスが入ると縦に広がって全文が読める。同時に出すのは3件までで、4件目が来たら一番古いものから消える。ポインタが載っている間・中にフォーカスがある間・タブが裏にある間・画面外にある間は残り時間の時計が止まり、残り秒数と細線で見える。削除の知らせだけは8秒と「元に戻す」が付き、押すと行が元の位置に戻って、同じ通知が「元に戻しました」に差し替わる。読み上げは別の aria-live 領域で1回だけ行い、Alt+T(Mac は Option+T)で最新の通知へ移り、Esc で閉じるとフォーカスは元の場所へ戻る。動きは transform と opacity だけで、prefers-reduced-motion では重ねずに広げて並べ、静止したまま全文が読める。依存ライブラリなし。角丸・英字の小見出し・末尾の矢印リンクは使わず、hairline 罫線と余白で組んでいる。

追加日:
2026-10-02
依存:
なし
tags
#toast #notification #snackbar #undo #aria-live #keyboard #accessibility #ui #no-image

プレビュー

結果は画面の端に短く、消したものはすぐ戻せる

保存・送信・削除の結果を右上に数秒だけ出します。続けて届いたら積み重ね、古いものから消えます。ポインタを載せるか中にフォーカスがある間は時計が止まり、削除の知らせには「元に戻す」が付きます。

First CH見積もり依頼の下書き

添付ファイル 4 件

  • 見積書_1002.pdf182 KB
  • 現場写真_01.jpg2.4 MB
  • 現場写真_02.jpg2.1 MB
  • 要件メモ.txt4 KB

    通知のきまり

    1. 1新しいものが手前続けて届いた通知は重ねて置き、奥のものは縁だけ見せます。載せると広がります。
    2. 2同時に出すのは3件まで4件目が来たら一番古いものから消します。残り時間も古い順に尽きます。
    3. 3読んでいる間は止めるポインタが載っている間と、中にフォーカスがある間は時計が進みません。
    4. 4削除だけ8秒と取り消し元に戻すと、その通知が「戻しました」に変わり、行は元の位置へ戻ります。
    Alt+T
    最新の通知へ移る(Mac は Option+T)
    Tab
    通知の中のボタンを順に選ぶ
    Enter
    元に戻す・閉じるを押す
    Esc
    フォーカス中の通知を閉じる
    "use client";
    
    import { useEffect, useId, useRef, useState, useSyncExternalStore, type KeyboardEvent } from "react";
    import { Montserrat } from "next/font/google";
    
    const montserrat = Montserrat({ weight: ["500", "600"], subsets: ["latin"], display: "swap" });
    
    /**
     * トースト通知(取り消しつき・重なりの整列)
     *
     * 差別化: 既存のフォーム・UI部品(ステータスバッジ・ボタン・入力欄・フォーム)はどれも置いたら動かない静的な部品で、
     * 時間で現れて消える一過性の知らせは無かった。本ブロックは「待ち行列」そのもの(最大3件の積み上げ・古いものから退場・
     * ホバー中は時計が止まる・削除は元に戻せる)で、構造も操作も別物。角丸・英字の小見出し・末尾の矢印リンクは置かない。
     *
     * 汎用技法メモ:
     * 1. **残り時間は rAF の差分で減らす**。各通知の残りミリ秒を Map に持ち、毎フレーム dt を引く。止めたいとき
     *    (ポインタが載っている・中にフォーカスがある・タブが裏・画面外・一覧の縮小カード)は引かないだけで一時停止になる。
     *    進み具合の細線(scaleX)と残り秒数は state を通さず DOM へ直接書くので、毎フレーム再描画しない。
     * 2. **重なりは transform だけで整える**。全件を上端に absolute で重ね、新しい順の番号 i で
     *    畳んだ時は scale(1-0.05×i) と、縁が12px×i覗くだけ下へずらす(縮んだ分の高さも足す)、広げた時は「手前の通知の高さの合計+隙間」だけ下へずらす。
     *    高さは ResizeObserver で中身を測る。置き場は広い枠で右上・狭い枠で下端(上に置くと操作ボタンを覆う)で、向きの符号だけを反転する。畳んだ時は奥の通知の高さを手前に揃え、中身を opacity 0 にして縁だけ覗かせる。
     * 3. **4件目が来たら最古を先に退場させる**。退場は opacity と translateX を 220ms 掛けてから配列から外す。
     * 4. **読み上げは別の live 領域で1回だけ**。見えている通知の一覧は live にせず、sr-only の role="status" に
     *    文面を書く(同じ文面が続いても読まれるよう、一度空にしてから次のフレームで入れる)。
     * 5. **キーボード**: Alt+T(Mac は Option+T・e.code で判定)で最新の通知へ移り、Tab で中のボタン、Esc で閉じる。
     *    閉じた通知にフォーカスがあったら、残っている最新の通知か、通知を出した元の場所へ戻す。
     * 6. prefers-reduced-motion では遷移を切り、通知は常に広げて並べる(重ねないので静止状態でも全文が読める)。
     *    進み具合の細線は消し、残り秒数の数字だけで伝える。
     *
     * 文言・項目・配色はすべて First CH のオリジナル。
     */
    
    type FileRow = { id: string; name: string; size: string };
    
    type Kind = "save" | "send" | "delete" | "restore";
    
    type Toast = {
      id: number;
      kind: Kind;
      title: string;
      detail: string;
      duration: number;
      /** 削除の通知だけが持つ。元に戻すときの行と元の位置 */
      undo?: { row: FileRow; index: number };
      phase: "enter" | "shown" | "leave";
    };
    
    const MAX = 3;
    const GAP = 10;
    const PEEK = 12;
    const LEAVE_MS = 220;
    
    const FILES: FileRow[] = [
      { id: "f1", name: "見積書_1002.pdf", size: "182 KB" },
      { id: "f2", name: "現場写真_01.jpg", size: "2.4 MB" },
      { id: "f3", name: "現場写真_02.jpg", size: "2.1 MB" },
      { id: "f4", name: "現場写真_03.jpg", size: "1.9 MB" },
      { id: "f5", name: "要件メモ.txt", size: "4 KB" },
    ];
    
    // 最初から1件消しておき、その「元に戻す」つきの通知を見本として出す
    const SEED_DELETED = { row: FILES[3], index: 3 };
    
    const subscribeMotion = (cb: () => void) => {
      const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
      mq.addEventListener("change", cb);
      return () => mq.removeEventListener("change", cb);
    };
    const getMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const getMotionServer = () => false;
    
    export default function ToastUndoNotice() {
      const uid = useId();
      const headingId = `${uid}-h`;
      const reduce = useSyncExternalStore(subscribeMotion, getMotion, getMotionServer);
    
      const [rows, setRows] = useState<FileRow[]>(() => FILES.filter((f) => f.id !== SEED_DELETED.row.id));
      const [toasts, setToasts] = useState<Toast[]>([]);
      const [heights, setHeights] = useState<Record<number, number>>({});
      const [hovered, setHovered] = useState(false);
      const [focused, setFocused] = useState(false);
      const [announce, setAnnounce] = useState("");
      // 広い枠では右上、狭い枠では下端に出す(狭い枠で上に置くと操作ボタンを覆うため)
      const [atTop, setAtTop] = useState(true);
      const [saves, setSaves] = useState(0);
      const [sends, setSends] = useState(0);
    
      const rootRef = useRef<HTMLElement>(null);
      const stageRef = useRef<HTMLDivElement>(null);
      const listRef = useRef<HTMLOListElement>(null);
      const nextId = useRef(1);
      const remaining = useRef(new Map<number, number>());
      const bars = useRef(new Map<number, HTMLElement>());
      const secs = useRef(new Map<number, HTMLElement>());
      const items = useRef(new Map<number, HTMLLIElement>());
      const contents = useRef(new Map<Element, number>());
      const observer = useRef<ResizeObserver | null>(null);
      const toastsRef = useRef<Toast[]>([]);
      const hoveredRef = useRef(false);
      const focusedRef = useRef(false);
      const inViewRef = useRef(true);
      const reduceRef = useRef(false);
      const returnRef = useRef<HTMLElement | null>(null);
      const dismissRef = useRef<(id: number) => void>(() => {});
    
      const expanded = reduce || hovered || focused;
    
      useEffect(() => {
        toastsRef.current = toasts;
        hoveredRef.current = hovered;
        focusedRef.current = focused;
        reduceRef.current = reduce;
      });
    
      // 中身の高さを測る(重なりの計算に使う)
      useEffect(() => {
        const ro = new ResizeObserver((entries) => {
          setHeights((prev) => {
            let next = prev;
            for (const en of entries) {
              const id = contents.current.get(en.target);
              if (id === undefined) continue;
              const h = Math.round((en.target as HTMLElement).offsetHeight);
              if (prev[id] !== h) {
                if (next === prev) next = { ...prev };
                next[id] = h;
              }
            }
            return next;
          });
        });
        observer.current = ro;
        contents.current.forEach((_, el) => ro.observe(el));
        return () => {
          ro.disconnect();
          observer.current = null;
        };
      }, []);
    
      useEffect(() => {
        const el = stageRef.current;
        if (!el) return;
        const ro = new ResizeObserver(() => setAtTop(el.offsetWidth >= 512));
        ro.observe(el);
        return () => ro.disconnect();
      }, []);
    
      // 画面外では時計を止める
      useEffect(() => {
        const el = stageRef.current;
        if (!el) return;
        const io = new IntersectionObserver(([en]) => {
          inViewRef.current = en.isIntersecting;
        });
        io.observe(el);
        return () => io.disconnect();
      }, []);
    
      const isScaled = () => {
        const el = rootRef.current;
        if (!el || el.offsetWidth === 0) return true;
        return el.getBoundingClientRect().width / el.offsetWidth < 0.75;
      };
    
      // 残り時間の時計。止める条件のどれかが立っている間は引かない
      useEffect(() => {
        let raf = 0;
        let last = 0;
        const tick = (t: number) => {
          const dt = last ? Math.min(t - last, 100) : 0;
          last = t;
          // フォーカス中の要素が消える(元に戻す・閉じる)と blur が来ないので、毎フレーム実物で確かめる
          const inList = !!listRef.current?.contains(document.activeElement);
          if (inList !== focusedRef.current) {
            focusedRef.current = inList;
            setFocused(inList);
          }
          const held = hoveredRef.current || inList;
          const stopped = held || document.hidden || !inViewRef.current || isScaled();
          for (const toast of toastsRef.current) {
            if (toast.phase === "leave") continue;
            let rem = remaining.current.get(toast.id) ?? toast.duration;
            if (!stopped) rem -= dt;
            remaining.current.set(toast.id, rem);
            if (rem <= 0) {
              dismissRef.current(toast.id);
              continue;
            }
            const bar = bars.current.get(toast.id);
            if (bar) bar.style.transform = `scaleX(${Math.max(0, rem / toast.duration)})`;
            const sec = secs.current.get(toast.id);
            if (sec) {
              const text = `${held ? "停止中 " : ""}${Math.ceil(rem / 1000)}s`;
              if (sec.textContent !== text) sec.textContent = text;
            }
          }
          raf = requestAnimationFrame(tick);
        };
        raf = requestAnimationFrame(tick);
        return () => cancelAnimationFrame(raf);
      }, []);
    
      const speak = (text: string) => {
        setAnnounce("");
        requestAnimationFrame(() => setAnnounce(text));
      };
    
      const focusAfter = (fallback?: HTMLElement | null) => {
        requestAnimationFrame(() => {
          const live = toastsRef.current.filter((t) => t.phase !== "leave");
          const newest = live.length ? items.current.get(live[live.length - 1].id) : undefined;
          const target = newest ?? (returnRef.current?.isConnected ? returnRef.current : fallback);
          target?.focus();
        });
      };
    
      const dismiss = (id: number) => {
        const li = items.current.get(id);
        const hadFocus = !!li && li.contains(document.activeElement);
        setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, phase: "leave" } : t)));
        // 退場中の通知はもう選べないので、閉じた時点で残りの toastsRef を先に更新しておく
        toastsRef.current = toastsRef.current.map((t) => (t.id === id ? { ...t, phase: "leave" } : t));
        window.setTimeout(
          () => {
            setToasts((prev) => prev.filter((t) => t.id !== id));
            remaining.current.delete(id);
          },
          reduceRef.current ? 0 : LEAVE_MS,
        );
        if (hadFocus) focusAfter(listRef.current);
      };
    
      useEffect(() => {
        dismissRef.current = dismiss;
      });
    
      const push = (t: Omit<Toast, "id" | "phase">, opts: { quiet?: boolean } = {}) => {
        const id = nextId.current++;
        const live = toastsRef.current.filter((x) => x.phase !== "leave");
        if (live.length >= MAX) dismiss(live[0].id);
        remaining.current.set(id, t.duration);
        const toast: Toast = { ...t, id, phase: reduceRef.current ? "shown" : "enter" };
        toastsRef.current = [...toastsRef.current, toast];
        setToasts((prev) => [...prev, toast]);
        if (!reduceRef.current) {
          requestAnimationFrame(() =>
            requestAnimationFrame(() => setToasts((prev) => prev.map((x) => (x.id === id && x.phase === "enter" ? { ...x, phase: "shown" } : x)))),
          );
        }
        if (!opts.quiet) speak(t.undo ? `${t.title}。元に戻すボタンは通知の中にあります。Alt+T で通知へ移動できます` : `${t.title}。${t.detail}`);
      };
    
      // 見本: 開いた直後に2件届いた状態から始める
      useEffect(() => {
        const a = window.setTimeout(() => push({ kind: "save", title: "下書きを自動保存しました", detail: "あとで続きから書けます", duration: 6000 }, { quiet: true }), 150);
        const b = window.setTimeout(
          () =>
            push(
              { kind: "delete", title: `「${SEED_DELETED.row.name}」を削除しました`, detail: "添付ファイルから外しました", duration: 9000, undo: SEED_DELETED },
              { quiet: true },
            ),
          500,
        );
        return () => {
          window.clearTimeout(a);
          window.clearTimeout(b);
        };
        // 見本の初期状態を1回だけ出す
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, []);
    
      // Alt+T で最新の通知へ移る(一覧の縮小カードでは反応しない)
      useEffect(() => {
        const onKey = (e: globalThis.KeyboardEvent) => {
          if (e.code !== "KeyT" || !e.altKey || e.metaKey || e.ctrlKey || e.shiftKey) return;
          if (isScaled()) return;
          const live = toastsRef.current.filter((t) => t.phase !== "leave");
          if (!live.length) return;
          e.preventDefault();
          const active = document.activeElement as HTMLElement | null;
          if (active && !listRef.current?.contains(active)) returnRef.current = active;
          items.current.get(live[live.length - 1].id)?.focus();
        };
        window.addEventListener("keydown", onKey);
        return () => window.removeEventListener("keydown", onKey);
      }, []);
    
      const save = (e: { currentTarget: HTMLElement }) => {
        returnRef.current = e.currentTarget;
        const n = saves + 1;
        setSaves(n);
        push({ kind: "save", title: "下書きを保存しました", detail: n === 1 ? "あとで続きから書けます" : `今日 ${n} 回目の保存です`, duration: 5000 });
      };
    
      const send = (e: { currentTarget: HTMLElement }) => {
        returnRef.current = e.currentTarget;
        const n = sends + 1;
        setSends(n);
        // 見本なので実際には送らない。受付済みと誤解させないよう、通知でもそう明示する
        push({ kind: "send", title: "送信の見本です(実際には送信していません)", detail: n === 1 ? "本番ではここに受付番号と返信の目安を出します" : `見本の送信 ${n} 回目・どこにも届いていません`, duration: 6000 });
      };
    
      const remove = (row: FileRow, index: number) => {
        setRows((prev) => prev.filter((r) => r.id !== row.id));
        // 消した行のボタンは無くなるので、隣の行か「保存」ボタンを戻り先にする
        const neighbor = rows[index + 1] ?? rows[index - 1];
        const stage = stageRef.current;
        requestAnimationFrame(() => {
          const target =
            (neighbor && stage?.querySelector<HTMLElement>(`[data-row="${neighbor.id}"] button`)) || stage?.querySelector<HTMLElement>("[data-save]") || null;
          returnRef.current = target;
          target?.focus();
        });
        push({ kind: "delete", title: `「${row.name}」を削除しました`, detail: "添付ファイルから外しました", duration: 8000, undo: { row, index } });
      };
    
      const undo = (toast: Toast) => {
        if (!toast.undo) return;
        const { row, index } = toast.undo;
        setRows((prev) => {
          if (prev.some((r) => r.id === row.id)) return prev;
          const next = [...prev];
          next.splice(Math.min(index, next.length), 0, row);
          return next;
        });
        // 同じ通知をその場で「戻しました」に差し替え、時計も巻き直す
        const restored: Toast = { ...toast, kind: "restore", title: `「${row.name}」を元に戻しました`, detail: "元の位置に戻っています", duration: 3500, undo: undefined };
        remaining.current.set(toast.id, restored.duration);
        toastsRef.current = toastsRef.current.map((t) => (t.id === toast.id ? restored : t));
        setToasts((prev) => prev.map((t) => (t.id === toast.id ? restored : t)));
        speak(restored.title);
        const stage = stageRef.current;
        requestAnimationFrame(() => {
          const btn = stage?.querySelector<HTMLElement>(`[data-row="${row.id}"] button`);
          returnRef.current = btn ?? returnRef.current;
          btn?.focus();
        });
      };
    
      const onToastKey = (e: KeyboardEvent<HTMLLIElement>, id: number) => {
        if (e.key === "Escape") {
          e.preventDefault();
          dismiss(id);
        }
      };
    
      // 新しい順の番号と、広げた時の下へのずらし量
      const ordered = [...toasts].reverse();
      const offsets: number[] = [];
      let acc = 0;
      for (const t of ordered) {
        offsets.push(acc);
        acc += (heights[t.id] ?? 72) + GAP;
      }
      const frontH = ordered.length ? (heights[ordered[0].id] ?? 72) : 0;
      const shownCount = Math.min(ordered.filter((t) => t.phase !== "leave").length, MAX);
      const listH = !ordered.length ? 0 : expanded ? Math.max(acc - GAP, 0) : frontH + Math.max(shownCount - 1, 0) * PEEK;
    
      return (
        <section ref={rootRef} aria-labelledby={headingId} className="@container w-full max-w-5xl bg-white text-gray-900">
          {/* 冒頭: 英字の小見出しは置かず、和文の見出しとリードだけで開く */}
          <div className="max-w-2xl">
            <h2 id={headingId} className="text-[clamp(1.45rem,3.4vw,2.1rem)] leading-[1.35] font-bold tracking-tight">
              結果は画面の端に短く、
              <span className="whitespace-nowrap">消したものはすぐ戻せる</span>
            </h2>
            <p className="mt-4 text-[14px] leading-[1.9] font-medium text-gray-600">保存・送信・削除の結果を右上に数秒だけ出します。続けて届いたら積み重ね、古いものから消えます。ポインタを載せるか中にフォーカスがある間は時計が止まり、削除の知らせには「元に戻す」が付きます。</p>
          </div>
    
          <div className="mt-8 grid gap-8 border-t border-gray-200 pt-8 @2xl:grid-cols-[minmax(0,1fr)_15rem] @2xl:gap-9">
            {/* 舞台: 見本の入力画面。通知はこの枠の右上に出す(本番では fixed で画面の右上)。広い枠では通知の列を空けておく */}
            <div ref={stageRef} className="@container/stage relative h-[31rem] overflow-hidden border border-gray-200 bg-[linear-gradient(180deg,#ffffff_0%,#f8f6f2_100%)]">
              <div className="flex items-center justify-between gap-3 border-b border-gray-200 bg-white px-4 py-3">
                <span className={`${montserrat.className} text-[13px] font-semibold tracking-wide`}>First CH</span>
                <span className="truncate text-[11.5px] text-gray-500">見積もり依頼の下書き</span>
              </div>
    
              <div className="px-4 py-5 @md/stage:px-6 @lg/stage:max-w-[calc(100%-18.5rem)]">
                <div className="flex flex-wrap items-center gap-2">
                  <button
                    data-save
                    type="button"
                    onClick={save}
                    className="border border-gray-300 bg-white px-3.5 py-2 text-[12.5px] font-medium text-gray-800 transition-colors hover:border-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
                  >
                    下書きを保存
                  </button>
                  <button
                    type="button"
                    onClick={send}
                    className="border border-gray-900 bg-gray-900 px-3.5 py-2 text-[12.5px] font-medium text-white transition-colors hover:border-amber-700 hover:bg-amber-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
                  >
                    送信する
                  </button>
                </div>
    
                <p className="mt-6 text-[11.5px] font-bold text-gray-500">
                  添付ファイル <span className={`${montserrat.className} font-medium tabular-nums`}>{rows.length}</span> 件
                </p>
                <ul className="mt-2 border-t border-gray-200">
                  {rows.map((row, i) => (
                    <li key={row.id} data-row={row.id} className="flex items-center justify-between gap-3 border-b border-gray-200 py-2.5">
                      <span className="min-w-0">
                        <span className="block truncate text-[13px] text-gray-800">{row.name}</span>
                        <span className={`${montserrat.className} block text-[10.5px] font-medium text-gray-400 tabular-nums`}>{row.size}</span>
                      </span>
                      <button
                        type="button"
                        onClick={() => remove(row, i)}
                        aria-label={`「${row.name}」を削除`}
                        className="flex-none border border-transparent px-2 py-1 text-[12px] text-gray-500 transition-colors hover:border-gray-300 hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
                      >
                        削除
                      </button>
                    </li>
                  ))}
                  {rows.length === 0 && <li className="border-b border-gray-200 py-3 text-[12.5px] text-gray-500">添付ファイルはありません。通知の「元に戻す」で戻せます。</li>}
                </ul>
              </div>
    
              {/* 読み上げ専用。見えている通知の一覧は live にしない(二重に読まれるのを防ぐ) */}
              <p role="status" aria-live="polite" aria-atomic="true" className="sr-only">
                {announce}
              </p>
    
              {/* 通知の積み上げ。新しいものが手前(枠の端の側)、古いものが奥 */}
              <section aria-label="通知(Alt+T で移動)" className={`absolute right-3 w-[min(17rem,calc(100%-1.5rem))] ${atTop ? "top-[3.75rem]" : "bottom-3"}`}>
                <ol
                  ref={listRef}
                  tabIndex={-1}
                  onMouseEnter={() => setHovered(true)}
                  onMouseLeave={() => setHovered(false)}
                  onFocus={() => setFocused(true)}
                  style={{ height: listH }}
                  className="relative focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
                >
                  {ordered.map((t, i) => {
                    const front = i === 0;
                    const hiddenBehind = !expanded && i >= MAX;
                    const s = expanded || t.phase === "enter" ? 1 : 1 - i * 0.05;
                    // 縮めた分だけ外側の端が寄るので、その差も足して縁を PEEK ずつ覗かせる
                    const dir = atTop ? 1 : -1;
                    const y = dir * (t.phase === "enter" ? -24 : expanded ? offsets[i] : i * PEEK + frontH * (1 - s));
                    const x = t.phase === "leave" ? 28 : 0;
                    const opacity = t.phase === "enter" || t.phase === "leave" || hiddenBehind ? 0 : 1;
                    return (
                      <li
                        key={t.id}
                        ref={(el) => {
                          if (el) items.current.set(t.id, el);
                          else items.current.delete(t.id);
                        }}
                        tabIndex={-1}
                        aria-label={t.title}
                        onKeyDown={(e) => onToastKey(e, t.id)}
                        inert={t.phase === "leave"}
                        style={{
                          transform: `translate(${x}px, ${y}px) scale(${s})`,
                          opacity,
                          zIndex: 10 - i,
                          height: expanded || front ? undefined : frontH,
                        }}
                        className={`absolute inset-x-0 overflow-hidden ${atTop ? "top-0 origin-top" : "bottom-0 origin-bottom"} border bg-white shadow-[0_14px_32px_-16px_rgba(28,25,23,0.35)] transition-[transform,opacity] duration-[220ms] ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none ${
                          t.kind === "delete" ? "border-gray-300 border-l-amber-600 border-l-2" : "border-gray-300"
                        }`}
                      >
                        <div
                          ref={(el) => {
                            const ro = observer.current;
                            if (!el) return;
                            contents.current.set(el, t.id);
                            ro?.observe(el);
                            return () => {
                              contents.current.delete(el);
                              observer.current?.unobserve(el);
                            };
                          }}
                          className={`transition-opacity duration-150 motion-reduce:transition-none ${expanded || front ? "opacity-100" : "opacity-0"}`}
                        >
                          <div className="flex items-start gap-2 pt-3 pr-1.5 pl-3.5">
                            <div className="min-w-0 flex-1">
                              <p className="text-[13px] leading-[1.6] font-bold text-gray-900">{t.title}</p>
                              <p className="mt-0.5 text-[11.5px] leading-[1.6] text-gray-500">{t.detail}</p>
                            </div>
                            <button
                              type="button"
                              onClick={() => dismiss(t.id)}
                              className="grid size-7 flex-none place-items-center text-[15px] leading-none text-gray-400 transition-colors hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
                            >
                              <span className="sr-only">通知を閉じる</span>
                              <span aria-hidden="true">×</span>
                            </button>
                          </div>
                          <div className="flex min-h-9 items-center gap-2 pt-1 pr-3 pb-2.5 pl-3.5">
                            <div aria-hidden="true" className="h-px flex-1 bg-gray-100">
                              <div
                                ref={(el) => {
                                  if (el) bars.current.set(t.id, el);
                                  else bars.current.delete(t.id);
                                }}
                                className="h-px origin-left bg-amber-600 motion-reduce:hidden"
                              />
                            </div>
                            <span
                              aria-hidden="true"
                              ref={(el) => {
                                if (el) secs.current.set(t.id, el);
                                else secs.current.delete(t.id);
                              }}
                              className={`${montserrat.className} flex-none text-[10px] font-medium text-gray-400 tabular-nums`}
                            />
                            {t.undo && (
                              <button
                                type="button"
                                onClick={() => undo(t)}
                                className="flex-none border border-amber-600 px-2.5 py-1 text-[12px] font-bold whitespace-nowrap text-amber-800 transition-colors hover:bg-amber-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
                              >
                                元に戻す
                              </button>
                            )}
                          </div>
                        </div>
                      </li>
                    );
                  })}
                </ol>
              </section>
            </div>
    
            {/* 通知のきまり(実装の挙動をそのまま文章にしたもの) */}
            <div className="min-w-0">
              <p className="text-[12px] font-bold tracking-widest text-gray-500">通知のきまり</p>
              <ol className="mt-3 border-t border-gray-200">
                {[
                  ["新しいものが手前", "続けて届いた通知は重ねて置き、奥のものは縁だけ見せます。載せると広がります。"],
                  ["同時に出すのは3件まで", "4件目が来たら一番古いものから消します。残り時間も古い順に尽きます。"],
                  ["読んでいる間は止める", "ポインタが載っている間と、中にフォーカスがある間は時計が進みません。"],
                  ["削除だけ8秒と取り消し", "元に戻すと、その通知が「戻しました」に変わり、行は元の位置へ戻ります。"],
                ].map(([term, body], i) => (
                  <li key={term} className="grid grid-cols-[1.5rem_minmax(0,1fr)] gap-x-2 border-b border-gray-200 py-3">
                    <span className={`${montserrat.className} text-[12px] font-semibold text-amber-700 tabular-nums`}>{i + 1}</span>
                    <span>
                      <span className="block text-[13px] leading-[1.6] font-bold">{term}</span>
                      <span className="mt-1 block text-[12px] leading-[1.75] text-gray-500">{body}</span>
                    </span>
                  </li>
                ))}
              </ol>
              <dl className="mt-5 grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-2 text-[12px] text-gray-600">
                {[
                  ["Alt+T", "最新の通知へ移る(Mac は Option+T)"],
                  ["Tab", "通知の中のボタンを順に選ぶ"],
                  ["Enter", "元に戻す・閉じるを押す"],
                  ["Esc", "フォーカス中の通知を閉じる"],
                ].map(([k, v]) => (
                  <div key={k} className="contents">
                    <dt>
                      <kbd className={`${montserrat.className} inline-block min-w-[2.9rem] border border-gray-200 bg-gray-50 px-1.5 py-px text-center text-[10.5px] font-medium text-gray-700`}>
                        {k}
                      </kbd>
                    </dt>
                    <dd>{v}</dd>
                  </div>
                ))}
              </dl>
            </div>
          </div>
        </section>
      );
    }
    

    shadcn CLI でプロジェクトに追加

    npx shadcn@latest add https://designs.first-ch.com/r/toast-undo-notice.json