command 및 commandfor 소개

게시: 2025년 3월 7일

버튼은 동적 웹 애플리케이션을 만드는 데 필수적입니다. 버튼은 메뉴를 열고, 작업을 전환하고, 양식을 제출합니다. 웹에서 상호작용의 기반을 제공합니다. 버튼을 간단하고 접근하기 쉽게 만들면 예상치 못한 문제가 발생할 수 있습니다. 마이크로 프런트엔드 또는 구성요소 시스템을 빌드하는 개발자는 필요 이상으로 복잡해지는 솔루션을 경험할 수 있습니다. 프레임워크가 도움이 되지만 웹에서 더 많은 작업을 할 수 있습니다.

Chrome 135에서는 새로운 commandcommandfor 속성을 사용하여 선언적 동작을 제공하는 새로운 기능을 도입하여 popovertargetactionpopovertarget 속성을 개선하고 대체합니다. 이러한 새 속성을 버튼에 추가하면 브라우저가 단순성과 접근성 관련 몇 가지 핵심 문제를 해결하고 내장된 공통 기능을 제공할 수 있습니다.

기존 패턴

프레임워크 없이 버튼 동작을 빌드하면 프로덕션 코드가 발전함에 따라 몇 가지 흥미로운 문제가 발생할 수 있습니다. HTML은 버튼에 onclick 핸들러를 제공하지만 콘텐츠 보안 정책 (CSP) 규칙으로 인해 데모나 튜토리얼 외부에서는 이러한 핸들러가 허용되지 않는 경우가 많습니다. 이러한 이벤트는 버튼 요소에서 전달되지만 버튼은 일반적으로 다른 요소를 제어하기 위해 페이지에 배치되므로 코드가 한 번에 두 요소를 제어해야 합니다. 또한 보조 기술 사용자도 이 상호작용에 액세스할 수 있어야 합니다. 이로 인해 코드가 다음과 같이 표시되는 경우가 많습니다.

<div class="menu-wrapper">
  <button class="menu-opener" aria-expanded="false">
    Open Menu
  </button>
  <div popover class="menu-content">
    <!-- ... -->
  </div>
</div>
<script type="module">
document.addEventListener('click', e => {  
  const button = e.target;
  if (button.matches('.menu-opener')) {
    const menu = button
      .closest('.menu-wrapper')
      .querySelector('.menu-content');
    if (menu) {
      button.setAttribute('aria-expanded', 'true');
      menu.showPopover();
      menu.addEventListener('toggle', e => {
        // reset back to aria-expanded=false on close
        if (e.newState == 'closed') {
          button.setAttribute('aria-expanded', 'false');
        }
      }, {once: true})
    }
  }
});
</script>

이 접근 방식은 약간 취약할 수 있으며 프레임워크는 인체공학을 개선하는 것을 목표로 합니다. React와 같은 프레임워크의 일반적인 패턴은 클릭을 상태 변경에 매핑하는 것입니다.

function MyMenu({ children }) {
  const [isOpen, setIsOpen] = useState(false);
  const open = useCallback(() => setIsOpen(true), []);
  const handleToggle = useCallback((e) => {
      // popovers have light dismiss which influences our state
     setIsOpen(e.newState === 'open')
  }, []);
  const popoverRef = useRef(null);
  useEffect(() => {
    if (popoverRef.current) {
      if (isOpen) {
        popoverRef.current.showPopover();
      } else {
        popoverRef.current.hidePopover();
      }
    }
  }, [popoverRef, isOpen]);
  return (
    <>
      <button onClick={open} aria-expanded={isOpen}>
        Open Menu
      </button>
      <div popover onToggle={handleToggle} ref={popoverRef}>
        {children}
      </div>
    </>
  );
}

다른 많은 프레임워크에서도 유사한 인체공학을 제공하는 것을 목표로 합니다. 예를 들어 AlpineJS에서는 다음과 같이 작성할 수 있습니다.

