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新しいものが手前続けて届いた通知は重ねて置き、奥のものは縁だけ見せます。載せると広がります。
- 2同時に出すのは3件まで4件目が来たら一番古いものから消します。残り時間も古い順に尽きます。
- 3読んでいる間は止めるポインタが載っている間と、中にフォーカスがある間は時計が進みません。
- 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