06오타 제안 — 편집거리를 계산해 '이것을 찾으셨나요?'를 띄우는 계산
입력어와 후보 사이의 레벤슈타인 편집거리를 2차원 DP 표로 계산해 거리 1~2 이하면 오타로 판정, '이것을 찾으셨나요?' 줄에 원래 단어를 제안합니다. 대소문자·공백 정규화 후 재거리 계산과, 거리 임계값을 낮출수록 보수적으로 바뀌는 조율까지 다룹니다.
쓰임맞춤법에 약한 사용자 배려
beforeinputCSS custom propertiesaria-expanded

<main class="stage">
<div class="ty" id="ty">
<p class="ty__hint">오타면 편집거리를 계산해 가장 가까운 단어를 제안합니다</p>
<div class="ty__field" role="combobox" aria-expanded="false" aria-owns="tyList" aria-haspopup="listbox" id="tyField">
<input class="ty__input" id="tyInput" type="text" role="searchbox" aria-autocomplete="list" aria-controls="tyList" aria-label="과일 검색" autocomplete="off" readonly>
</div>
<ul class="ty__list" id="tyList" role="listbox" aria-label="후보 목록"></ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue-deep;
// 파스텔 무대(도구/파스텔.py to_pastel 결과: 채도 .34 · 명도 +.11, $stage-yellow 기준)
$p-yellow: #d2c7a4;
@include stage($p-yellow);
.ty {
width: 100%;
max-width: 380px;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.ty__hint {
margin: 0;
padding: 0 $sp-1;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: rgba($subject-ink, .72);
}
.ty__field {
background: $subject-cream;
border-radius: $r-control;
padding: $sp-2 $sp-3;
box-shadow: $shadow-press;
}
.ty__input {
width: 100%;
border: 0;
outline: 0;
background: transparent;
font-weight: 500;
font-size: 13px;
color: $subject-ink;
caret-color: $color;
}
.ty__list {
margin: 0;
padding: $sp-1;
list-style: none;
display: flex;
flex-direction: column;
gap: $sp-1;
max-height: 132px;
overflow-y: auto;
background: $subject-cream;
border-radius: $r-card;
box-shadow: $shadow-press;
}
.ty__item {
padding: $sp-1 $sp-2;
border-radius: $r-sm;
font-weight: 500;
font-size: 12px;
line-height: 1.6;
color: $subject-ink;
}
.ty__item.is-exact {
background: $color;
color: $subject-cream;
}
.ty__item--empty {
color: rgba($subject-ink, .72);
font-weight: 400;
}
.ty__item--typo {
--emph: 1;
display: flex;
flex-direction: column;
gap: $sp-1;
border-left: calc(2px + 2px * var(--emph)) solid $color;
padding-left: $sp-2;
border-radius: $r-sm;
transition: background-color $duration $easing, border-left-width $duration $easing;
}
.ty__typoLabel {
font-weight: 400;
font-size: 10px;
color: rgba($subject-ink, .72);
}
.ty__typoWord {
font-weight: 700;
font-size: 13px;
color: $color;
}
@media (max-width: 340px) {
.ty { max-width: 260px; }
.ty__list { max-height: 100px; }
}
@media (prefers-reduced-motion: reduce) {
.ty__item--typo {
transition: none;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #d2c7a4;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ty {
width: 100%;
max-width: 380px;
display: flex;
flex-direction: column;
gap: 8px;
}
.ty__hint {
margin: 0;
padding: 0 4px;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: rgba(23, 20, 26, 0.72);
}
.ty__field {
background: #fff7e6;
border-radius: 12px;
padding: 8px 12px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ty__input {
width: 100%;
border: 0;
outline: 0;
background: transparent;
font-weight: 500;
font-size: 13px;
color: #17141a;
caret-color: #0a4fe8;
}
.ty__list {
margin: 0;
padding: 4px;
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
max-height: 132px;
overflow-y: auto;
background: #fff7e6;
border-radius: 16px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ty__item {
padding: 4px 8px;
border-radius: 8px;
font-weight: 500;
font-size: 12px;
line-height: 1.6;
color: #17141a;
}
.ty__item.is-exact {
background: #0a4fe8;
color: #fff7e6;
}
.ty__item--empty {
color: rgba(23, 20, 26, 0.72);
font-weight: 400;
}
.ty__item--typo {
--emph: 1;
display: flex;
flex-direction: column;
gap: 4px;
border-left: calc(2px + 2px * var(--emph)) solid #0a4fe8;
padding-left: 8px;
border-radius: 8px;
transition: background-color 220ms ease-out, border-left-width 220ms ease-out;
}
.ty__typoLabel {
font-weight: 400;
font-size: 10px;
color: rgba(23, 20, 26, 0.72);
}
.ty__typoWord {
font-weight: 700;
font-size: 13px;
color: #0a4fe8;
}
@media (max-width: 340px) {
.ty {
max-width: 260px;
}
.ty__list {
max-height: 100px;
}
}
@media (prefers-reduced-motion: reduce) {
.ty__item--typo {
transition: none;
}
}// 오타 제안(06) — 입력어와 후보 각각 사이의 레벤슈타인 편집거리를 2차원 DP 표로 계산해
// 최소 거리 후보를 찾는다. 거리 0 이면 정확히 일치, 1~THRESHOLD 이면 "이것을 찾으셨나요?"
// 줄에 원래 단어를 제안, 그보다 크면 무시한다. 비교 전 toLowerCase + trim 으로
// 대소문자·공백을 정규화한 뒤 다시 거리를 잰다.
(function () {
var field = document.getElementById('tyField');
var input = document.getElementById('tyInput');
var list = document.getElementById('tyList');
if (!field || !input || !list) return;
var WORDS = ['바나나', '파파야', '아보카도', '파인애플', '자몽', '체리', '복숭아', '수박', '딸기', '망고'];
var THRESHOLD = 2;
function normalize(s) {
return s.toLowerCase().trim();
}
function editDistance(a, b) {
var m = a.length, n = b.length;
var dp = [];
for (var i = 0; i <= m; i++) { dp.push([i]); }
for (var j = 0; j <= n; j++) { dp[0][j] = j; }
for (i = 1; i <= m; i++) {
for (j = 1; j <= n; j++) {
if (a[i - 1] === b[j - 1]) dp[i][j] = dp[i - 1][j - 1];
else {
dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
}
}
}
return dp[m][n];
}
function nearest(query) {
var q = normalize(query);
var best = null, bestDist = Infinity;
WORDS.forEach(function (w) {
var d = editDistance(q, normalize(w));
if (d < bestDist) { bestDist = d; best = w; }
});
return { word: best, dist: bestDist };
}
function render(query) {
if (!query) {
list.innerHTML = WORDS.map(function (w) {
return '<li class="ty__item" role="option">' + w + '</li>';
}).join('');
field.setAttribute('aria-expanded', 'false');
return;
}
var q = normalize(query);
var exact = WORDS.filter(function (w) { return normalize(w).indexOf(q) !== -1; });
var rows = '';
if (exact.length) {
rows = exact.map(function (w) {
return '<li class="ty__item is-exact" role="option">' + w + '</li>';
}).join('');
} else {
var n = nearest(query);
if (n.dist >= 1 && n.dist <= THRESHOLD) {
var emph = (THRESHOLD - n.dist + 1) / THRESHOLD;
var alpha = (0.08 + 0.14 * emph).toFixed(3);
rows = '<li class="ty__item ty__item--typo" role="option" aria-live="polite" style="--emph:' + emph.toFixed(2) + ';background:rgba(10,79,232,' + alpha + ')">' +
'<span class="ty__typoLabel">이것을 찾으셨나요?</span>' +
'<span class="ty__typoWord">' + n.word + '</span></li>';
} else {
rows = '<li class="ty__item ty__item--empty">일치하는 과일이 없습니다</li>';
}
}
list.innerHTML = rows;
field.setAttribute('aria-expanded', 'true');
}
render('');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = true;
function stop() { playing = false; }
input.addEventListener('beforeinput', stop);
input.addEventListener('input', function () { render(input.value); });
input.addEventListener('pointerdown', stop);
var STEPS = [
{ q: '', ms: 260 },
{ q: '바나나', ms: 420 },
{ q: '바다나', ms: 560 },
{ q: '', ms: 220 },
{ q: '수박', ms: 420 },
{ q: '슈박', ms: 560 }
];
if (reduce) {
input.value = STEPS[2].q;
render(STEPS[2].q);
} else {
var seg = 0, segStart = performance.now();
(function step(now) {
if (playing) {
var elapsed = now - segStart;
if (elapsed >= STEPS[seg].ms) {
seg = (seg + 1) % STEPS.length;
segStart = now;
}
var q = STEPS[seg].q;
if (input.value !== q) { input.value = q; render(q); }
}
requestAnimationFrame(step);
})(segStart);
}
})();// SearchAutocompleteTypoSuggestDistance — vanilla 판(run/335/demo/06-search-autocomplete-typo-suggest-distance/index.html)
// 의 React 포트. 입력어와 후보 사이의 레벤슈타인 편집거리를 2차원 DP 표로 계산해
// 최소 거리 후보를 찾는다. 거리 0 이면 정확히 일치, 1~THRESHOLD 이면 "이것을
// 찾으셨나요?" 줄에 원래 단어를 제안한다.
import { useEffect, useRef } from "react";
import styles from "./SearchAutocompleteTypoSuggestDistance.module.scss";
export type SearchAutocompleteTypoSuggestDistanceProps = {
duration?: string;
easing?: string;
color?: string;
};
const HINT = "오타면 편집거리를 계산해 가장 가까운 단어를 제안합니다";
const ARIA_LABEL = "과일 검색";
const LIST_LABEL = "후보 목록";
const DID_YOU_MEAN = "이것을 찾으셨나요?";
const NO_MATCH = "일치하는 과일이 없습니다";
const WORDS = ["바나나", "파파야", "아보카도", "파인애플", "자몽", "체리", "복숭아", "수박", "딸기", "망고"];
const THRESHOLD = 2;
function normalize(s: string): string {
return s.toLowerCase().trim();
}
function editDistance(a: string, b: string): number {
const m = a.length;
const n = b.length;
const dp: number[][] = [];
for (let i = 0; i <= m; i++) {
dp.push([i]);
}
for (let j = 0; j <= n; j++) {
dp[0][j] = j;
}
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (a[i - 1] === b[j - 1]) dp[i][j] = dp[i - 1][j - 1];
else dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
}
}
return dp[m][n];
}
function nearest(query: string): { word: string; dist: number } {
const q = normalize(query);
let best = "";
let bestDist = Infinity;
WORDS.forEach((w) => {
const d = editDistance(q, normalize(w));
if (d < bestDist) {
bestDist = d;
best = w;
}
});
return { word: best, dist: bestDist };
}
const STEPS: { q: string; ms: number }[] = [
{ q: "", ms: 260 },
{ q: "바나나", ms: 420 },
{ q: "바다나", ms: 560 },
{ q: "", ms: 220 },
{ q: "수박", ms: 420 },
{ q: "슈박", ms: 560 }
];
export default function SearchAutocompleteTypoSuggestDistance({
duration = "220ms",
easing = "ease-out",
color = "#0a4fe8"
}: SearchAutocompleteTypoSuggestDistanceProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const listRef = useRef<HTMLUListElement | null>(null);
useEffect(() => {
const field = fieldRef.current;
const input = inputRef.current;
const list = listRef.current;
if (!field || !input || !list) return;
function render(query: string): void {
if (!query) {
list!.innerHTML = WORDS.map((w) => '<li class="' + styles.item + '" role="option">' + w + "</li>").join("");
field!.setAttribute("aria-expanded", "false");
return;
}
const q = normalize(query);
const exact = WORDS.filter((w) => normalize(w).indexOf(q) !== -1);
let rows = "";
if (exact.length) {
rows = exact.map((w) => '<li class="' + styles.item + " " + styles.isExact + '" role="option">' + w + "</li>").join("");
} else {
const n = nearest(query);
if (n.dist >= 1 && n.dist <= THRESHOLD) {
const emph = (THRESHOLD - n.dist + 1) / THRESHOLD;
const alpha = (0.08 + 0.14 * emph).toFixed(3);
rows =
'<li class="' + styles.item + " " + styles.itemTypo + '" role="option" aria-live="polite" style="--emph:' +
emph.toFixed(2) + ";background:rgba(10,79,232," + alpha + ')">' +
'<span class="' + styles.typoLabel + '">' + DID_YOU_MEAN + "</span>" +
'<span class="' + styles.typoWord + '">' + n.word + "</span></li>";
} else {
rows = '<li class="' + styles.item + " " + styles.itemEmpty + '">' + NO_MATCH + "</li>";
}
}
list!.innerHTML = rows;
field!.setAttribute("aria-expanded", "true");
}
render("");
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = true;
function stop(): void {
playing = false;
}
function onInput(): void {
render(input!.value);
}
input.addEventListener("beforeinput", stop);
input.addEventListener("input", onInput);
input.addEventListener("pointerdown", stop);
let raf = 0;
if (reduce) {
input.value = STEPS[2].q;
render(STEPS[2].q);
} else {
let seg = 0;
let segStart = performance.now();
const step = (now: number): void => {
if (playing) {
const elapsed = now - segStart;
if (elapsed >= STEPS[seg].ms) {
seg = (seg + 1) % STEPS.length;
segStart = now;
}
const q = STEPS[seg].q;
if (input!.value !== q) {
input!.value = q;
render(q);
}
}
raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
}
return () => {
cancelAnimationFrame(raf);
input.removeEventListener("beforeinput", stop);
input.removeEventListener("input", onInput);
input.removeEventListener("pointerdown", stop);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.ty}>
<p className={styles.hint}>{HINT}</p>
<div className={styles.field} ref={fieldRef} role="combobox" aria-expanded="false" aria-owns="tyListReact" aria-haspopup="listbox">
<input
className={styles.input}
ref={inputRef}
type="text"
role="searchbox"
aria-autocomplete="list"
aria-controls="tyListReact"
aria-label={ARIA_LABEL}
autoComplete="off"
readOnly
style={{ caretColor: color, transitionDuration: duration, transitionTimingFunction: easing }}
/>
</div>
<ul className={styles.list} id="tyListReact" ref={listRef} role="listbox" aria-label={LIST_LABEL} />
</div>
</main>
);
}06 오타 제안 — 편집거리를 계산해 '이것을 찾으셨나요?'를 띄우는 계산 — 측정 누적면적 45.977% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · input 자동완성 검색창 — 하이라이트·디바운스·편집거리 계산을 얹은 9종
이 데모가 속한 글
input 자동완성 검색창 — 하이라이트·디바운스·편집거리 계산을 얹은 9종입력한 글자가 제안 항목 안에서 실제로 어디에 있는지 찾아 그 부분만 굵게 칠하고, 300ms 디바운스로 요청을 모아 키보드로도 목록이 순환합니다. 최근 검색·오타 제안·인기 순위 막대까지 — input 자동완성 검 이 글의 데모 전부 보기