<div x-data="{open: false}">
  <button @click="open = !open; $refs.popover.showPopover()" :aria-expanded="open">
    Open Menu
  </button>
  <div popover x-ref="popover" @toggle="open = $event.newState === 'open'">
    <!-- ... -->
  </div>
</div>

Svelte에서 작성하면 다음과 같이 표시됩니다.

<script>
  let popover;
  let open = false;
  function togglePopover() {
    open ? popover.hidePopover() : popover.showPopover();
    open = !open;
  }
</script>
<button on:click={togglePopover} aria-expanded={open}>
  Open Menu
</button>
<div bind:this={popover} popover>
  <!-- ... -->
</div>

일부 디자인 시스템 또는 라이브러리는 상태 변경을 캡슐화하는 버튼 요소 주위에 래퍼를 제공하여 한 단계 더 나아갈 수 있습니다. 이렇게 하면 트리거 구성요소 뒤의 상태 변경사항이 추상화되어 약간의 유연성을 희생하여 인체공학을 개선할 수 있습니다.

import {MenuTrigger, MenuContent} from 'my-design-system';
function MyMenu({children}) {
  return (
    <MenuTrigger>
      <button>Open Menu</button>
    </MenuTrigger>
    <MenuContent>{children}</MenuContent>
  );
}

명령어 및 commandfor 패턴

이제 commandcommandfor 속성을 사용하여 버튼이 다른 요소에서 선언적으로 작업을 실행할 수 있으므로 유연성을 희생하지 않고도 프레임워크의 인체공학을 구현할 수 있습니다. commandfor 버튼은 for 속성과 마찬가지로 ID를 사용하지만 command는 내장 값을 허용하므로 더 이식성이 높고 직관적인 접근 방식을 사용할 수 있습니다.

예: 명령어 및 commandfor가 있는 메뉴 열기 버튼

다음 HTML은 브라우저가 로직과 접근성을 처리할 수 있도록 버튼과 메뉴 간에 선언적 관계를 설정합니다. aria-expanded를 관리하거나 JavaScript를 추가할 필요가 없습니다.

<button commandfor="my-menu" command="show-popover">
Open Menu
</button>
<div popover id="my-menu">
  <!-- ... -->
</div>

commandcommandforpopovertargetactionpopovertarget 비교

이전에 popover를 사용한 적이 있다면 popovertargetpopovertargetaction 속성에 익숙할 수 있습니다. commandforcommand와 유사하게 작동하지만 팝오버에만 적용됩니다. commandcommandfor 속성은 이러한 이전 속성을 완전히 대체합니다. 새 속성은 이전 속성이 지원하는 모든 기능을 지원하고 새로운 기능을 추가합니다.

내장 명령어

command 속성에는 대화형 요소의 다양한 API에 매핑되는 일련의 기본 동작이 있습니다.

  • show-popover: el.showPopover()에 매핑됩니다.
  • hide-popover: el.hidePopover()에 매핑됩니다.
  • toggle-popover: el.togglePopover()에 매핑됩니다.
  • show-modal: dialogEl.showModal()에 매핑됩니다.
  • close: dialogEl.close()에 매핑됩니다.

이러한 명령어는 JavaScript 상응하는 명령어에 매핑되며 접근성 (예: aria-detailsaria-expanded 등가 관계 제공), 포커스 관리 등을 간소화합니다.

예: commandcommandfor가 포함된 확인 대화상자

<button commandfor="confirm-dialog" command="show-modal">
  Delete Record
</button>
<dialog id="confirm-dialog">
  <header>
    <h1>Delete Record?</h1>
    <button commandfor="confirm-dialog" command="close" aria-label="Close" value="close">
      <img role="none" src="/close-icon.svg">
    </button>
  </header>
  <p>Are you sure? This action cannot be undone</p>
  <footer>
    <button commandfor="confirm-dialog" command="close" value="cancel">
      Cancel
    </button>
    <button commandfor="confirm-dialog" command="close" value="delete">
      Delete
    </button>
  </footer>
