GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

태그 입력 ui 9가지 — 칩 UI 코드로 만들기

태그 입력 UI 아홉 가지를 나란히 놓고 보면, 칩 목록 끝에 「+3」 표시가 뜨는 지점부터 본다 — 줄 너비 값이 칩 개수를 계산해 넘친 만큼 접고, 쉼표로 붙여넣은 문자열은 구분자 값으로 나눈 개수만큼 칩을 한 번에 만들며, 글자 수 값은 칩 너비를 계산해 늘어난다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

숫자 값 하나가 칩 아홉 개의 동작을 가릅니다

태그 입력 칩은 겉보기엔 다 비슷해 보이지만, 실제로 들여다보면 각자 다른 값 하나를 기준으로 움직입니다. 01은 글자 수, 02는 개수, 03은 중복 여부, 04는 선택 단계, 05는 구분자, 06은 좌표, 07은 해시값, 08은 남은 폭, 09는 점수 — 이 값이 바뀌는 순간마다 칩이 늘어나거나 접히거나 색이 정해집니다. 아래 아홉 개는 각각 그 값을 계산하는 함수와 그 값이 화면에 반영되는 지점까지 코드를 그대로 보여줍니다. 실무 화면에 직접 붙일 자리가 있다면 각 항목의 훅·리스너 구성을 먼저 확인하는 편이 재사용에 유리합니다.

01입력한 글자 수만큼 폭이 늘어나는 칩 입력

입력 칸에 글자를 넣을 때마다 그 글자 수만큼 안쪽 칩의 폭을 다시 재서 칩이 좁았다 넓었다 하지 않고 내용에 딱 맞게 늘어납니다. 처음 그리는 순간에는 WIDTH_PER_CHAR 어림값으로 먼저 폭을 잡고, 같은 흐름 안에서 syncFont()가 캔버스 컨텍스트의 폰트를 draft 요소의 계산 스타일과 맞춘 뒤 measureWidth()의 ctx.measureText() 실측값을 구해, 다음 프레임에 applyWidth()가 폭을 MAX_W 안에서 다시 반영합니다.

입력한 글자 수만큼 폭이 늘어나는 칩 입력
새 창에서 열기

01 · 입력한 글자 수만큼 폭이 늘어나는 칩 입력 — 측정 누적면적 1.497% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

WIDTH_PER_CHARmeasureTextMAX_W
  function syncFont() {
    var cs = window.getComputedStyle(draftText);
    ctx.font = cs.fontWeight + ' ' + cs.fontSize + ' ' + cs.fontFamily;
  }
  function measureWidth(text) {
    // 계산 겹: canvas measureText 로 실제 글리프 폭을 잰다 — 글자 수 곱셈 추정이 아니다
    return ctx.measureText(text).width;
  }

  var target = '';
  function applyWidth(text, precise) {
    var guess = Math.min(MAX_W, Math.max(MIN_W, text.length * WIDTH_PER_CHAR + CH_PAD));
    draft.style.width = guess.toFixed(1) + 'px';
    if (precise) {
      syncFont();
      var real = Math.min(MAX_W, Math.max(MIN_W, measureWidth(text) + CH_PAD));
      requestAnimationFrame(function () {
        draft.style.width = real.toFixed(1) + 'px';
        wOut.textContent = Math.round(real);
      });
    } else {
      wOut.textContent = Math.round(guess);
    }
    nOut.textContent = text.length;
  }

02개수 한도를 넘기면 흔들려 막는 칩 입력

칩 개수를 정해둔 한도와 매번 비교하다가 한도에 닿으면 새 칩을 만들지 않고 입력 칸 전체를 짧게 흔들어 더는 못 넣는다는 걸 보여줍니다. block()은 흔드는 클래스를 껐다 켜는 리플로우 트릭을 쓰고, reduced-motion이면 흔들지 않고 바로 렌더만 한 번 더 부릅니다.

개수 한도를 넘기면 흔들려 막는 칩 입력
새 창에서 열기

02 · 개수 한도를 넘기면 흔들려 막는 칩 입력 — 측정 누적면적 13.369% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

