デモ
デモを読み込めませんでした。
仕様
- 用途
- フォーム
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
打つたびに、その文字を読みに含む候補だけを残し、「一致が読みのどこから始まるか」で並べ替えます。候補は市区町村の名前と読みのかなを持ち、読みで引きます。読みの頭から一致する候補が上へ、途中で一致する候補が下へ回るので、打ち進めるほど目当てのものが上がってきます。「ひ」では上にいた東広島市が、「ひろ」で帯広市の下へ回るように、合わない候補を消して詰めるだけでなく、残った候補の順番そのものが入れ替わるのが、この作りの要です。
行は絶対位置で置いてあり、並びが変わったら前の位置から新しい位置へ滑らせます。移すのは0.3秒の power3.inOut、外れる行は0.25秒で0.92倍まで縮めながら薄くします。新しく出てくる行は10px上から、上の行から0.035秒ずつずらして降ります。パネルの高さも行数から出して0.35秒で動かすので、行の移動と枠の伸び縮みが1つの動きに見えます。一致した部分は、読みの中で明暗を反転した札にします。
高さを動かすのは、余白を持たない外側の箱にします。余白のある ul そのものを0まで縮めても、余白の分の帯が残ります。日本語の入力では、変換を確定するEnterにも keydown が届くので、isComposing を見て受け流します(Safariでは確定のときに false になるので、keyCodeの229も見ます)。読みはひらがなで持ち、カタカナで打たれてもひらがなにそろえて引きます。変換して漢字で打ったときは名前のほうで引き、名前に札を付けます。
欄は role="combobox"、候補は listbox にして、焦点は欄に置いたまま、選んでいる行を aria-activedescendant で伝えます。↑↓で移り、Enterで決め、Escで閉じます。候補の件数は見えない領域に書いて読み上げに届け、閉じている候補は hidden にして、読み上げとTabの行き先から外します。
一致の判定は部分一致です。滑る時間とずらしの幅がつまみで、ずらしを広げると1行ずつ並んでいく感じになります。動きを減らす設定では、ほぼ一瞬で切り替えます。住所の市区町村や駅名の補完、タグや商品の検索に向いています。
コード
<!-- 打った文字で候補を絞る入力補完。候補は名前と読みを持ち、並び順は「一致がどこから始まるか」で決める -->
<form class="ship" action="/checkout/" method="post">
<div class="combo">
<label class="combo__label" for="city">市区町村</label>
<div class="combo__field">
<!-- 焦点は欄に置いたまま、選んでいる候補は aria-activedescendant で伝える -->
<input class="combo__input" id="city" name="city" type="text" role="combobox" autocomplete="off"
aria-expanded="false" aria-controls="city-list" aria-autocomplete="list" placeholder="市区町村名を入力">
<button class="combo__clear" type="button" hidden>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" /></svg>
<span class="combo__sr">入力を消す</span>
</button>
</div>
<!-- 高さを伸ばすのは余白を持たない外側の箱(余白のある ul を 0 にしても、余白の帯が残る) -->
<div class="combo__panel">
<ul class="combo__list" id="city-list" role="listbox" aria-label="市区町村の候補" hidden>
<li class="combo__row" id="c-hiroshima" role="option"><span class="combo__name">広島市</span><span class="combo__kana">ひろしま</span><span class="combo__area">広島県</span></li>
<li class="combo__row" id="c-higashihiroshima" role="option"><span class="combo__name">東広島市</span><span class="combo__kana">ひがしひろしま</span><span class="combo__area">広島県</span></li>
<li class="combo__row" id="c-hirosaki" role="option"><span class="combo__name">弘前市</span><span class="combo__kana">ひろさき</span><span class="combo__area">青森県</span></li>
<li class="combo__row" id="c-himeji" role="option"><span class="combo__name">姫路市</span><span class="combo__kana">ひめじ</span><span class="combo__area">兵庫県</span></li>
<li class="combo__row" id="c-hitachi" role="option"><span class="combo__name">日立市</span><span class="combo__kana">ひたち</span><span class="combo__area">茨城県</span></li>
<li class="combo__row" id="c-hikone" role="option"><span class="combo__name">彦根市</span><span class="combo__kana">ひこね</span><span class="combo__area">滋賀県</span></li>
<li class="combo__row" id="c-obihiro" role="option"><span class="combo__name">帯広市</span><span class="combo__kana">おびひろ</span><span class="combo__area">北海道</span></li>
<li class="combo__row" id="c-asahikawa" role="option"><span class="combo__name">旭川市</span><span class="combo__kana">あさひかわ</span><span class="combo__area">北海道</span></li>
</ul>
</div>
<p class="combo__count" role="status"></p>
</div>
</form>
/* 暗い地の入力補完。候補の行は絶対位置で置き、並びが変わったら位置だけを動かす */
.ship {
--ground: #111111; /* フォームの地 */
--ink: #eeece6; /* 文字と焦点の枠 */
--label: #a8a6a0; /* 欄の名前・地域名(16px 未満の補足) */
--surface: #262626; /* 候補のパネル */
--line: rgba(238, 236, 230, 0.14); /* 休みの枠とパネルの縁 */
--active: rgba(238, 236, 230, 0.07); /* 選んでいる行の地 */
padding: 32px;
background: var(--ground);
color: var(--ink);
}
.combo { position: relative; max-width: 544px; }
.combo__label { display: block; margin-bottom: 8px; color: var(--label); font-size: 13px; font-weight: 600; letter-spacing: 0.06em; }
.combo__field { position: relative; }
.combo__input {
box-sizing: border-box; width: 100%; height: 56px; padding: 0 56px 0 16px;
border: 0; border-radius: 4px; box-shadow: inset 0 0 0 1px var(--line);
background: none; color: var(--ink); font: inherit; font-size: 18px; letter-spacing: 0.04em;
}
.combo__input::placeholder { color: #898989; }
.combo__input:focus { outline: none; box-shadow: inset 0 0 0 1px var(--ink); }
.combo__clear {
position: absolute; right: 12px; top: 50%; display: grid; place-items: center;
width: 32px; height: 32px; margin-top: -16px; padding: 0;
border: 0; border-radius: 50%; box-shadow: inset 0 0 0 1px var(--line);
background: none; color: inherit; cursor: pointer;
}
.combo__clear[hidden] { display: none; }
.combo__clear svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
.combo__sr, .combo__count { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
/* 高さを動かすのは、余白を持たないこの箱 */
.combo__panel { height: 0; margin-top: 8px; overflow: hidden; }
.combo__list {
position: relative; box-sizing: border-box; height: 100%; margin: 0; padding: 8px;
border-radius: 4px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); list-style: none;
}
.combo__row {
position: absolute; left: 8px; right: 8px; top: 0;
display: flex; align-items: center; justify-content: space-between; gap: 16px;
height: 40px; padding: 0 16px; border-radius: 4px;
transform-origin: left center; cursor: pointer;
}
.combo__row[aria-selected="true"] { background: var(--active); }
.combo__name { flex: none; min-width: 5em; font-size: 16px; font-weight: 500; letter-spacing: 0.04em; white-space: nowrap; } /* 読みの列がそろうよう、5 字ぶん取る */
.combo__kana { margin-right: auto; color: var(--label); font-size: 14px; letter-spacing: 0.04em; white-space: nowrap; }
.combo__area { color: var(--label); font-size: 13px; letter-spacing: 0.04em; white-space: nowrap; }
/* 一致した部分は明暗を反転した札にする。左右の負の余白で、札が付いても文字がずれないようにする */
.combo__row mark { margin: 0 -2px; padding: 0 2px; border-radius: 2px; background: rgba(238, 236, 230, 0.92); color: var(--ground); }
@media (max-width: 480px) {
.ship { padding: 24px 16px; }
.combo__input { font-size: 16px; }
.combo__area { display: none; }
}
import { gsap } from "gsap";
// 動きを減らす設定では、ほぼ一瞬で切り替える
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const T = reduce ? 0.02 : 1;
document.querySelectorAll(".combo").forEach((combo) => {
const input = combo.querySelector(".combo__input");
const panel = combo.querySelector(".combo__panel");
const list = combo.querySelector(".combo__list");
const rows = [...list.querySelectorAll(".combo__row")];
const clear = combo.querySelector(".combo__clear");
const count = combo.querySelector(".combo__count");
// 候補の名前と読みは HTML に書いてある。寸法も CSS で決めた値を測って使う(測るあいだだけ hidden を外す)
const texts = (part) => rows.map((row) => row.querySelector(part).textContent);
const names = texts(".combo__name");
const kanas = texts(".combo__kana");
list.hidden = false;
const rowH = rows[0].offsetHeight;
const pad = parseFloat(getComputedStyle(list).paddingTop);
list.hidden = true;
let shown = [];
let active = -1;
// 一致した部分だけを mark で囲む(読みで一致したら読みに、漢字で一致したら名前に付ける)
const paint = (row, i, hit, len) => {
row.querySelector(".combo__name").textContent = names[i];
row.querySelector(".combo__kana").textContent = kanas[i];
const text = hit.kana ? kanas[i] : names[i];
const mark = document.createElement("mark");
mark.textContent = text.slice(hit.at, hit.at + len);
row.querySelector(hit.kana ? ".combo__kana" : ".combo__name").replaceChildren(text.slice(0, hit.at), mark, text.slice(hit.at + len));
};
const select = (k) => {
active = k;
rows.forEach((row) => row.setAttribute("aria-selected", "false"));
if (k < 0) return input.removeAttribute("aria-activedescendant");
shown[k].setAttribute("aria-selected", "true");
input.setAttribute("aria-activedescendant", shown[k].id);
};
const apply = (open = true) => {
// カタカナで打っても引けるよう、ひらがなにそろえてから読みと名前の両方で探す
const q = input.value.trim().replace(/[ァ-ヶ]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0x60));
const find = (i) => (kanas[i].indexOf(q) >= 0 ? { i, at: kanas[i].indexOf(q), kana: true } : { i, at: names[i].indexOf(q), kana: false });
const hits = q ? names.map((_, i) => find(i)).filter((h) => h.at >= 0) : [];
hits.sort((a, b) => a.at - b.at || a.i - b.i); // 一致が前から始まる候補を上にする
const rank = new Map(hits.map((h, k) => [h.i, k]));
if (!open) hits.length = 0;
if (hits.length) list.hidden = false;
rows.forEach((row, i) => {
const k = rank.get(i);
if (k === undefined || !open) {
// 外れた候補は、縮みながら薄くなってから並びを抜ける(overwrite で前の動きを止め、古い onComplete が後から隠さないようにする)
gsap.to(row, { opacity: 0, scale: 0.92, duration: 0.25 * T, ease: "power2.out", overwrite: true, onComplete: () => (row.hidden = true) });
return;
}
const fresh = row.hidden;
row.hidden = false;
paint(row, i, hits.find((h) => h.i === i), q.length);
if (fresh) gsap.set(row, { y: pad + k * rowH - 10, opacity: 0, scale: 1 });
// 残った候補は、前の位置から新しい位置へ滑る
gsap.to(row, { y: pad + k * rowH, opacity: 1, scale: 1, duration: (fresh ? 0.4 : 0.3) * T, delay: fresh ? k * 0.035 * T : 0, ease: fresh ? "power3.out" : "power3.inOut", overwrite: true });
});
shown = hits.map((h) => rows[h.i]);
// 閉じきってから listbox を外す(先に外すと、縮むあいだ中身が消えて見える)
const h = hits.length ? pad * 2 + hits.length * rowH : 0;
gsap.to(panel, { height: h, duration: 0.35 * T, ease: "power3.out", overwrite: true, onComplete: () => (list.hidden = h === 0) });
input.setAttribute("aria-expanded", String(hits.length > 0));
clear.hidden = input.value === "";
count.textContent = q && open ? `候補${hits.length}件` : "";
select(hits.length ? 0 : -1);
};
input.addEventListener("input", () => apply());
input.addEventListener("keydown", (event) => {
// かな漢字変換を確定する Enter では動かさない(Safari は keyCode でも見分ける)
if (event.isComposing || event.keyCode === 229) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (shown.length) select((active + (event.key === "ArrowDown" ? 1 : shown.length - 1)) % shown.length);
} else if (event.key === "Enter" && active >= 0) {
event.preventDefault();
shown[active].click();
} else if (event.key === "Escape") {
apply(false);
}
});
const commit = (value) => {
input.value = value;
apply(false);
input.focus();
};
rows.forEach((row) => row.addEventListener("click", () => commit(row.querySelector(".combo__name").textContent)));
clear.addEventListener("click", () => commit(""));
apply(false);
});