발행일

disabled를 쓰면 "왜 안 되는지"를 말할 수 없다 — 잠긴 버튼에 사유를 붙인 회고

disabled를 쓰면 "왜 안 되는지"를 말할 수 없다

메타클래스의 구성 속성을 고르는 화면에는 속성마다 "키" 버튼이 있습니다. 그 속성의 값을 데이터 이름으로 쓰겠다는 지정이에요. 아무 속성이나 키가 될 수는 없습니다. 서버 검증이 두 갈래로 막고 있어요.

  1. 필수 속성이어야 한다 — 안 채울 수도 있는 값을 이름으로 쓸 수 없으니까
  2. 객체 참조가 아니어야 한다 — 이름은 리터럴 텍스트라 다른 인스턴스를 가리키는 값은 원천이 될 수 없음

화면은 이 규칙을 그대로 옮겨 조건에 안 맞는 버튼을 잠가뒀습니다. 사유도 title로 달아뒀고요.

'<button type="button" data-name-source="' + escapeHtml(id) + '"' +
  (selectable ? "" : " disabled") +
  ' title="' +
  (selectable
    ? "데이터 이름(키)으로 사용할 속성"
    : objectProperty
      ? "클래스 참조(객체) 속성은 키로 사용할 수 없습니다"
      : "필수 속성만 키로 사용할 수 있습니다") +
  '" class="' + ...

써놓고 보면 완벽합니다. 실제로는 아무리 호버해도 안 뜹니다.

disabled는 이벤트를 통째로 죽인다

disabled가 붙은 폼 컨트롤은 마우스·키보드 이벤트를 아예 발생시키지 않습니다. 클릭만 막는 게 아니라 mouseover도, 포커스도, 그리고 브라우저가 title을 띄우는 그 동작도 같이 죽어요.

disabled + title"왜 안 되는지 적어뒀지만 아무도 읽을 수 없는 상태" 입니다. 접근성 문서에서 "disabled 대신 aria-disabled를 검토하라"는 얘기를 볼 때 저는 스크린리더 얘기인 줄만 알았는데, 마우스 쓰는 사람한테도 똑같이 안 보이는 거였습니다.

무엇으로 바꿀 것인가

방법장점포기하는 것판단
disabled 유지 + 버튼 옆에 사유 텍스트 상시 노출이벤트 문제 없음. 가장 단순칩 하나에 사유 한 줄씩 붙어 목록이 두 배로 길어짐. 대부분의 칩은 잠겨 있어서 화면이 사유로 도배됨기각
disabled 유지 + 래퍼 <span>에 title코드 몇 줄래퍼에 호버하면 뜨지만 버튼 위에서는 여전히 안 뜸(버튼이 이벤트를 먹지도, 통과시키지도 않는 구간이 생김). 정확히 버튼을 가리켰을 때 안 뜨는 게 제일 헷갈림기각
disabledaria-disabled="true", 클릭은 핸들러에서 차단이벤트가 살아 있어 호버·포커스·클릭 어디로 닿아도 사유를 띄울 수 있음. 탭 순서에도 남아 키보드 사용자가 발견 가능버튼이 "진짜로는" 활성이라, 클릭 차단을 코드로 계속 지켜야 함. 빠뜨리면 잠긴 버튼이 눌림채택
④ 아예 버튼을 안 그림가장 깔끔한 화면"이 속성에는 키를 지정할 수 있나?"라는 질문에 화면이 침묵함. 없는 것과 잠긴 것을 구별 못 함기각

③으로 갔습니다. 회색 모습은 그대로 두고, 의미만 aria-disabled로 옮겼습니다.

const reason = objectProperty
  ? "클래스 참조(객체) 속성은 키로 사용할 수 없습니다"
  : "필수 속성만 키로 사용할 수 있습니다";
return (
  '<button type="button" data-name-source="' + escapeHtml(id) + '"' +
  // 잠긴 버튼에 disabled 를 쓰지 않는다 — 마우스·키보드 이벤트가 모두 죽어
  // '왜 못 쓰는지'를 알릴 수단이 사라진다(네이티브 title 조차 안 뜬다).
  // 회색 모습은 그대로 두고, 클릭은 아래 핸들러에서 aria-disabled 로 막는다.
  (selectable
    ? ' title="데이터 이름(키)으로 사용할 속성"'
    : ' aria-disabled="true" data-lock-reason="' + escapeHtml(reason) +
      '" aria-label="키 — ' + escapeHtml(reason) + '"') +
  ' class="' + FLAG_BTN_BASE + ...
);