MAX_COUNTblockcount
  function block() {
    // 계산 겹: 개수를 MAX_COUNT 와 비교해 이미 한도라면 칩을 만들지 않고 스프링 흔들림만 재생한다
    row.classList.remove('tm__row--shake');
    void row.offsetWidth; // 리플로우 — 같은 클래스를 다시 걸어도 애니메이션이 재생되게
    row.classList.add('tm__row--shake');
    live.textContent = '태그 ' + MAX_COUNT + '개 한도에 닿았다';
    if (reduce) render();
  }

  function add(text) {
    if (!text) return;
    if (count() >= MAX_COUNT) { block(); return; }
    seq++;
    var chip = document.createElement('span');
    chip.className = 'tm__chip tm__chip--enter';
    chip.id = 'tmChip' + seq;
    chip.innerHTML = '<span class="tm__chipText"></span><button type="button" class="tm__chipX">×</button>';
    chip.querySelector('.tm__chipText').textContent = text;
    chip.querySelector('.tm__chipX').setAttribute('aria-label', '태그 삭제: ' + text);
    chip.querySelector('.tm__chipX').addEventListener('click', function () { human(); chip.remove(); render(); });
    row.insertBefore(chip, input);
    requestAnimationFrame(function () { chip.classList.remove('tm__chip--enter'); });
    render();
    live.textContent = text + ' 추가 · 태그 ' + count() + '개';
  }

03같은 태그를 다시 넣으면 기존 칩이 깜빡이는 입력

새로 입력한 글자를 이미 있는 칩들과 하나씩 비교해 같은 것이 있으면 새 칩을 만들지 않고 그 기존 칩만 잠깐 배경색을 밝혔다 되돌려 이미 있다는 걸 알려줍니다. findDup()가 정규화한 문자열로 선형 탐색을 하고, merge()가 찾은 칩의 펄스 클래스를 지웠다 다시 걸어 리플로우로 재생시킵니다.

같은 태그를 다시 넣으면 기존 칩이 깜빡이는 입력
새 창에서 열기

03 · 같은 태그를 다시 넣으면 기존 칩이 깜빡이는 입력 — 측정 누적면적 11.464% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

normfindDupmerge
  function findDup(text) {
    // 계산 겹: 정규화한 문자열을 기존 칩 전부와 견줘 같은 것을 찾는다 — 대소문자·앞뒤 공백은 다르게 치지 않는다
    var key = norm(text);
    var list = chips();
    for (var i = 0; i < list.length; i++) {
      if (norm(list[i].dataset.text) === key) return list[i];
    }
    return null;
  }

  function merge(chip) {
    // 계산 겹: 새 칩을 만들지 않고 이미 있는 칩에 감쇠 스프링 펄스를 다시 건다(reflow 로 재생 보장)
    chip.classList.remove('td__chip--merge');
    void chip.offsetWidth;
    chip.classList.add('td__chip--merge');
    dupEl.hidden = false;
    live.textContent = chip.dataset.text + ' 은 이미 있다';
    if (reduce) render();
  }

04백스페이스 두 번으로 지우는 칩 입력

입력 칸이 비어 있을 때 백스페이스를 누르면 맨 끝 칩을 먼저 선택 상태로 바꾸고, 그 상태에서 한 번 더 누를 때만 그 칩을 지워 실수로 지우는 걸 한 단계 막습니다. select()가 1단(표시만), remove()가 2단(퇴장 애니메이션 뒤 DOM 제거)을 맡고 backspaceStep()이 입력칸이 비었을 때만 이 둘 중 하나로 갈라 보냅니다.

백스페이스 두 번으로 지우는 칩 입력
새 창에서 열기

04 · 백스페이스 두 번으로 지우는 칩 입력 — 측정 누적면적 3.374% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

