새 글Svelte키보드

키보드 데모 테스트

keydown과 keyup은 언제, 몇 번 발생할까요? 아래 데모를 클릭한 뒤 방향키를 눌러보세요. 키를 꾹 누르고 있으면 어떤 일이 생기는지도 확인해보세요.

데모 · 방향키를 눌러보세요
↑←↓→
  1. 이벤트가 여기에 표시돼요
코드 보기코드 접기KeyboardDemo.svelte
<script lang="ts">
  import { SvelteSet } from "svelte/reactivity";
  import DemoFrame from "@/demos/DemoFrame.svelte";
  import { roughDecor } from "@/lib/rough-action";
  import { ROUGH_PRESETS } from "@/lib/rough";

  type LogEntry = { id: number; type: "keydown" | "keyup"; key: string; repeat: boolean };

  const KEYS = [
    { key: "ArrowUp", label: "↑", area: "up" },
    { key: "ArrowLeft", label: "←", area: "left" },
    { key: "ArrowDown", label: "↓", area: "down" },
    { key: "ArrowRight", label: "→", area: "right" },
  ];

  const pressed = new SvelteSet<string>();
  let log = $state<LogEntry[]>([]);
  let nextId = 0;

  function record(type: LogEntry["type"], event: KeyboardEvent) {
    log = [{ id: nextId++, type, key: event.key, repeat: event.repeat }, ...log].slice(0, 4);
  }

  function onkeydown(event: KeyboardEvent) {
    if (!KEYS.some((k) => k.key === event.key)) return;
    event.preventDefault();
    pressed.add(event.key);
    record("keydown", event);
  }

  function onkeyup(event: KeyboardEvent) {
    if (!KEYS.some((k) => k.key === event.key)) return;
    pressed.delete(event.key);
    record("keyup", event);
  }
</script>

<DemoFrame hint="방향키를 눌러보세요" {onkeydown} {onkeyup} ondeactivate={() => pressed.clear()}>
  {#snippet children({ active })}
    <div class="body">
      <div class="keys">
        {#each KEYS as k (k.key)}
          <span class="key" class:on={pressed.has(k.key)} style:grid-area={k.area}
            ><span class="key-shape" aria-hidden="true" use:roughDecor={{ kind: "box", seed: `key:${k.key}`, fill: true, radius: 9, ...ROUGH_PRESETS.chip }}></span
            >{#if pressed.has(k.key)}
              <!-- 눌린 동안은 화살표 대신 웃는 얼굴(^o^)이 된다. -->
              <svg class="face" viewBox="0 0 18 16" aria-hidden="true">
                <path d="M2 6.5 4.5 3.5 7 6.5M11 6.5 13.5 3.5 16 6.5" />
                <path d="M7 9.5h4c0 2.6-.9 4-2 4s-2-1.4-2-4z" />
              </svg>
              <span class="sr-only">{k.label}</span>
            {:else}
              {k.label}
            {/if}</span
          >
        {/each}
      </div>
      <ol class="log" aria-live="polite">
        {#each log as entry (entry.id)}
          <li><span class="type">{entry.type}</span> {entry.key}{entry.repeat ? " (repeat)" : ""}</li>
        {:else}
          <li class="empty">{active ? "방향키를 눌러보세요" : "이벤트가 여기에 표시돼요"}</li>
        {/each}
      </ol>
    </div>
  {/snippet}
</DemoFrame>

<style>
  .body {
    position: relative;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 16px 24px;
  }

  .keys {
    display: grid;
    grid-template-areas: ". up ." "left down right";
    gap: 5px;
  }

  .key {
    position: relative;
    z-index: 0;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 32px;
    height: 28px;
    color: var(--key-text);
    font-size: 14px;
  }

  /* 키 모양은 rough.js로 그린다. 키마다 모양이 다르다. */
  .key-shape {
    --rough-fill: var(--key-bg);
    --rough-stroke: var(--key-border);
    --rough-stroke-width: 1;
    position: absolute;
    inset: 0;
    z-index: -1;
    isolation: isolate;
  }

  .key-shape:not([data-rough-ready]) {
    border: 1px solid var(--key-border);
    border-radius: 9px;
    background: var(--key-bg);
  }

  .key.on {
    color: var(--key-on-text);
  }

  .face {
    width: 18px;
    height: 16px;
    fill: none;
    stroke: currentColor;
    stroke-width: 1.4;
    stroke-linecap: round;
    stroke-linejoin: round;
    animation: pop 0.18s ease-out;
  }

  /* 얼굴이 나올 때 살짝 튀어나온다. */
  @keyframes pop {
    from {
      transform: scale(0.6);
    }
    60% {
      transform: scale(1.15);
    }
  }

  @media (prefers-reduced-motion: reduce) {
    .face {
      animation: none;
    }
  }

  .key.on .key-shape {
    --rough-fill: var(--key-on-bg);
    --rough-stroke: var(--key-on-border);
  }

  .log {
    flex: 1;
    min-width: 150px;
    white-space: nowrap;
    margin: 0;
    padding: 0;
    list-style: none;
    font-family: var(--font-mono);
    font-size: 13px;
    line-height: 1.7;
    color: var(--demo-label);
  }

  .type {
    color: var(--demo-active);
  }

  .empty {
    font-family: var(--font-sans);
  }
</style>

keydown은 여러 번, keyup은 한 번

키를 누르고 있으면 운영체제의 키 반복 설정에 따라 keydown이 계속 발생합니다. 이때 두 번째부터는 event.repeat가 true예요. 반대로 keyup은 손을 뗄 때 딱 한 번만 발생합니다.

게임처럼 “누르고 있는 동안” 상태가 필요하다면 keydown에서 키를 집합에 넣고 keyup에서 빼는 방식이 가장 단순합니다.

const pressed = new Set<string>();

element.addEventListener("keydown", (event) => {
  pressed.add(event.key);
});

element.addEventListener("keyup", (event) => {
  pressed.delete(event.key);
});

왜 window가 아니라 요소에 거나

한 페이지에 데모가 여러 개 있으면 window에 건 이벤트는 모든 데모가 동시에 받습니다. 방향키나 스페이스로 페이지를 스크롤하는 것도 막혀버려요. 그래서 이 블로그의 데모는 이렇게 동작합니다.

  • 데모를 클릭해서 포커스가 생겨야 키 입력을 받습니다.
  • 포커스가 있을 때만 preventDefault()로 스크롤을 막습니다.
  • Esc를 누르거나 바깥을 클릭하면 포커스가 풀립니다.

포커스가 어디 있는지 눈에 보여야 합니다. 데모 위쪽 문구가 “● 입력 중”으로 바뀌는 이유예요.

정리

keydown의 repeat를 확인하고, 이벤트는 포커스를 받을 수 있는 요소에 거는 게 좋습니다.