Newer
Older
client_dwatch / lib / errors / error-content-picker.ts
/**
 * エラー内容を選ぶ窓の絞り込み(純粋関数。指示書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;
}