backspaceStepselectlastChip
  function select(chip) {
    // 계산 겹 1단: 선택은 지우기 전 단계 — 어떤 칩이 지워질지 눈에 보이게 표시만 한다
    clearSelect();
    chip.classList.add('tb__chip--selected');
    selectedId = chip.id;
    hint.textContent = '한 번 더 백스페이스 — "' + chip.dataset.text + '" 를 지운다';
    live.textContent = chip.dataset.text + ' 선택됨';
  }

  function remove(chip) {
    // 계산 겹 2단: 선택된 칩에만 퇴장 애니메이션(회전+수축)을 걸고 끝난 뒤 DOM 에서 뗀다
    selectedId = null;
    chip.classList.add('tb__chip--exit');
    var text = chip.dataset.text;
    window.setTimeout(function () {
      chip.remove();
      hint.textContent = '빈 칸에서 백스페이스 — 첫 번째는 고른다';
      live.textContent = text + ' 삭제됨 · 태그 ' + chips().length + '개';
    }, reduce ? 0 : 320);
  }

05붙여넣은 문장을 한 번에 칩으로 쪼개는 입력

쉼표나 줄바꿈으로 이어진 문자열을 붙여넣으면 그 구분 문자를 기준으로 문자열을 나눠 빈 조각을 걸러내고 남은 조각 수만큼 칩을 한꺼번에 만듭니다. 여기서 실제로 쓰는 건 paste 이벤트와 e.clipboardData.getData('text/plain')뿐이고, 자동 재생 데모가 붙여넣기를 재현할 때도 navigator.clipboard API를 부르지 않고 합성 이벤트에 문자열을 실어 dispatchEvent로 넘깁니다 — e.isTrusted로 사람이 실제로 붙여넣었는지, 데모가 흉내 낸 건지만 가릅니다.

붙여넣은 문장을 한 번에 칩으로 쪼개는 입력
새 창에서 열기

05 · 붙여넣은 문장을 한 번에 칩으로 쪼개는 입력 — 측정 누적면적 14.601% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

DELIM_PATTERNsplitfilter
  function handlePaste(e) {
    // 계산 겹: paste 이벤트의 clipboardData 에서 글을 꺼내 구분자로 쪼갠다 — navigator.clipboard 를 읽지 않는다
    // e.isTrusted 로 진짜 사용자 붙여넣기와 자동 데모의 흉내 이벤트(dispatchEvent)를 가른다
    if (e.isTrusted) human();
    e.preventDefault();
    var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
    var parts = text.split(DELIM_PATTERN).map(function (s) { return s.trim(); }).filter(Boolean);
    if (parts.length > 1) splitCount++;
    parts.forEach(function (p, i) { addChip(p, i); });
    input.value = '';
    render();
    live.textContent = parts.length + '개 태그로 쪼갬';
  }

06드래그로 순서를 바꾸는 칩 입력

잡은 칩의 가로 위치를 옆 칩들의 중심 위치와 계속 비교하다가 그 위치를 지나치면 두 칩의 순서를 그 자리에서 바꾸고 나머지 칩은 밀려나며 빈자리를 메웁니다. checkSwap()이 중심 좌표 비교를, doSwap()이 DOM 순서 변경과 swapCompensation 오프셋 누적을 맡습니다.

드래그로 순서를 바꾸는 칩 입력
새 창에서 열기

06 · 드래그로 순서를 바꾸는 칩 입력 — 측정 누적면적 7.892% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

checkSwapdoSwapswapCompensation
  function checkSwap() {
    var dr = dragEl.getBoundingClientRect();
    var dragCenter = dr.left + dr.width / 2;
    var prev = dragEl.previousElementSibling;
    if (prev) {
      var pr = prev.getBoundingClientRect();
      if (dragCenter < pr.left + pr.width / 2) { doSwap(prev, true); return; }
    }
    var next = dragEl.nextElementSibling;
    if (next) {
      var nr = next.getBoundingClientRect();
      if (dragCenter > nr.left + nr.width / 2) { doSwap(next, false); return; }
    }
  }

  function doSwap(sibling, moveBefore) {
    var oldLeft = dragEl.offsetLeft;
    if (moveBefore) { row.insertBefore(dragEl, sibling); }
    else { row.insertBefore(sibling, dragEl); }
    var newLeft = dragEl.offsetLeft;
    swapCompensation += (oldLeft - newLeft);
    applyTransform(lastDx);
    renderOrder();
    live.textContent = orderText();
  }

07글자만으로 색이 정해지는 칩 입력

