Invoker Commands API를 사용하면 버튼이 HTML 속성을 사용하여 선언적으로 타겟 요소에서 작업을 트리거할 수 있습니다.
이 접근 방식은 수동 이벤트 리스너의 필요성을 줄이고 UI를 구현 세부정보에서 분리합니다.
애플리케이션별 맞춤 작업의 경우 자체 명령어 이름을 정의할 수 있습니다. 향후 기본 제공 브라우저 명령어와의 충돌을 방지하려면 커스텀 명령어 앞에 이중 대시 (--)를 붙여야 합니다.
구현 단계
- 타겟 요소 정의: 작업에 응답할 요소를 식별합니다. 고유한
id가 없으면 추가합니다. - 호출자 버튼 구성:
commandfor속성을 사용하여 타겟의id를 가리키고command속성을 사용하여 커스텀 명령어 이름 (접두사--)을 지정합니다. - 명령어 이벤트 처리: 타겟 요소에 직접
command이벤트 리스너를 연결합니다. 이벤트 객체에는command속성과target속성(commandfor로 식별되는 요소 참조)이 포함됩니다. - aria 상태 처리: 맞춤 명령어에는 고유한 시맨틱이 없으므로
aria-pressed또는aria-expanded와 같은 상태를 처리해야 합니다.
예: 맞춤 애니메이션 컨트롤
<!-- The target element that will respond to custom commands -->
<div id="action-target" class="target">
Action Target
</div>
<!-- Buttons declaratively linked to the target element -->
<!-- Each button sends a unique custom command starting with '--' -->
<button commandfor="action-target" command="--spin">
Spin
</button>
<button commandfor="action-target" command="--grow">
Grow
</button>
<button commandfor="action-target" command="--reset">
Reset All
</button>
<script>
// 1. **Optional:** Define a registry of requested actions for cleaner logic
const commandRegistry = {
'--spin': (target, source) => {
const isSpun = target.classList.toggle('is-spun');
// Set ARIA states, as custom commands have no inherent semantics.
source?.setAttribute('aria-pressed', isSpun);
},
'--grow': (target, source) => {
const isGrown = target.classList.toggle('is-grown');
source?.setAttribute('aria-pressed', isGrown);
},
'--reset': (target) => {
target.classList.remove('is-spun', 'is-grown');
// Reset all associated buttons' ARIA states
document.querySelectorAll(`button[commandfor="${target.id}"]`).forEach(btn => {
btn.setAttribute('aria-pressed', 'false');
});
},
};
// 2. **Mandatory:** Listen for the 'command' event directly on the target element
// (This is necessary because the native 'command' event does not bubble)
document.getElementById('action-target').addEventListener('command', (event) => {
const command = event.command;
const target = event.target;
const source = event.source; // event.source refers to the triggering button
const action = commandRegistry[command];
if (action) {
action(target, source);
}
});
</script>
주요 제약 조건
- 커스텀 명령어 프리픽스: 필수: 모든 커스텀 명령어 이름은
--로 시작해야 합니다(예:command="--my-action"). - 타겟팅:
commandfor속성은 동일한 문서 트리에 있는 요소의id와 일치해야 합니다. - 버블링 없음:
command이벤트는 버블링되지 않습니다. 가능한 타겟이 여러 개 있는 경우 이벤트 핸들러에{ capture: true }를 추가하고 상위 요소를 수신 대기합니다. - 섀도 루트: 타겟이 섀도 루트에 있을 수 있는 경우
event.target대신event.composedPath()[0]를 사용합니다. - 접근성: 맞춤 명령어에는 고유한 시맨틱스가 없으므로 상태를 명시적으로 적용해야 합니다.
접근성 고려사항
- 버튼에 추가 콘텐츠가 표시되는 경우
aria-expanded속성을 사용합니다. 이 속성은 기본적으로aria-expanded="false"로 설정되고 함께 제공되는 콘텐츠가 표시되면aria-expanded="true"로 설정됩니다. - 버튼이 상호작용 자체를 용이하게 하는 경우 (예: 재생 및 일시중지할 수 있는 전환 버튼, 음소거 및 음소거 해제할 수 있는 볼륨 버튼)
aria-pressed속성을 사용하여 비활성 상태일 때는aria-pressed="false"로, 활성 상태일 때는aria-pressed="true"로 설정합니다. - 버튼이
menu,listbox,tree,grid또는dialog역할이 있는 요소를 여는 경우 컨트롤에aria-haspopup속성이 있어야 하며, 이 속성의 값은 열리는 요소의 역할로 설정되어야 합니다. - 활성화 시 버튼이 사용 중지되는 경우
disabled대신aria-disabled="true"를 사용하여 포커스가 손실되지 않도록 합니다.
대체 전략
Invoker Commands API가 지원되지 않으면 command 이벤트가 실행되지 않습니다. 모든 최신 브라우저에서 완벽하게 지원하려면 npm install 또는 CDN을 통해 https://github.com/keithamus/invokers-polyfill의 invokers-polyfill을 사용하는 것이 좋습니다.
이 폴리필은 맞춤 액션(--로 시작)을 완전히 지원하며 네이티브 API와 정확히 동일하게 command 이벤트를 전달합니다.
동적 가져오기 (성능 최적화)
최상의 성능을 위해서는 브라우저가 API를 기본적으로 지원하지 않는 경우에만 폴리필을 로드해야 합니다. 이렇게 하면 최신 브라우저를 사용하는 사용자의 대역폭이 절약되고 스크립트 실행 시간이 단축됩니다.
참고: 이 polyfill은 맞춤 명령어의 ARIA 상태 (예: aria-pressed 또는 aria-expanded)를 관리하지 않습니다. 사이트에 액세스할 수 있도록 이벤트 리스너에서 이러한 상태를 수동으로 동기화해야 합니다.
// 1. Conditionally load the polyfill
const hasNativeSupport = 'commandForElement' in HTMLButtonElement.prototype;
if (!hasNativeSupport) {
// Wrap in an async IIFE to avoid top-level await issues in older browsers
(async () => {
try {
await import('https://esm.run/invokers-polyfill');
} catch (err) {
console.error('Error loading fallback:', err);
}
})();
}
// 2. Manually manage ARIA states in your listener
document.getElementById('action-target').addEventListener('command', (event) => {
const command = event.command;
const target = event.target;
const source = event.source; // The button that triggered the command
if (command === '--spin') {
const isSpun = target.classList.toggle('is-spun');
// Polyfill tip: Manually update ARIA to match the new state
source?.setAttribute('aria-pressed', isSpun);
}
});
수동 대체 (기존 패턴)
폴리필을 사용하지 않으려면 이벤트 위임을 사용하여 이벤트를 전달하고 명령어 레지스트리를 사용하여 작업을 처리할 수 있습니다. 이는 기존 JavaScript 개발에서 흔히 사용되는 아키텍처 패턴으로, 여전히 매우 효율적이고 확장성이 뛰어납니다.
// 1. **Optional:** Define a registry of requested actions for cleaner logic
const commandRegistry = {
'--spin': (target) => target.classList.toggle('is-spun'),
'--grow': (target) => target.classList.toggle('is-grown'),
'--reset': (target) => target.classList.remove('is-spun', 'is-grown'),
};
// 2. If CommandEvent doesn't exist, we assume no native support and provide the fallback
if (!globalThis.CommandEvent) {
globalThis.CommandEvent = class CommandEvent extends Event {
constructor(type, { source, command, ...options } = {}) {
super(type, options);
this.source = source;
this.command = command;
}
}
}
// 3. The fallback: Dispatch events manually if native support is missing
document.addEventListener('click', (event) => {
const button = event.composedPath().find((el) => el.matches?.("button[commandfor]"));
if (!button) return;
const target = document.getElementById(button.getAttribute('commandfor'));
const command = button.getAttribute('command');
if (target && command) {
target.dispatchEvent(new CommandEvent('command', {
command,
source: button,
}));
}
});
// 4. **Mandatory:** Register the unified listener directly on the target element
document.getElementById('action-target').addEventListener('command', (event) => {
const command = event.command;
const target = event.target;
const action = commandRegistry[command];
if (action) {
action(target);
}
});