- 발행일
disabled를 쓰면 "왜 안 되는지"를 말할 수 없다 — 잠긴 버튼에 사유를 붙인 회고
disabled를 쓰면 "왜 안 되는지"를 말할 수 없다
메타클래스의 구성 속성을 고르는 화면에는 속성마다 "키" 버튼이 있습니다. 그 속성의 값을 데이터 이름으로 쓰겠다는 지정이에요. 아무 속성이나 키가 될 수는 없습니다. 서버 검증이 두 갈래로 막고 있어요.
- 필수 속성이어야 한다 — 안 채울 수도 있는 값을 이름으로 쓸 수 없으니까
- 객체 참조가 아니어야 한다 — 이름은 리터럴 텍스트라 다른 인스턴스를 가리키는 값은 원천이 될 수 없음
화면은 이 규칙을 그대로 옮겨 조건에 안 맞는 버튼을 잠가뒀습니다. 사유도 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 | 코드 몇 줄 | 래퍼에 호버하면 뜨지만 버튼 위에서는 여전히 안 뜸(버튼이 이벤트를 먹지도, 통과시키지도 않는 구간이 생김). 정확히 버튼을 가리켰을 때 안 뜨는 게 제일 헷갈림 | 기각 |
③ disabled → aria-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 페이로드 변경이라 기획 확인 대기 중입니다.