칩에 들어갈 글자를 숫자로 바꿔 더한 값을 정해둔 색 목록 개수로 나눈 나머지로 색을 골라, 같은 글자의 칩은 언제 다시 입력해도 항상 같은 색을 받습니다. hashIndex()는 Math.random을 쓰지 않고 글자 코드값에 자리 가중치를 곱해 더한 합을 PALETTE_LEN으로 나눈 나머지만 씁니다.

글자만으로 색이 정해지는 칩 입력
새 창에서 열기

07 · 글자만으로 색이 정해지는 칩 입력 — 측정 누적면적 11.941% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

hashIndexPALETTE_LENhash-c0
  // 계산 겹: 글자 코드값을 더한 합을 팔레트 개수로 나눈 나머지로 색을 고른다 — 같은 글자는 언제나 같은 나머지다
  function hashIndex(text) {
    var h = 0;
    for (var i = 0; i < text.length; i++) { h += text.charCodeAt(i) * (i + 1); }
    return h % PALETTE_LEN;
  }

  function addChip(text) {
    seq++;
    var idx = hashIndex(text);
    var chip = document.createElement('span');
    chip.className = 'tc__chip tc__chip--enter tc__chip--c' + idx;
    chip.id = 'tcChip' + seq;
    chip.innerHTML = '<span class="tc__chipText"></span><button type="button" class="tc__chipX">×</button>';
    chip.querySelector('.tc__chipText').textContent = text;
    chip.querySelector('.tc__chipX').setAttribute('aria-label', '태그 삭제: ' + text);
    chip.querySelector('.tc__chipX').addEventListener('click', function () { human(); chip.remove(); render(); });
    row.insertBefore(chip, input);
    requestAnimationFrame(function () { chip.classList.remove('tc__chip--enter'); });
    render();
    live.textContent = text + ' 태그, ' + (idx + 1) + '번째 색';
  }

08줄이 넘치면 「+N」으로 접는 칩 입력

한 줄에 들어갈 수 있는 폭을 칩들의 너비 합과 계속 비교하다가 넘치는 순간부터는 그 뒤 칩들을 감추고 감춘 개수를 세어 자리에 남긴 배지에 적습니다. relayout()은 배지 자리로 GAP + BADGE_RESERVE만큼을 미리 빼고 남은 폭 안에서만 칩을 셉니다.

줄이 넘치면 「+N」으로 접는 칩 입력
새 창에서 열기

08 · 줄이 넘치면 「+N」으로 접는 칩 입력 — 측정 누적면적 2.418% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BADGE_RESERVEGAPrelayout
    for (var i = 0; i < chips.length; i++) {
      chips[i].style.display = '';
    }
    for (i = 0; i < chips.length; i++) {
      var w = chips[i].getBoundingClientRect().width;
      var addGap = shown > 0 ? GAP : 0;
      var withThis = sum + addGap + w;
      var remaining = chips.length - (i + 1);
      var reserve = remaining > 0 ? GAP + BADGE_RESERVE : 0;
      if (withThis + reserve <= containerWidth) { sum = withThis; shown++; }
      else { break; }
    }
    for (var j = shown; j < chips.length; j++) { chips[j].style.display = 'none'; }

09입력값과 비슷한 순서로 후보를 보여주는 칩 입력

입력한 글자와 후보 목록 각각을 겹치는 글자 수로 비교해 점수를 매기고, 점수가 높은 순서로 후보를 다시 늘어놓아 위쪽 후보를 클릭하면 그대로 칩이 됩니다. 후보 목록은 고정 배열 CANDIDATES이고 서버 요청 없이 score()가 매 입력마다 클라이언트에서 다시 계산합니다.

입력값과 비슷한 순서로 후보를 보여주는 칩 입력
새 창에서 열기

09 · 입력값과 비슷한 순서로 후보를 보여주는 칩 입력 — 측정 누적면적 3.226% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

