(function($) { "use strict"; var DEFAULT_DATA_URL = "/personal/faq/faq-search-data.json"; var MAX_RESULTS = 20; /* 入力中に出す候補(サジェスト)の設定 */ var SUGGEST_MAX = 6; var SUGGEST_DELAY = 200; var SUGGEST_LIST_ID = "faqSearchSuggestList"; var SUGGEST_ITEM_ID = "faqSearchSuggestItem"; function toHalfWidthAscii(value) { return value .replace(/[!-~]/g, function(character) { return String.fromCharCode(character.charCodeAt(0) - 0xFEE0); }) .replace(/ /g, " "); } function normalizeText(value) { var text = value === null || typeof value === "undefined" ? "" : String(value); if (text.normalize) { try { text = text.normalize("NFKC"); } catch (error) { text = toHalfWidthAscii(text); } } else { text = toHalfWidthAscii(text); } return $.trim(text.toLowerCase().replace(/\s+/g, " ")); } function createSearchRecord(item) { var record = { item: item, title: normalizeText(item.title), keywords: normalizeText(item.keywords), category: normalizeText(item.category), tags: normalizeText(item.tags), answer: normalizeText(item.answer) }; record.all = [record.title, record.keywords, record.category, record.tags, record.answer].join(" "); return record; } function scoreRecord(record, query, tokens) { var score = 0; var i; var token; for (i = 0; i < tokens.length; i += 1) { token = tokens[i]; if (record.all.indexOf(token) === -1) { return -1; } if (record.title === token) { score += 120; } else if (record.title.indexOf(token) === 0) { score += 90; } else if (record.title.indexOf(token) !== -1) { score += 70; } if (record.keywords.indexOf(token) !== -1) { score += 60; } if (record.category.indexOf(token) !== -1) { score += 35; } if (record.tags.indexOf(token) !== -1) { score += 25; } if (record.answer.indexOf(token) !== -1) { score += 10; } } if (record.title === query) { score += 200; } else if (record.title.indexOf(query) !== -1) { score += 80; } return score; } function getSortOrder(item) { var value = $.trim(item.sortOrder || ""); return value !== "" && !isNaN(Number(value)) ? Number(value) : null; } function compareMatches(a, b) { var aOrder; var bOrder; if (a.score !== b.score) { return b.score - a.score; } aOrder = getSortOrder(a.record.item); bOrder = getSortOrder(b.record.item); if (aOrder !== null && bOrder !== null && aOrder !== bOrder) { return aOrder - bOrder; } if ((aOrder === null) !== (bOrder === null)) { return aOrder === null ? 1 : -1; } return Number(b.record.item.published || 0) - Number(a.record.item.published || 0); } /* 検索結果一覧とサジェストで同じ絞り込みを使う */ function findMatches(records, rawQuery) { var query = normalizeText(rawQuery); var matches = []; var tokens; if (!query) { return matches; } tokens = query.split(" "); $.each(records || [], function(index, record) { var score = scoreRecord(record, query, tokens); if (score >= 0) { matches.push({ record: record, score: score }); } }); matches.sort(compareMatches); return matches; } function getLocationQuery() { var match = window.location.search.match(/[?&]q=([^&]*)/); if (!match) { return ""; } try { return decodeURIComponent(match[1].replace(/\+/g, " ")); } catch (error) { return match[1].replace(/\+/g, " "); } } function updateLocationQuery(query, replace) { var url; if (!window.history || !window.history.pushState) { return; } url = window.location.pathname + (query ? "?q=" + encodeURIComponent(query) : ""); window.history[replace ? "replaceState" : "pushState"]({ faqQuery: query }, "", url); } function initFaqSearch() { var $form = $(".js-faq-search-form").first(); var $input = $form.find(".js-faq-search-input").first(); var $submit = $form.find(".js-faq-search-submit").first(); var $inputWrap = $input.closest(".p-faqTop__searchInputWrap"); var $results = $(".js-faq-search-results").first(); var $status = $results.find(".js-faq-search-status").first(); var $list = $results.find(".js-faq-search-list").first(); var $message = $results.find(".js-faq-search-message").first(); var dataUrl = $.trim($form.attr("data-search-data") || "") || DEFAULT_DATA_URL; var records = null; var loading = false; var dataFailed = false; var readyCallbacks = []; var initialQuery; /* サジェスト側の状態 */ var $suggest = null; var $suggestLive = null; var suggestMatches = []; var suggestIndex = -1; var suggestTimer = null; var composing = false; if (!$form.length || !$results.length) { return; } function setBusy(busy) { $submit.prop("disabled", busy).toggleClass("is-loading", busy); $submit.text(busy ? "読込中" : "検索する"); } function showState(status, message, error) { $results.removeAttr("hidden"); $status.text(status || "").toggleClass("is-error", !!error); $list.empty().attr("hidden", "hidden"); if (message) { $message.text(message).removeAttr("hidden").toggleClass("is-error", !!error); } else { $message.text("").attr("hidden", "hidden").removeClass("is-error"); } } /* 検索データの読み込みを1本にまとめる。サジェストと検索ボタンの両方から 呼ばれるので、読み込み中に来た依頼はキューへ積んで完了時にまとめて流す。 */ function flushReady(success) { var callbacks = readyCallbacks; readyCallbacks = []; $.each(callbacks, function(index, callback) { callback(success); }); } function ensureData(onReady) { if (records !== null) { if (onReady) { onReady(true); } return; } /* 一度失敗したらサジェストからは再試行しない(検索ボタン側でリセットする) */ if (dataFailed) { if (onReady) { onReady(false); } return; } if (onReady) { readyCallbacks.push(onReady); } if (loading) { return; } loading = true; $.ajax({ url: dataUrl, dataType: "json", cache: false }).done(function(data) { var items = $.isArray(data) ? data : []; records = $.map(items, function(item) { return createSearchRecord(item || {}); }); loading = false; flushReady(true); }).fail(function() { loading = false; dataFailed = true; flushReady(false); }); } function appendResult(match) { var item = match.record.item; var $resultItem = $("
  • ").addClass("p-faqTop__searchResultItem"); var $link = $("").addClass("p-faqTop__searchResultLink").attr("href", item.url || "#"); var $mark = $("").addClass("p-faqTop__searchResultMark").attr("aria-hidden", "true").text("Q"); var $body = $("").addClass("p-faqTop__searchResultBody"); var $title = $("").addClass("p-faqTop__searchResultTitle").text(item.title || ""); $body.append($title); if (item.category) { $body.append($("").addClass("p-faqTop__searchResultCategory").text(item.category)); } $link.append($mark).append($body).append($("").addClass("p-faqTop__searchResultArrow").attr("aria-hidden", "true")); $list.append($resultItem.append($link)); } function renderSearch(rawQuery) { var matches; var visibleMatches; var statusText; if (!normalizeText(rawQuery)) { showState("", "キーワードを入力してください。", false); $input.focus(); return; } matches = findMatches(records, rawQuery); visibleMatches = matches.slice(0, MAX_RESULTS); $list.empty(); $message.attr("hidden", "hidden").removeClass("is-error"); $results.removeAttr("hidden"); $status.removeClass("is-error"); if (!matches.length) { $status.text("「" + $.trim(rawQuery) + "」の検索結果"); $message.text("該当するよくあるご質問はありません。").removeAttr("hidden"); $list.attr("hidden", "hidden"); return; } statusText = "「" + $.trim(rawQuery) + "」で" + matches.length + "件見つかりました。"; if (matches.length > MAX_RESULTS) { statusText += "関連度の高い" + MAX_RESULTS + "件を表示しています。"; } $status.text(statusText); $list.removeAttr("hidden"); $.each(visibleMatches, function(index, match) { appendResult(match); }); } function loadDataAndSearch(rawQuery) { if (records !== null) { renderSearch(rawQuery); return; } /* サジェストで一度失敗していても、検索ボタンからならもう一度取りに行く */ dataFailed = false; setBusy(true); showState("検索データを読み込んでいます…", "", false); ensureData(function(success) { setBusy(false); if (success) { renderSearch(rawQuery); } else { showState("検索データを読み込めませんでした。", "時間をおいて、もう一度お試しください。", true); } }); } /*===================================================== 入力中のサジェスト JSが動いたときだけ入力欄をコンボボックスとして扱いたいので、 DOMとaria属性はここで組み立てる(テンプレート側は変更なし)。 =====================================================*/ function buildSuggest() { $suggest = $("
      ") .addClass("p-faqTop__searchSuggestList js-faq-suggest-list") .attr({ id: SUGGEST_LIST_ID, role: "listbox", "aria-label": "キーワードの候補" }) .attr("hidden", "hidden"); /* 候補件数の読み上げ用。画面には出さない */ $suggestLive = $("

      ") .addClass("p-faqTop__searchSuggestLive") .attr({ role: "status", "aria-live": "polite" }); $inputWrap.append($suggest).append($suggestLive); $input.attr({ role: "combobox", "aria-expanded": "false", "aria-autocomplete": "list", "aria-controls": SUGGEST_LIST_ID }); } function closeSuggest() { if (!$suggest) { return; } $suggest.attr("hidden", "hidden").empty(); $input.attr("aria-expanded", "false").removeAttr("aria-activedescendant"); $suggestLive.text(""); suggestMatches = []; suggestIndex = -1; } /* 選んだ候補がリストからはみ出していたらスクロールして見せる */ function scrollSuggestIntoView($option) { var listTop = $suggest.scrollTop(); var listHeight = $suggest.innerHeight(); var optionTop = $option.position().top + listTop; var optionHeight = $option.outerHeight(); if (optionTop < listTop) { $suggest.scrollTop(optionTop); } else if (optionTop + optionHeight > listTop + listHeight) { $suggest.scrollTop(optionTop + optionHeight - listHeight); } } function setSuggestActive(index) { var $options = $suggest.children(); var $active; $options.removeClass("is-active").attr("aria-selected", "false"); suggestIndex = index; if (index < 0) { $input.removeAttr("aria-activedescendant"); return; } $active = $options.eq(index).addClass("is-active").attr("aria-selected", "true"); $input.attr("aria-activedescendant", $active.attr("id")); scrollSuggestIntoView($active); } function createSuggestOption(match, index) { var item = match.record.item; var $option = $("

    • ") .addClass("p-faqTop__searchSuggestItem") .attr({ id: SUGGEST_ITEM_ID + index, role: "option", "aria-selected": "false" }); $option.append($("").addClass("p-faqTop__searchSuggestTitle").text(item.title || "")); if (item.category) { $option.append($("").addClass("p-faqTop__searchSuggestCategory").text(item.category)); } return $option; } function renderSuggest(rawQuery) { var matches = findMatches(records, rawQuery); var visibleMatches = matches.slice(0, SUGGEST_MAX); if (!visibleMatches.length) { closeSuggest(); return; } $suggest.empty(); $.each(visibleMatches, function(index, match) { $suggest.append(createSuggestOption(match, index)); }); suggestMatches = visibleMatches; suggestIndex = -1; $suggest.scrollTop(0).removeAttr("hidden"); $input.attr("aria-expanded", "true").removeAttr("aria-activedescendant"); $suggestLive.text(matches.length + "件の候補があります。"); } function requestSuggest() { var rawQuery = $.trim($input.val()); if (!normalizeText(rawQuery)) { closeSuggest(); return; } ensureData(function(success) { /* 読み込みを待つ間に入力が変わっていたら、その結果は捨てる */ if (!success || $.trim($input.val()) !== rawQuery) { return; } renderSuggest(rawQuery); }); } function scheduleSuggest() { if (suggestTimer) { window.clearTimeout(suggestTimer); } suggestTimer = window.setTimeout(function() { suggestTimer = null; requestSuggest(); }, SUGGEST_DELAY); } function cancelSuggest() { if (suggestTimer) { window.clearTimeout(suggestTimer); suggestTimer = null; } closeSuggest(); } function goToSuggest(index) { var match = suggestMatches[index]; var url = match && match.record.item.url; if (!url) { return false; } cancelSuggest(); window.location.href = url; return true; } function bindSuggest() { /* 日本語入力の変換中は1文字ごとにinputが飛ぶので、確定するまで待つ。 変換確定のEnterはkeydownで229が来るため、そこでも弾いておく。 */ $input.on("compositionstart", function() { composing = true; }); $input.on("compositionend", function() { composing = false; scheduleSuggest(); }); $input.on("input search", function() { if (composing) { return; } scheduleSuggest(); }); /* inputが使えない古いブラウザ向けの保険。二重に走ってもdebounceで吸収される */ $input.on("keyup", function(event) { var key = event.which || event.keyCode; if (composing || key === 229) { return; } if (key === 13 || key === 27 || key === 9 || key === 38 || key === 40) { return; } scheduleSuggest(); }); $input.on("keydown", function(event) { var key = event.which || event.keyCode; var isOpen = suggestMatches.length > 0; var original = event.originalEvent; if (composing || key === 229 || (original && original.isComposing)) { return; } if (key === 40) { /* ↓:閉じているときは候補を出しにいく */ if (!isOpen) { requestSuggest(); return; } event.preventDefault(); setSuggestActive(suggestIndex + 1 >= suggestMatches.length ? -1 : suggestIndex + 1); } else if (key === 38) { if (!isOpen) { return; } event.preventDefault(); setSuggestActive(suggestIndex - 1 < -1 ? suggestMatches.length - 1 : suggestIndex - 1); } else if (key === 13) { /* 候補を選んでいるときだけ記事へ。選んでいなければ通常の検索 */ if (isOpen && suggestIndex >= 0) { event.preventDefault(); goToSuggest(suggestIndex); } } else if (key === 27) { if (isOpen) { event.preventDefault(); cancelSuggest(); } } else if (key === 9) { cancelSuggest(); } }); /* mousedownで既定動作を止め、クリックが決まる前に閉じないようにする */ $suggest.on("mousedown", ".p-faqTop__searchSuggestItem", function(event) { event.preventDefault(); }); $suggest.on("click", ".p-faqTop__searchSuggestItem", function() { goToSuggest($(this).index()); }); $suggest.on("mouseenter", ".p-faqTop__searchSuggestItem", function() { setSuggestActive($(this).index()); }); /* 入力欄にフォーカスが来た時点で検索データを先読みしておく */ $input.on("focus", function() { ensureData(null); }); $(document).on("click", function(event) { if (!$(event.target).closest($inputWrap).length) { cancelSuggest(); } }); } if ($input.length && $inputWrap.length) { buildSuggest(); bindSuggest(); } $form.on("submit", function(event) { var rawQuery = $.trim($input.val()); event.preventDefault(); cancelSuggest(); if (!normalizeText(rawQuery)) { updateLocationQuery("", false); showState("", "キーワードを入力してください。", false); $input.focus(); return; } updateLocationQuery(rawQuery, false); loadDataAndSearch(rawQuery); }); $(window).on("popstate", function() { var query = getLocationQuery(); cancelSuggest(); $input.val(query); if (query) { loadDataAndSearch(query); } else { $results.attr("hidden", "hidden"); } }); initialQuery = getLocationQuery(); if (initialQuery) { $input.val(initialQuery); updateLocationQuery(initialQuery, true); loadDataAndSearch(initialQuery); } } $(initFaqSearch); })(jQuery);