aria-label에 사유를 함께 넣은 건, 스크린리더로 읽을 때 "키" 한 글자만 들리면 왜 못 누르는지 알 수 없기 때문입니다.

그 다음에 나온 세 가지 문제

여기서 끝날 줄 알았는데, 말풍선을 실제로 띄우기 시작하니 문제가 셋 나왔습니다.

1. 스크롤 패널에서 잘린다

'선택된 항목' 목록은 overflow-y-auto입니다. 그 안에 말풍선을 그리면 마지막 행에서 아래가, 오른쪽 끝에서 옆이 잘려요. body에 붙이고 fixed로 띄웠습니다.

2. 말풍선이 버튼보다 열 배 넓다

사유 문장이 길어서 말풍선이 버튼보다 훨씬 넓습니다. 보통 하듯 버튼 가운데에 맞추면 절반이 패널 밖으로 삐져나가고, 어느 칩의 사유인지가 흐려집니다. 이 버튼들은 패널 오른쪽 끝에 붙어 있으니 오른쪽 끝을 맞추는 게 맞았어요.

lockTip.textContent = btn.dataset.lockReason;
lockTip.classList.remove("hidden");
const rect = btn.getBoundingClientRect();
const width = lockTip.offsetWidth;
// 버튼 가운데가 아니라 오른쪽 끝에 맞춘다 — 말풍선이 버튼보다 열 배 넓은데
// 이 버튼들은 패널 오른쪽 끝에 붙어 있어, 가운데를 잡으면 절반이 패널 밖으로
// 삐져나가 어느 칩의 사유인지 흐려진다.
lockTip.style.left =
  Math.max(8, Math.min(rect.right - width, window.innerWidth - width - 8)) + "px";
lockTip.style.top = rect.bottom + 6 + "px";

3. 휠 관성 때문에 떴다 사라진다

fixed로 띄웠으니 패널이 스크롤되면 말풍선만 제자리에 남습니다. 그래서 처음엔 스크롤 시 그냥 감췄는데, 트랙패드 관성 스크롤이 호버가 멈춘 뒤에도 이어져서 말풍선이 떴다 사라졌다를 반복했습니다.

감추는 대신 칩을 따라 자리를 다시 잡게 했습니다. 여기서 한 번 더 걸린 게 있어요.

selectedEl.addEventListener("scroll", function () {
  if (!lockTipBtn) return;
  const btnRect = lockTipBtn.getBoundingClientRect();
  const listRect = selectedEl.getBoundingClientRect();
  // 여기서 hideLockTip() 을 부르면 안 된다 — 기준 칩까지 지워버려서, 되돌려
  // 스크롤해도 마우스가 그대로면 다시 뜨지 않는다. 감추기만 하고 기준은 남긴다.
  if (btnRect.top < listRect.top || btnRect.bottom > listRect.bottom) {
    lockTip.classList.add("hidden");
  } else {
    showLockTip(lockTipBtn);
  }
});

hideLockTip()lockTipBtn = null까지 합니다. 스크롤 밖으로 나갔을 때 이걸 부르면 기준 칩을 잃어서, 되돌려 스크롤해도 마우스가 그대로 있는 한 다시 안 떠요. "감춘다"와 "잊는다"를 같은 함수로 묶어뒀던 게 원인이었습니다.

다시 그릴 때도 정리가 필요했습니다. 칩 목록을 innerHTML로 다시 그리면 말풍선이 가리키던 버튼이 사라지는데, fixed로 body에 붙어 있는 말풍선은 그대로 허공에 남습니다.