scoreTOP_NrenderList
  function renderList(query) {
    var scored = CANDIDATES.map(function (c) { return { text: c, score: score(query, c) }; });
    scored.sort(function (a, b) { return b.score - a.score; });
    var top = scored.slice(0, TOP_N);
    list.innerHTML = '';
    top.forEach(function (item, i) {
      var li = document.createElement('li');
      li.className = 'as__opt' + (i === 0 && item.score > 0 ? ' as__opt--lead' : '');
      li.setAttribute('role', 'option');
      li.setAttribute('tabindex', '0');
      li.textContent = item.text;
      li.addEventListener('click', function (e) { if (e.isTrusted) human(); commit(item.text); });
      li.addEventListener('keydown', function (e) { if (e.key === 'Enter') { human(); commit(item.text); } });
      list.appendChild(li);
    });
  }

백스페이스 두 번, aria-live 아홉 곳 — 접근성과 reduced-motion

아홉 항목 모두 role="status" 또는 role="alert" aria-live 영역을 하나씩 갖고 있어 총 아홉 곳에서 칩이 늘거나 줄 때마다 스크린리더에 읽어줄 문장을 올립니다. 04의 백스페이스 두 단계는 이 발표를 특히 많이 씁니다 — 첫 번째 백스페이스에서 select()가 "선택됨"을 알리고, 두 번째 백스페이스에서 remove()가 "삭제됨 · 태그 N개"를 알려 마우스 없이 키보드만으로도 지금 뭐가 지워지려 하는지, 실제로 지워졌는지를 따라갈 수 있습니다. prefers-reduced-motion이 참이면 흔들기·펄스·퇴장 트랜지션 같은 시간차 연출 없이 상태만 즉시 바꾸고 render()를 한 번 부르는 것으로 끝냅니다. 이 감지 조건은 MDN의 prefers-reduced-motion 문서를 기준으로 삼았고, 발표 영역 구성은 aria-live 속성 설명을 따랐습니다.

태그 ui 디자인에서 이 아홉 개를 실무 어디에 붙이나

블로그·문서 도구의 태그 편집창에는 02의 개수 제한과 03의 중복 병합을 같이 붙이는 조합이 흔합니다 — 태그를 무한정 늘리지 않으면서 같은 태그를 실수로 두 번 못 넣게 막는 구성입니다. 이력서·프로필 스킬 입력창처럼 붙여넣기가 잦은 자리에는 05의 구분자 분리가 맞고, 검색 필터 사이드바처럼 후보가 정해진 자리에는 09의 자동완성이 어울립니다. 07의 해시 색상은 프로젝트 관리 도구에서 라벨마다 같은 색을 계속 유지해야 할 때 어울리고, 06의 드래그 순서 바꾸기는 우선순위 태그를 앞으로 끌어와야 하는 화면에 붙습니다.

아홉 칩 입력의 실측값 — 누적 면적·강도·움직인 프레임

24프레임을 캡처해 이전 프레임과 비교한 값입니다. 움직인프레임은 24프레임 사이 23번의 비교 중 실제로 달라진 횟수입니다.

항목 누적면적% 강도평균 움직인프레임
01 폭 자동 조절 1.497 93.0 10/23
02 개수 한도 흔들기 13.369 119.6 15/23
03 중복 하이라이트 병합 11.464 80.4 9/23
04 백스페이스 2단 삭제 3.374 81.3 16/23
05 붙여넣기 분리 14.601 66.1 17/23
06 드래그 순서 변경 7.892 99.7 16/23
07 해시 색상 11.941 114.5 9/23
08 오버플로우 접기 2.418 121.3 6/23
09 자동완성 추천 3.226 163.7 6/23

WIDTH_PER_CHAR·DELIM_PATTERN·BADGE_RESERVE, 값 하나씩 바꾼 결과

01의 WIDTH_PER_CHAR는 canvas measureText 실측이 오기 전 첫 프레임에 쓰는 어림 폭(글자당 px)입니다 — 6.4를 9.5로 올리면 칩이 처음 그려지는 순간 더 넓게 잡히고, 바로 다음 프레임에서 measureText 실측값으로 좁혀지는 보정은 그대로 남습니다. 05의 DELIM_PATTERN은 붙여넣은 글을 쪼개는 구분자 정규식으로, 쉼표·줄바꿈만 보던 /[,\n]+/에 세미콜론을 더해 /[,;\n]+/로 바꾸면 세미콜론으로 이어 붙인 글도 같은 붙여넣기 한 번에 여러 칩으로 쪼개집니다. 08의 BADGE_RESERVE는 relayout()이 「+N」 배지 자리로 미리 남겨 두는 폭(px)으로, 34에서 54로 올리면 같은 줄 폭에서 배지가 먹는 몫이 커져 그만큼 접히기 전에 보이는 칩 수가 줄고 배지가 더 일찍 뜹니다.

