/**
* エラー内容を選ぶ窓の絞り込み(純粋関数。指示書BE §4-1・§8)。
* キーワードと分類は同時に効く(AND)。文字列は `t()` に通さない。
*/
import type { Lang } from "@/lib/i18n";
import type { ErrorListItem } from "@/lib/types";
export interface ErrorListCategory {
/** 大分類の日英を組にした識別子(表示には使わない) */
key: string;
ja: string;
en: string;
}
/** `<option value>` に載せるので、分類名に出ない区切りにする(NUL は値から落ちることがある) */
const CATEGORY_KEY_SEP = "\u241e";
export function errorListCategoryKey(item: ErrorListItem): string {
return `${item.error_category_main_ja}${CATEGORY_KEY_SEP}${item.error_category_main_en}`;
}
/** 大分類を id 順に、初めて出た順で並べる */
export function errorListCategories(items: readonly ErrorListItem[]): ErrorListCategory[] {
const seen = new Map<string, ErrorListCategory>();
const sorted = [...items].sort((a, b) => a.id - b.id);
for (const item of sorted) {
const key = errorListCategoryKey(item);
if (!seen.has(key)) {
seen.set(key, {
key,
ja: item.error_category_main_ja,
en: item.error_category_main_en,
});
}
}
return [...seen.values()];
}
/** ひらがな(ぁ U+3041 〜 ゖ U+3096)→ カタカナ(ァ U+30A1 〜 ヶ U+30F6)。差は 0x60 */
const HIRAGANA_FIRST = 0x3041;
const HIRAGANA_LAST = 0x3096;
const HIRAGANA_TO_KATAKANA = 0x60;
/**
* キーワードと詳細の両側に掛ける正規化。
* 1. NFKC(全角英数 → 半角、半角カナ → 全角カナ(濁点も1文字に)、全角スペース → 半角スペース)
* 2. 小文字にする
* 3. ひらがな → カタカナ(`ばってりー` で `バッテリー` に当てるため)
* ★記号・空白は除かない★(AND 検索にもしない。空白は1つの文字として部分一致に入る)。
*/
export function normalizeErrorListKeyword(text: string): string {
let out = "";
for (const ch of text.normalize("NFKC").toLowerCase()) {
const code = ch.codePointAt(0)!;
out +=
code >= HIRAGANA_FIRST && code <= HIRAGANA_LAST
? String.fromCodePoint(code + HIRAGANA_TO_KATAKANA)
: ch;
}
return out;
}
/**
* ★キーワードはエラー内容の詳細だけに当てる★(`error_detail_ja` と `error_detail_en` の両方。★画面の言語によらない★)。
* ★小分類・大分類の名前には当てない★(分類は分類の選択で絞る)。
* 部分一致。比べる前に両側を `normalizeErrorListKeyword()` に通す(全角・半角、大文字・小文字、ひらがな・カタカナを区別しない)。
* キーワードの前後の空白(全角スペースを含む)は除く。空ならキーワードでは絞らない。
* ★分類とは AND★ — `categoryKey` が null でなければ、その大分類の外の行はキーワードに当たっても返さない
* (`categoryKey === null` はすべての分類)。並びは渡された順(API の id 順)のまま。
* `lang` は今は絞り込みに使わない(呼び出し側の形を変えないために受け取る)。
*/
export function filterErrorLists(
items: readonly ErrorListItem[],
input: { lang: Lang; keyword: string; categoryKey: string | null }
): ErrorListItem[] {
const keyword = normalizeErrorListKeyword(input.keyword).trim();
return items.filter((item) => {
if (input.categoryKey !== null && errorListCategoryKey(item) !== input.categoryKey) {
return false;
}
if (keyword === "") return true;
return [item.error_detail_ja, item.error_detail_en].some((text) =>
normalizeErrorListKeyword(text).includes(keyword)
);
});
}
export interface ErrorListGroup {
heading: string;
items: ErrorListItem[];
}
/** 見出しは「大分類 / 小分類」。同じ見出しが続いているあいだは1つの塊にする(id 順のまま) */
export function groupErrorLists(lang: Lang, items: readonly ErrorListItem[]): ErrorListGroup[] {
const groups: ErrorListGroup[] = [];
for (const item of items) {
const main = lang === "ja" ? item.error_category_main_ja : item.error_category_main_en;
const sub = lang === "ja" ? item.error_category_sub_ja : item.error_category_sub_en;
const heading = `${main} / ${sub}`;
const last = groups[groups.length - 1];
if (last && last.heading === heading) last.items.push(item);
else groups.push({ heading, items: [item] });
}
return groups;
}
/**
* 背景のクリックで閉じてよいか(指示書BE §4-1)。
* ★押した(mousedown)のも離した(click)のも背景そのもののときだけ★閉じる。
* キーワード入力の中で文字を選び、背景の上で離したときに閉じないため。
*/
export function shouldCloseOnBackdrop(downOnOverlay: boolean, clickOnOverlay: boolean): boolean {
return downOnOverlay && clickOnOverlay;
}
/**
* Tab でフォーカスを窓の中に閉じ込める(指示書BE §4-1)。
* `count` は押した時点のフォーカスできる要素の数(絞り込みで変わるので毎回数える)、
* `activeIndex` は今のフォーカスの位置(窓の外なら -1)。
* 戻り値はフォーカスを移す位置。null はブラウザの既定の移動に任せる。
*/
export function trapFocusIndex(count: number, activeIndex: number, backward: boolean): number | null {
if (count <= 0) return null;
const last = count - 1;
if (backward) return activeIndex <= 0 || activeIndex > last ? last : null;
return activeIndex < 0 || activeIndex >= last ? 0 : null;
}