function renderSelected() {
  // 다시 그리면 말풍선이 가리키던 버튼이 사라진다 — 허공에 남지 않게 먼저 닫는다.
  hideLockTip();
  selectedEl.innerHTML = "";
  ...

화면과 서버가 규칙을 하나씩만 알고 있었다

말풍선을 붙이고 나서야 드러난 결함이 하나 더 있었습니다. 서버는 키 조건을 봅니다(필수 + 객체 아님). 그런데 화면은 필수만 반영하고 있었어요.

그래서 객체 참조 속성을 "필수"로 올리면, 화면에서 '키' 버튼이 풀립니다. 눌러서 지정하고 저장하면 서버가 400으로 튕깁니다. 화면은 된다고 하고 저장에서만 막히는, 제일 안 좋은 형태였습니다.

속성 옵션 페이로드에 is_object를 실어 화면도 두 조건을 다 보게 했습니다. 사유 문구도 조건별로 갈랐고요(위 reason 분기).

이게 왜 여태 안 걸렸냐면, 화면 규칙과 서버 규칙이 각자 적혀 있었기 때문입니다. 서버 serializers/meta_class.py의 두 갈래를 JS가 손으로 옮겨 적은 구조라, 한쪽이 늘어도 다른 쪽은 모릅니다. 지금은 주석으로 출처를 못박아 뒀지만 근본 해결은 아니에요.

// 키(이름 원천)는 ①필수이고 ②객체 참조가 아닌 속성만 고를 수 있다 — 서버 검증
// 두 갈래(serializers/meta_class.py)를 그대로 옮긴 것.

같은 원칙을 "0건" 안내에도

며칠 뒤 인스턴스 편집 화면에서 같은 성격의 문제를 만났습니다. 객체 슬롯에 넣을 후보가 없을 때 "선택할 Message 인스턴스가 없습니다" 라고만 떠 있었어요. 사유는 말했는데 다음 행동이 없습니다. 관리자는 이제 어디로 가야 하는지 모릅니다.

클래스 이름을 링크로 만들고, 누르면 트리에서 그 클래스 노드로 이동하게 했습니다. 세 가지를 같이 처리했습니다.

  • 필수 여부로 좁혀진 화면이면 스코프를 전체로 넓힌 뒤 이동 — 안 그러면 링크를 눌렀는데 그 노드가 화면에 없습니다
  • 링크 대상은 선언된 range. 둘 이상이면 어디로 보낼지 정할 수 없으니 링크 없이 문구만
  • 이동 전에 편집 이탈 확인, 이후 프로그램이 하는 선택은 중복 확인 안 함

"왜 안 되는지"를 말했으면 "그럼 어떻게 하는지"까지 가야 한다는 게 이 두 작업의 공통점이었습니다.

검증

  • Playwright 회귀 잠금 추가: 말풍선 문구 일치, 잠긴 버튼 클릭 무효 단언.
  • 여기서 걸린 것 — Playwright는 aria-disabled="true"를 비활성으로 봅니다. 그냥 click()하면 "element is not enabled"로 죽어요. force: true를 줘서 실제로 클릭 이벤트를 쏘고, 그래도 상태가 안 바뀌는지를 단언해야 했습니다. 도구가 ARIA를 읽는다는 걸 여기서 처음 실감했습니다.
  • 재사용 규칙을 ui-conventions.md §6으로 신설 — 잠긴 컨트롤은 disabled 대신 aria-disabled + 사유 말풍선.
  • 서버 400 케이스: is_object 판정(Django 테스트) + 키 버튼 잠금(Playwright) 양쪽 잠금.

남은 것 · 한계

  • 클릭 차단이 핸들러 책임입니다. aria-disabled는 브라우저가 강제하지 않아요. 새 코드에서 이 버튼을 다른 경로로 트리거하면 그냥 눌립니다. 지금은 회귀 스펙 하나가 지키고 있을 뿐이라, 컨트롤 팩토리를 만들어 잠금·사유·차단을 한 묶음으로 내보내는 게 맞습니다.
  • 말풍선이 이 파일 안에 있습니다. 같은 패턴을 쓰는 화면이 이미 셋인데(속성 도움말 ? 배지, 분류 칩 ? 배지, 이 잠금 사유) 마크업이 각자 있어요. §6에 규칙만 적어둔 상태라, 공용 유틸로 빼기 전까지는 문구가 갈라질 위험이 남습니다.
  • 화면과 서버의 키 조건은 여전히 두 곳에 따로 적혀 있습니다. 서버가 조건을 셋으로 늘리면 화면은 또 모릅니다. 조건을 페이로드로 내려주는 형태가 맞는데, BE 페이로드 변경이라 기획 확인 대기 중입니다.

관련 글: 플래그를 잘못된 테이블에 달아뒀다 · Playwright로 메뉴 136종 QA 스윕