항목 상수 전 → 후
01 WIDTH_PER_CHAR 6.4 → 9.5
05 DELIM_PATTERN /[,\n]+/ → /[,;\n]+/
08 BADGE_RESERVE 34 → 54

폰트가 안 맞은 채로 재면 깨지는 함정

01은 syncFont()로 캔버스 컨텍스트의 폰트를 draft 요소의 계산 스타일과 맞춘 뒤에만 measureText()를 부릅니다. 이 순서를 건너뛰고 캔버스 기본 폰트로 먼저 재버리면, 실제 화면 폰트보다 좁거나 넓은 값이 나와 칩 폭이 한 프레임 동안 눈에 띄게 어긋났다가 다음 렌더에서 튕기듯 바뀝니다. 커스텀 웹폰트를 쓰는 화면에서는 폰트 로드가 늦게 끝나는 경우까지 감안해 syncFont() 호출 지점을 입력 이벤트마다 반복해야 첫 칩부터 폭이 맞습니다. 전체 동작을 코드 그대로 확인하려면 kq4849c2으로 아홉 개 데모의 HTML·SCSS·JS 원본을 열어 이 순서를 직접 비교해 보는 편이 빠릅니다.

입력창 디자인 210편, 셀렉트박스 237편과 무엇이 다른가요?

입력창 디자인 9가지 — 복붙하면 되는 폼 위젯은 단일 입력 칸 자체의 포커스·유효성 표현을 다루고, 이번 397편은 그 입력 칸 안에서 값이 칩이라는 독립된 단위로 쪼개지고 합쳐지는 과정을 다룹니다. css 셀렉트박스 디자인 9종 — 진짜 열리는 드롭다운은 정해진 목록에서 하나를 고르는 흐름이라 열림·닫힘 상태가 핵심이지만, 397편은 값을 계속 추가·삭제·재배열하는 다중 항목 흐름이라 상태보다 개수·순서·중복 판정 로직이 큽니다. input 자동완성 검색창 — 하이라이트·디바운스·편집거리 계산을 얹은 9종의 후보 추천 계산은 397편 09와 결이 비슷하지만, 335편은 검색 결과 하이라이트가 중심이고 397편 09는 고른 후보가 칩으로 확정되는 지점까지 이어집니다.

태그 입력 칩을 적용하기 전에 자주 나오는 질문

태그 선택 UI에서 05의 붙여넣기 분리는 실제 클립보드 API를 쓰나요?

아니요. 05는 paste 이벤트와 e.clipboardData.getData('text/plain')만 읽습니다. 사람이 직접 Ctrl+V로 붙여넣은 경우와 자동 재생 데모가 문자열을 합성해 넣는 경우를 e.isTrusted로만 구분할 뿐, navigator.clipboard.readText() 같은 클립보드 API 자체를 부르지는 않습니다.

태그 선택 UI를 만들 때 외부 라이브러리가 필요한가요?

필요 없습니다. 아홉 개 전부 외부 라이브러리나 CDN 없이 순수 JS와 CSS만으로 동작합니다. fetch나 서버 통신, localStorage 저장도 쓰지 않으며 09의 후보 목록도 고정 배열입니다.

04의 백스페이스 두 단계는 한 번만 눌러도 지워지게 바꿀 수 있나요?

가능하지만 그러면 select() 단계가 사라져 실수로 여러 칩을 연달아 지우기 쉬워집니다. backspaceStep()에서 selectedId === last.id 조건을 지우고 바로 remove(last)만 부르면 한 번에 지워지는데, 이때는 04가 원래 막으려던 실수 삭제 방지 목적이 없어집니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.