서식 툴바

Rich text toolbar

굵게·기울임·밑줄처럼 지금 선택한 글자에 서식을 켜고 끄는 토글 버튼들이 늘어선 줄.

다른 이름: Formatting toolbarWYSIWYG toolbar
···
html
<div class="rtw">
  <div class="rttb" role="toolbar" aria-label="서식" aria-orientation="horizontal" id="rttb">
    <button aria-pressed="true" aria-label="굵게" tabindex="0"><b>B</b></button>
    <button aria-pressed="false" aria-label="기울임" tabindex="-1"><i>I</i></button>
    <button aria-pressed="false" aria-label="밑줄" tabindex="-1"><u>U</u></button>
    <button aria-pressed="false" aria-label="취소선" tabindex="-1"><s>S</s></button>
  </div>
  <p class="rtprev">문장 일부에 <span id="rtsample">서식</span>이 적용됩니다.</p>
</div>
css
.rtw{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px}
.rttb{display:flex;gap:3px;border:1px solid var(--line);border-radius:9px;background:var(--surface);padding:4px}
.rttb button{width:26px;height:26px;border:0;border-radius:6px;background:none;color:var(--fg);font-size:12px;cursor:default}
.rttb button[aria-pressed=true]{background:var(--accent);color:#fff}
.rtprev{font-size:12px;color:var(--fg);max-width:220px;text-align:center;line-height:1.6}
js
const btns = [...document.querySelectorAll('#rttb button')];
const sample = document.getElementById('rtsample');
const CODES = ['B', 'I', 'U', 'S'];
const STYLES = [['B'], ['I'], ['U'], ['B', 'I'], ['S'], []];
function apply(codes) {
  btns.forEach((b, i) => b.setAttribute('aria-pressed', String(codes.includes(CODES[i]))));
  sample.style.fontWeight = codes.includes('B') ? '800' : '400';
  sample.style.fontStyle = codes.includes('I') ? 'italic' : 'normal';
  sample.style.textDecoration = [codes.includes('U') ? 'underline' : '', codes.includes('S') ? 'line-through' : ''].filter(Boolean).join(' ');
}
let auto = true, focusIdx = 0;
btns.forEach((b, i) => {
  b.addEventListener('click', () => {
    auto = false;
    b.setAttribute('aria-pressed', String(b.getAttribute('aria-pressed') !== 'true'));
    const active = btns.map((x, xi) => (x.getAttribute('aria-pressed') === 'true' ? CODES[xi] : null)).filter(Boolean);
    apply(active);
  });
});
document.getElementById('rttb').addEventListener('keydown', (e) => {
  if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
  auto = false; e.preventDefault();
  btns[focusIdx].tabIndex = -1;
  focusIdx = (focusIdx + (e.key === 'ArrowRight' ? 1 : -1) + btns.length) % btns.length;
  btns[focusIdx].tabIndex = 0; btns[focusIdx].focus();
});
let si = 0;
setInterval(() => { if (!auto) return; si = (si + 1) % STYLES.length; apply(STYLES[si]); }, 1000);

각 버튼은 aria-pressed="true"/"false"로 지금 켜져 있는지 알리는데, 이건 메뉴 항목이 쓰는 aria-expanded나 체크 가능한 메뉴 항목의 aria-checked와는 다른 값입니다 — 순수하게 눌림/안 눌림 두 상태만 표현하기 때문입니다.

이 사전의 "툴바" 항목이 다루는 일반 패턴의 특수한 사례입니다. 로빙 탭인덱스·방향키 규칙은 그대로 물려받지만, 서식 툴바만의 특징은 버튼 상태가 고정돼 있지 않고 캐럿이나 선택 영역이 옮겨갈 때마다 "지금 커서가 있는 자리는 굵게인가?"를 다시 계산해 버튼 눌림 상태를 동기화해야 한다는 점입니다. 메뉴바와는 아예 다릅니다 — 메뉴바 항목은 눌렀을 때 하위 메뉴가 펼쳐지지만, 서식 툴바의 버튼은 펼쳐지는 것 없이 그 자리에서 바로 상태를 토글합니다(링크 삽입처럼 팝업이 필요한 항목만 예외).

버튼 그룹 전체는 하나의 Tab 정지점만 가져야 합니다 — 방향키로 버튼 사이를 이동하고(로빙 탭인덱스), Tab은 툴바를 통째로 건너뛰어 다음 위젯(본문 편집 영역)으로 갑니다.