"use client";
import { useEffect, useId, useRef, useState, type RefObject } from "react";
import { useTheme } from "@/components/theme/ThemeProvider";
import {
errorListCategories,
filterErrorLists,
groupErrorLists,
shouldCloseOnBackdrop,
trapFocusIndex,
} from "@/lib/errors/error-content-picker";
import type { Lang } from "@/lib/i18n";
import type { ErrorListItem } from "@/lib/types";
import styles from "./ErrorContentPicker.module.css";
interface ErrorContentPickerProps {
items: readonly ErrorListItem[];
lang: Lang;
/** 今の値(下書き優先)。未選択は null */
currentId: number | null;
/** 閉じたときにフォーカスを戻すボタン */
restoreFocusRef: RefObject<HTMLButtonElement | null>;
onPick: (id: number) => void;
onClose: () => void;
}
/** Tab の閉じ込めに使う、窓の中のフォーカスできる要素 */
const FOCUSABLE_SELECTOR = [
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"a[href]",
'[tabindex]:not([tabindex="-1"])',
].join(", ");
/**
* エラー一覧でエラー内容を選ぶ窓(この画面専用。指示書BE §4-1。D-185)。
* 共通部品にはしない。分類名・詳細は `t()` に通さない。
*/
export function ErrorContentPicker({
items,
lang,
currentId,
restoreFocusRef,
onPick,
onClose,
}: ErrorContentPickerProps) {
const { t } = useTheme();
const titleId = useId();
const keywordRef = useRef<HTMLInputElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
/** mousedown が背景そのものに起きたか(窓の中で押して背景で離したときに閉じないため) */
const downOnOverlayRef = useRef(false);
const [keyword, setKeyword] = useState("");
const [categoryKey, setCategoryKey] = useState<string | null>(null);
useEffect(() => {
keywordRef.current?.focus();
const opener = restoreFocusRef.current;
return () => {
opener?.focus();
};
}, [restoreFocusRef]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
return;
}
if (e.key !== "Tab") return;
const panel = panelRef.current;
if (!panel) return;
// 絞り込みで並びが変わるので、押すたびに数える
const focusables = Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
const active = document.activeElement;
const activeIndex = active instanceof HTMLElement ? focusables.indexOf(active) : -1;
const next = trapFocusIndex(focusables.length, activeIndex, e.shiftKey);
if (focusables.length === 0) {
e.preventDefault();
return;
}
if (next !== null) {
e.preventDefault();
focusables[next]?.focus();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onClose]);
const categories = errorListCategories(items);
const filtered = filterErrorLists(items, { lang, keyword, categoryKey });
const groups = groupErrorLists(lang, filtered);
const countLabel = t("{n}件 / {total}件")
.replace("{n}", String(filtered.length))
.replace("{total}", String(items.length));
return (
<div
className={styles.overlay}
onMouseDown={(e) => {
downOnOverlayRef.current = e.target === e.currentTarget;
}}
onClick={(e) => {
const downOnOverlay = downOnOverlayRef.current;
downOnOverlayRef.current = false;
if (shouldCloseOnBackdrop(downOnOverlay, e.target === e.currentTarget)) onClose();
}}
>
<div
ref={panelRef}
className={styles.panel}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
onClick={(e) => e.stopPropagation()}
>
<div className={styles.header}>
<h2 id={titleId} className={styles.title}>
{t("エラー内容を選ぶ")}
</h2>
<button type="button" className={styles.close} onClick={onClose}>
{t("閉じる")}
</button>
</div>
<div className={styles.filters}>
<select
className={styles.category}
aria-label={t("分類")}
value={categoryKey ?? ""}
onChange={(e) => setCategoryKey(e.target.value === "" ? null : e.target.value)}
>
<option value="">{t("すべての分類")}</option>
{categories.map((category) => (
<option key={category.key} value={category.key}>
{lang === "ja" ? category.ja : category.en}
</option>
))}
</select>
<input
ref={keywordRef}
className={styles.keyword}
aria-label={t("キーワード")}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
<span className={styles.count}>{countLabel}</span>
<button
type="button"
className={styles.clear}
onClick={() => {
setKeyword("");
setCategoryKey(null);
}}
>
{t("クリア")}
</button>
</div>
<div className={styles.list}>
{filtered.length === 0 ? (
<div className={styles.empty}>{t("該当するエラー内容がありません")}</div>
) : (
groups.map((group, index) => (
<div key={`${group.heading}\u241e${index}`} className={styles.group}>
<div className={styles.heading}>{group.heading}</div>
{group.items.map((item) => {
const detail = lang === "ja" ? item.error_detail_ja : item.error_detail_en;
const current = item.id === currentId;
return (
<button
key={item.id}
type="button"
className={current ? `${styles.item} ${styles.itemCurrent}` : styles.item}
aria-current={current ? "true" : undefined}
title={detail}
onClick={() => onPick(item.id)}
>
<span className={styles.itemText}>{detail}</span>
{item.note_required && <span className={styles.chip}>{t("備考必須")}</span>}
</button>
);
})}
</div>
))
)}
</div>
</div>
</div>
);
}