</dialog>

Delete Record 버튼을 클릭하면 대화상자가 모달로 열리고 Close, Cancel 또는 Delete 버튼을 클릭하면 대화상자가 닫히면서 버튼 값과 일치하는 returnValue 속성이 있는 "close" 이벤트가 대화상자에 전달됩니다. 이렇게 하면 다음에 실행할 작업을 결정하기 위해 대화상자의 단일 이벤트 리스너 외에도 JavaScript가 필요하지 않습니다.

dialog.addEventListener("close", (event) => {
  if (event.target.returnValue == "cancel") {
    console.log("cancel was clicked");
  } else if (event.target.returnValue == "close") {
    console.log("close was clicked");
  } else if (event.target.returnValue == "delete") {
    console.log("delete was clicked");
  }
});

맞춤 명령어

내장 명령어 외에도 -- 접두사를 사용하여 맞춤 명령어를 정의할 수 있습니다. 맞춤 명령어는 내장 명령어와 마찬가지로 타겟 요소에 "command" 이벤트를 전달하지만 그 외에는 내장 값과 같이 추가 로직을 실행하지 않습니다. 따라서 래퍼 구성요소를 제공하거나 타겟 요소의 DOM을 탐색하거나 버튼 클릭을 상태 변경에 매핑하지 않고도 다양한 방식으로 버튼에 반응할 수 있는 구성요소를 유연하게 빌드할 수 있습니다. 이렇게 하면 구성요소의 HTML 내에서 API를 제공할 수 있습니다.

<button commandfor="the-image" command="--rotate-landscape">
 Landscape
</button>
<button commandfor="the-image" command="--rotate-portrait">
 Portrait
</button>

<img id="the-image" src="photo.jpg">

<script type="module">
  const image = document.getElementById("the-image");
  image.addEventListener("command", (event) => {
   if ( event.command == "--rotate-landscape" ) {
    image.style.rotate = "-90deg"
   } else if ( event.command == "--rotate-portrait" ) {
    image.style.rotate = "0deg"
   }
  });
</script>

ShadowDOM의 명령어

commandfor 속성은 ID를 사용하므로 shadow DOM을 교차하는 것과 관련하여 제한사항이 있습니다. 이 경우 JavaScript API를 사용하여 그림자 루트 전체에서 요소를 설정할 수 있는 .commandForElement 속성을 설정할 수 있습니다.

<my-element>
  <template shadowrootmode=open>
    <button command="show-popover">Show popover</button>
    <slot></slot>
  </template>
  <div popover><!-- ... --></div>
</my-element>
<script>
customElements.define("my-element", class extends HTMLElement {
  connectedCallback() {
    const popover = this.querySelector('[popover]');
    // The commandForElement can set cross-shadow root elements.
    this.shadowRoot.querySelector('button').commandForElement = popover;
  }
});
</script>

향후 제안서에서는 참조 타겟 제안서와 같이 그림자 경계를 넘어 참조를 공유하는 선언적 방법을 제공할 수 있습니다.

다음 단계

Google은 웹사이트에서 사용하는 일반적인 기능을 다루기 위해 새로운 내장 명령어의 가능성을 계속 모색할 예정입니다. 제안된 아이디어는 개방형 UI 제안서에서 다룹니다. 이미 살펴본 아이디어 중 일부는 다음과 같습니다.

  • <details> 요소 열기 및 닫기
  • <input><select> 요소의 "show-picker" 명령어로, showPicker()에 매핑됩니다.
  • <video><audio> 요소의 재생 명령어입니다.
  • 요소에서 텍스트 콘텐츠를 복사합니다.

커뮤니티의 의견을 환영합니다. 제안사항이 있으면 언제든지 Open UI Issue Tracker에서 문제를 제출해 주세요.

자세히 알아보기

commandcommandfor에 관한 자세한 내용은 사양MDN을 참고하세요.