드롭다운 메뉴 빌드

드롭다운 메뉴 빌드

이 가이드에서는 드롭다운, 컨텍스트, 더보기, 이를 여는 메뉴 바 등 모든 팝업 메뉴를 다룹니다. 이 예시는 항목이 인터페이스를 변경하는 (만들기, 저장, 환경설정) 애플리케이션 명령어 메뉴입니다.

두 가지 독립적인 선택입니다. 그 외의 모든 사항은 두 설정에 모두 적용됩니다.

  • 항목당 요소 명령어는 <button type="button">이고 도착 페이지는 새 탭에서 열리고 URL로 복사되며 스크립트 없이 작동하는 <a href>입니다. 드롭다운은 링크 2개와 명령어 6개, 또는 명령어 1개와 링크 6개 등 자유롭게 혼합할 수 있으며, 아래 내용은 변경되지 않습니다.
  • 상호작용 모델(드롭다운별) 화살표 키, Home/End, Esc를 사용하여 메뉴로 만듭니다. 포커스 그룹을 추가하고, 버튼이든 링크든 모든 항목에서 menuitem를 추론하도록 하고, 메뉴처럼 보이게 합니다 (연속 막대 또는 드롭다운 표시기가 있는 트리거, 아래의 '메뉴 바 또는 별도의 메뉴 버튼' 참고). 사용자가 Tab하는 짧은 링크 목록은 탐색입니다. 포커스 그룹, role="menu", role="menuitem", aria-haspopup="menu"가 없습니다. 팝오버, Esc, 포커스 복원을 유지하고 탐색 메뉴 가이드를 참고하세요.

콘텐츠가 역할을 결정하지 않습니다. 링크의 '제품' 드롭다운은 메뉴가 될 수 있지만 명령어의 '계정' 드롭다운은 탐색이 될 수 없습니다.

탭 정지 1개와 화살표 키 탐색이 있는 복합 위젯에만 메뉴 패턴을 사용합니다. HTML <menu> 요소가 아닌 <div focusgroup="menu nomemory">를 사용합니다. 공유 마크업에 role="menu" 또는 role="menuitem"를 추가하지 마세요. 네이티브 포커스 그룹은 이러한 역할을 추론하며 명시적 역할은 이러한 추론을 차단합니다. 아래의 지원되지 않는 브라우저 대체에만 추가하세요. nomemory는 첫 번째 항목에서 다시 열기를 시작합니다. Focusgroup은 포커스를 이동하고 스크립트는 미리보기, 활성화, 포커스 복원을 처리합니다.

메뉴 열기 및 배치

commandfor 및 command="toggle-popover"를 사용하여 각 트리거를 명령어 호출자로 만듭니다. 브라우저는 클릭, Enter, Space에서 팝오버를 열고 닫으며 트리거를 팝오버의 가벼운 닫기 그룹 내에 유지하므로 열린 트리거를 클릭하면 닫힙니다. popovertarget 또는 popovertargetaction를 사용하지 마세요. 이 패턴은 대신 commandfor를 사용합니다.

각 팝오버를 트리거 바로 뒤에 배치합니다. 브라우저는 팝오버가 다음 동위 요소가 아닌 경우에만 암시적 aria-details 관계를 추가하므로 이 배치는 추가 속성 없이 이를 방지합니다.

<!-- Command invoker: the browser toggles the popover on click, Enter, and Space -->
<button id="file-trigger" class="trigger" type="button" aria-haspopup="menu" aria-expanded="false"
  commandfor="file-menu" command="toggle-popover">
  File
</button>
<!-- Immediately after its trigger, so browsers add no implicit aria-details -->
<div id="file-menu" popover="auto">
  <!-- Nested, not on the popover: Chromium otherwise drops focusgroup keyboard behavior -->
  <div focusgroup="menu nomemory" aria-labelledby="file-trigger">
    <button type="button">New file</button>
    <button type="button">Save</button>
    <button id="export-trigger" type="button" aria-haspopup="menu" aria-expanded="false"
      commandfor="export-menu" command="toggle-popover">
      Export <span aria-hidden="true">›</span>
    </button>
    <!-- Submenu: popover="auto" directly after its trigger, inside the parent focusgroup -->
    <div id="export-menu" popover="auto">
      <div focusgroup="menu nomemory" aria-labelledby="export-trigger">
        <button type="button">PDF</button>
        <button type="button">Plain Text</button>
      </div>
    </div>
  </div>
</div>

각 메뉴의 이름을 지정하고 aria-labelledby가 트리거를 가리키도록 합니다. CSS에 장식용 하위 메뉴 표시기를 유지하거나 aria-hidden="true"를 추가합니다. 사용자가 어떤 메뉴가 열려 있거나 미리보기 상태인지 알 수 있도록 aria-expanded="true"가 있는 트리거에 눈에 보이는 활성 스타일을 지정합니다.

최상위 트리거에 대해 하나의 형식을 선택합니다. 역할, 키보드 모델, 스타일이 일치해야 합니다. 시각적으로 구분된 버튼은 각각 하나의 Tab 정지를 나타냅니다.

  • 메뉴 바. 컨테이너의 role="menubar"(팝오버에는 없음)는 트리거 사이에 간격이 없는 하나의 연속된 막대로 스타일이 지정됩니다. Tab 정지 1개, ArrowLeft/ArrowRight 트리거 간 이동, 마우스 오버로 메뉴 전환 애플리케이션 명령어 표시줄에 사용합니다.
  • 별도의 메뉴 버튼. 각 트리거는 자체 Tab 정지이며 독립형 버튼처럼 보입니다. role="menubar" 없음, 트리거 간 화살표 키 없음, 포커스 시 미리보기 없음, 마우스 오버 전환 없음 탐색하는 경우 <nav>로 래핑하고 그렇지 않으면 역할이 없습니다. 툴바 및 페이지 헤더에 사용합니다.

이 가이드의 나머지 부분에서는 메뉴 바 양식을 설명합니다. 별도의 버튼의 경우 각 메뉴 내에 모든 항목을 유지하고 메뉴 바 수준 동작을 삭제합니다.

CSS 앵커 포지셔닝으로 메뉴 배치

JavaScript 좌표가 아닌 CSS 앵커 위치 지정을 사용합니다. command="toggle-popover" 및 showPopover({ source: trigger })는 모두 암시적 앵커를 설정하므로 메뉴별 anchor-name 또는 position-anchor 규칙을 추가하지 마세요. 메뉴 동작이 앵커 위치에 따라 달라지면 안 됩니다.

/* Top-level menus sit below their trigger. The implicit anchor comes from the invoker or source. */
[popover] {
  position-area: block-end span-inline-end;
  inset: auto;
}

/* A popover nested in another popover is a submenu: beside its parent item, flipping when it would overflow. */
[popover] [popover] {
  position-area: inline-end span-block-end;
  position-try-fallbacks: flip-inline, flip-block, --below;
}

/* Last resort for narrow viewports: directly below the trigger, indented, so the submenu never
   covers its trigger. */
@position-try --below {
  position-area: block-end span-inline-end;
  margin-inline-start: 1rem;
  /* Fit the area: the submenu's minimum width must not stop this fallback from fitting. */
  min-inline-size: 0;
  max-inline-size: calc(100% - 1rem);
}

--below을 마지막에 유지하여 어느 쪽도 맞지 않는 경우에만 적용되도록 합니다. 트리거 바로 아래에 열리므로 좁은 화면에서는 트리거 아래의 동위 항목을 가립니다. 이는 트리거 옆에 유지하기 위해 허용되는 절충안입니다. 또한 하위 메뉴를 영역 (min-inline-size: 0, max-inline-size에서 들여쓰기를 뺀 값)으로 축소합니다. 맞지 않는 대체는 건너뛰고, 맞는 대체가 없으면 브라우저는 기본 배치를 유지하므로 몇 픽셀 너무 넓은 하위 메뉴가 트리거를 자동으로 가립니다.

일반 팝오버에서 플립 대체가 작동하는 방식은 표시 영역 인식 드롭다운을 참고하세요. 이 가이드에서는 메뉴가 아닌 버튼 그룹을 배치합니다. 메뉴의 경우 하위 메뉴 배치와 위의 --below 대체가 우선 적용됩니다.

열기 및 닫기 애니메이션

선택사항입니다. 150ms 미만, opacity 및 작은 translate만, 하위 메뉴도 동일, @media (prefers-reduced-motion: no-preference) 내부 포커스가 toggle로 이동하므로 전환으로 인해 키보드 사용자가 지연되지 않습니다.

@media (prefers-reduced-motion: no-preference) {
  /* Closing: this rule is in effect once :popover-open is removed. */
  [popover] {
    opacity: 0;
    translate: 0 -.25rem;
    transition: opacity .15s, translate .15s, display .15s allow-discrete, overlay .15s allow-discrete;
  }
  /* Opening: faster. */
  [popover]:popover-open {
    opacity: 1;
    translate: 0 0;
    transition-duration: .1s;
    @starting-style { opacity: 0; translate: 0 -.25rem; }
  }
  /* Switching between open menubar menus is instant, as in native menubars. */
  [role="menubar"]:has(> [popover]:popover-open) > [popover] {
    transition: none;
  }
}

@starting-style는 [popover]의 반복이 아닙니다. 닫힌 팝오버는 display: none이며 전환은 렌더링되지 않은 상태에서 실행되지 않으므로 열기 전의 값을 제공합니다. 이 속성이 없으면 열기 애니메이션은 바로 실행되고 닫기 애니메이션만 애니메이션으로 표시됩니다. allow-discrete가 있는 display 및 overlay는 닫기 전환이 끝날 때까지 팝오버를 렌더링하고 최상위 레이어에 유지합니다.

키보드 및 포커스

focusgroup 내에 두 번째 roving-tabindex 시스템을 추가하지 마세요. 스크립트는 호출자와 포커스 그룹이 다루지 않는 내용만 다룹니다.

  • 메뉴 바의 탭 정지 1개 Tab는 첫 번째 최상위 트리거에서 입력됩니다. ArrowLeft 및 ArrowRight는 최상위 트리거 간에 이동하며 양쪽 끝에서 줄바꿈됩니다. Home 및 End 트리거는 첫 번째 또는 마지막 항목에서 메뉴로 들어갑니다.
    • 참고: Tab가 반환되는 트리거입니다. APG는 첫 번째 트리거 또는 마지막으로 사용된 트리거를 허용합니다. 가이드에서는 첫 번째를 지정하지만 데모에서는 ArrowLeft 및 ArrowRight를 사용하여 탭 정지를 이동하므로 키보드 탐색 후 Shift+Tab는 마지막으로 도달한 트리거로 돌아갑니다. 포인터를 사용해도 포인터가 이동하지 않으므로 동일한 시퀀스가 다른 트리거에 도달할 수 있습니다. 두 동작 모두 허용됩니다. 하나를 선택하고 일관되게 유지하세요.
  • 미리보기 최상위 트리거에 포커스를 맞추면 메뉴가 표시되고 aria-expanded="true"가 설정됩니다. 포커스는 트리거에 유지되며 어떤 항목에도 포커스가 맞춰지지 않습니다. 트리거를 흐리게 처리하면 미리보기 전용 메뉴가 닫힙니다.
  • 미리보기에서 Enter 및 Space 미리보기를 닫고 트리거에 계속 포커스를 둡니다. 기본 toggle-popover 작업이 이를 처리하므로 스크립트가 이러한 키를 가로채서는 안 됩니다.
  • 미리보기에서 아래쪽 화살표 열린 메뉴로 변환하고 첫 번째 항목에 포커스를 맞춥니다.
  • 열려 있거나 미리보기된 트리거를 포인터로 클릭합니다. 기본적으로 닫히며 메뉴로 들어가지 않습니다.
    • 하위 메뉴 트리거에 관한 알려진 차이점 하위 메뉴 트리거를 포인터로 클릭하면 기본적으로 전환되므로 마우스 오버로 이미 미리 본 하위 메뉴를 닫거나 아직 표시되지 않은 하위 메뉴를 열 수 있습니다. 네이티브 메뉴는 열기만 하고 닫지 않습니다. 이는 무해하므로 가이드에서 허용합니다.
  • 닫힌 트리거를 포인터로 클릭합니다. 기본적으로 열립니다. 포커스가 트리거에 유지되며 포커스가 있는 항목이 없습니다. 키보드만 열리고 메뉴가 표시됩니다.
  • 항목 위로 포인터를 가져갑니다. 링 없이 포커스를 맞추므로 마우스 오버와 포커스가 하나의 항목입니다.
  • 메뉴가 열려 있는 동안 다른 최상위 트리거 위로 포인터를 가져갑니다. 닫고 마우스를 가져간 항목을 미리 보며, 링 없이 트리거에 포커스를 맞춥니다. 열려 있는 항목 없음: 마우스를 가져가도 아무 작업도 수행되지 않습니다.
  • 하위 메뉴 트리거 위로 포인터를 가져갑니다. 잠시 후 하위 메뉴를 미리 봅니다 (포인터 포커스는 동일한 포커스 시 미리보기를 실행함). 동위 항목이 닫습니다.
  • 메뉴 내에서 ArrowUp 및 ArrowDown: 사용 설정된 항목 간에 이동합니다. Home 및 End는 사용 설정된 첫 번째 항목과 마지막 항목으로 이동하며, 이는 스크립트에서 처리합니다.
  • 열려 있는 최상위 메뉴에서 왼쪽 화살표 또는 오른쪽 화살표 메뉴를 닫고 트리거에 포커스를 맞춘 상태에서 인접한 메뉴를 미리 보며 양쪽 끝에서 줄바꿈합니다. ArrowDown이(가) 입력합니다. 왼쪽 및 오른쪽은 메뉴 내에 표시되지 않습니다.
  • 하위 메뉴 트리거에서 ArrowRight 하위 메뉴를 열고 첫 번째 항목에 포커스를 맞춥니다.
  • 하위 메뉴가 없는 하위 메뉴 항목에서 ArrowRight 계층 구조를 닫고 다음 최상위 메뉴를 미리 보며 포커스는 트리거에 유지됩니다. ArrowDown이(가) 입력합니다.
  • 하위 메뉴에서 왼쪽 화살표 또는 Esc 해당 하위 메뉴만 닫고 포커스를 트리거로 복원합니다. 상위 메뉴는 열린 상태로 유지됩니다.
  • 명령어에서 Enter 또는 스페이스바를 누릅니다. 활성화하고 열려 있는 모든 메뉴를 닫습니다.
  • 탭 메뉴 시스템을 종료합니다. 포커스를 가두거나 모든 명령어를 단계별로 실행하지 마세요. 닫을 때 숨겨진 콘텐츠 내에 포커스가 남아 있으면 안 됩니다.

command 이벤트는 toggle-popover가 메뉴를 열기 전에 실행되므로 포커스가 메뉴로 이동할 수 없습니다. 대신 toggle 이벤트에서 포커스를 이동합니다. HTML은 스택의 첫 번째 popover="auto"에만 포커스를 복원하므로 중첩된 수준은 트리거에 포커스를 명시적으로 복원해야 합니다.

상호작용 구현

자체 마크업에 위의 동작을 구현합니다. 화살표, Home/End, 줄바꿈, Tab-to-exit 처리는 일반적인 keydown 코드입니다. 입력 유형별로 아래에 그룹화된 몇 가지 부분은 잘못 입력하기 쉽습니다.

키보드 및 포커스 처리

  1. 하나의 개방형 경로. 키보드로 열면 trigger.click()가 호출되므로 호출자의 toggle-popover가 메뉴를 여는 유일한 방법입니다. toggle 핸들러가 메뉴를 입력하도록 메뉴를 표시합니다. 키보드만 열립니다. 포인터가 열리면 toggle는 링 없이 트리거에 포커스를 맞춥니다. pointerdown가 포커스를 방지했고 화살표 키는 계속 작동해야 하기 때문입니다.
  2. 포커스 미리보기 트리거에 포커스를 맞추면 showPopover({ source: trigger })가 호출되어 메뉴가 트리거에 고정됩니다. 트리거 자체 메뉴 내부 또는 다른 창에서 반환되는 포커스를 무시합니다 (창 blur의 플래그, 창 focus 후 체크 표시 지우기 또는 앱 전환으로 마지막 메뉴 다시 열기). 포커스가 이동하지 않는 한 blur에서 미리보기된 메뉴를 숨깁니다.
  3. command가 아닌 toggle에서 포커스를 이동합니다. command는 메뉴가 열리기 전에 실행됩니다.
  4. 트리거에 포커스를 직접 복원합니다. hidePopover() 전에 트리거에 포커스를 맞춥니다. 포인터 활성화 후 링 없이 포커스를 복원하거나 (focus({ focusVisible: false })) 마우스 클릭 후 트리거가 키보드 포커스 윤곽선을 표시합니다. 키보드 활성화는 링을 유지합니다. event.detail === 0를 사용한 클릭은 키보드에서 발생했습니다.
  5. 한 번에 한 수준씩 닫습니다. 하위 메뉴에서 Escape 및 ArrowLeft에 대해 stopPropagation()를 호출하거나 상위 요소의 리스너도 이를 닫습니다.

포인터 처리

  1. 포인터 클릭 전환 클릭으로 인해 포커스가 이동하지 않고 클릭 중간에 미리보기가 시작되지 않도록 pointerdown에서 preventDefault()를 호출합니다. 어떤 트리거가 눌렸는지 기억합니다. 닫혀 있거나 미리보기된 트리거를 클릭하면 기본적으로 열리거나 닫히며 메뉴로 들어가지 않습니다. 키보드로 열 때만 메뉴로 들어갑니다. 클릭을 가로채지 마세요.
  2. 포커스를 따라 마우스 오버가 이동합니다. :hover 및 :focus-visible의 스타일을 별도로 지정합니다. 항목 위에서 pointermove하면 focus({ focusVisible: false })를 호출합니다. 이 옵션을 무시하는 브라우저는 포인터 포커스에 링을 그리지 않습니다.
  3. 마우스 오버 스위치는 메뉴를 엽니다. 다른 최상위 메뉴가 열려 있는 동안 최상위 트리거의 pointerenter: 메뉴를 숨기고, 마우스 오버된 트리거에 포커스를 맞추고(링 없음), 포커스 시 미리보기(위 참고)에서 미리보기를 표시합니다. 열려 있는 항목 없음: 아무 작업도 하지 않습니다.
  4. 안전 삼각형. 고정된 CSS 브리지는 너무 좁습니다. 포인터가 상위 요소의 가장자리 근처에서 시작할 때만 작동합니다. CSS가 브리지를 그리도록 하고 스크립트를 사용하여 포인터를 보고합니다.
    • matchMedia('(hover: hover)').matches인 경우에만 실행합니다. 터치 및 키보드와는 관련이 없습니다.
    • 하위 메뉴가 열려 있는 하위 메뉴 트리거에서 포인터가 벗어날 때 시작합니다. 측면 가장자리가 없는 --below 하위 메뉴를 건너뜁니다.
    • --bridge-x 및 --bridge-y (포인터, 하위 메뉴를 기준으로 한 픽셀 단위), --bridge-edge (포인터가 왼쪽에 있을 때 하위 메뉴의 가장자리 근처 x: 0, 뒤집혔을 때의 너비) 및 하위 메뉴의 --bridge-height를 설정하고 data-bridge를 설정하여 브리지를 켭니다.
    • 약 300ms 후, 포인터가 하위 메뉴의 항목에 도달하거나 하위 메뉴가 닫히면 중지합니다. 리스너와 속성을 삭제합니다. 그 이후에는 아무것도 실행되지 않습니다.
    • CSS는 하위 메뉴에 포인터에서 가까운 가장자리 모서리까지 삼각형으로 잘린 ::before를 제공합니다. 포인터 경로의 동위 항목 위에 있으므로 하위 메뉴를 닫는 마우스 오버를 수신하지 않습니다. clip-path는 또한 Hit Test를 제한하므로 나머지 보호자 메뉴는 상호작용이 가능한 상태로 유지됩니다.
    • 하위 메뉴에는 overflow: visible가 필요하며(기본적으로 팝오버 클립) 가상 요소가 이를 지나 확장되므로 도형의 포인트를 그만큼 오프셋합니다.
    • 후속 조치: 곡선 경로 (shape() 곡선 명령어)는 직선 삼각형보다 실제 포인터 호를 더 잘 따릅니다.
[popover] [popover] {
  overflow: visible;

  &[data-bridge]::before {
    content: "";
    position: absolute;
    inset: -100vh -100vw; /* Room for the triangle, which can extend far outside the submenu. */
    clip-path: shape(
      from calc(100vw + var(--bridge-x)) calc(100vh + var(--bridge-y)),
      line to calc(100vw + var(--bridge-edge)) 100vh,
      line to calc(100vw + var(--bridge-edge)) calc(100vh + var(--bridge-height)),
      close
    );
  }
}
const canHover = matchMedia('(hover: hover)');

// Call on pointerleave of a submenu trigger, with that trigger's submenu.
function startBridge(submenu, event) {
  if (!canHover.matches || !submenu.matches(':popover-open')) return;
  const rect = submenu.getBoundingClientRect();
  if (event.clientX >= rect.left && event.clientX <= rect.right) return; // --below: no side edge.
  const style = submenu.style;
  style.setProperty('--bridge-edge', `${event.clientX < rect.left ? 0 : rect.width}px`);
  style.setProperty('--bridge-height', `${rect.height}px`);
  const update = ({ clientX, clientY, target }) => {
    if (target !== submenu && submenu.contains(target)) return stop(); // Reached an item.
    style.setProperty('--bridge-x', `${clientX - rect.left}px`);
    style.setProperty('--bridge-y', `${clientY - rect.top}px`);
  };
  const timer = setTimeout(() => stop(), 300);
  const stop = () => {
    clearTimeout(timer);
    document.removeEventListener('pointermove', update);
    submenu.removeEventListener('toggle', stop);
    submenu.removeAttribute('data-bridge');
  };
  update(event);
  submenu.dataset.bridge = '';
  document.addEventListener('pointermove', update);
  submenu.addEventListener('toggle', stop, { once: true });
}

포커스에 따라 마우스 오버 (항목 2)는 일반 pointermove 핸들러로 유지되며 삼각형 로직이 필요하지 않습니다.

기타 정보:

  • 초점 외부. 포커스가 메뉴 시스템 외부에 있는 경우 열려 있는 모든 메뉴를 닫습니다(document focusin 리스너).
  • 명령어 활성화. 알린 다음 루트 메뉴를 숨기고 트리거에 포커스를 복원합니다. 포인터에서 활성화된 경우 링이 없습니다. 선택 가능한 항목 (menuitemcheckbox, menuitemradio)의 경우 변경된 체크표시가 표시되도록 닫기 전에 약 300ms 동안 기다립니다. 닫힌 트리거는 :focus-visible에서만 활성 상태로 스타일을 지정합니다. 그렇지 않으면 포인터 선택 후 복원된 (링이 없는) 포커스가 강조표시된 상태로 유지됩니다.
  • aria-expanded. toggle 리스너에서만 설정합니다. 미리보기 및 열기 상태를 포함하며, 메뉴가 미리보기 또는 열기 상태인 동안 트리거의 aria-expanded는 "true"로 유지됩니다.
  • 도우미 아래의 대체는 마크업에 정의한 도우미를 가정합니다. triggers (모든 [commandfor] 버튼), menuFor(trigger), isOpen(menu), closeAndRestore(menu, { fromPointer }) (트리거에 포커스, fromPointer일 때 링 없음, menu.hidePopover()), enterOnOpen (열리면 입력할 메뉴의 WeakSet), pointerTrigger (포인터로 마지막으로 누른 트리거)입니다.
  • 하위 메뉴에는 popover="auto"를 사용하고 상위 요소의 focusgroup 내 하위 메뉴 트리거 뒤에 배치합니다. popover="manual"에 이용하세요.
  • 중첩을 사용하면 브라우저에서 상위 메뉴를 열어 두고, 동위 하위 메뉴를 닫고, 루트 메뉴가 닫힐 때 하위 메뉴를 닫고, 외부 클릭으로 인한 가벼운 닫기를 처리할 수 있습니다.
  • 하위 메뉴 트리거는 aria-haspopup="menu" 및 aria-expanded를 유지하고 toggle 리스너는 aria-expanded를 동기화 상태로 유지합니다.
  • 키보드 스크립트는 위에서 설명한 대로 중첩된 Escape 및 ArrowLeft를 처리합니다.
  • 하위 메뉴 트리거는 명령어를 실행하지 않습니다. 하나의 컨트롤이 활성화와 열기를 모두 수행할 수 없으며 Enter는 두 가지를 모두 의미할 수 없습니다. 기본 작업을 첫 번째 하위 메뉴 항목으로 배치하거나 트리거 옆에 별도의 명령어 항목을 추가합니다. 결합된 컨트롤이 강제되는 경우 접근성 절충안으로 문서화합니다.

구분선 및 선택 가능한 항목

  • focusgroup 내에서 <hr>를 사용하여 상품 그룹을 분리합니다. 네이티브 separator는 포커스할 수 없으며 focusgroup과 대체에 의해 건너뜁니다. 스타일이 지정된 <div>가 아닙니다.
  • 불리언 옵션은 aria-checked 및 명시적 role="menuitemcheckbox"가 있는 <button type="button">입니다. Focusgroup은 이러한 역할을 추론하지 않으므로 공유 마크업이 항목 역할을 설정하는 유일한 위치입니다.
  • 세트 중 하나는 동일한 포커스 그룹에서 aria-label가 있는 role="group" 내에 role="menuitemradio" 및 aria-checked가 있는 <button type="button">입니다.
  • 스크립트가 aria-checked로 전환됩니다.
  • 체크박스에서 Enter를 누르면 메뉴가 전환되고 닫힙니다. Space를 누르면 전환되고 열린 상태로 유지됩니다 (APG). 라디오는 두 키 중 하나로 닫힙니다.
  • 별도의 아이콘 요소가 아닌 [aria-checked="true"]::before에서 체크표시를 렌더링합니다 (아래 CSS 참고).

여기에서 다루지 않음

  • 활성화 의견. 네이티브 메뉴는 닫기 전에 선택한 항목을 깜박입니다. 원하는 경우 hidePopover() 전에 약 100ms의 시각적 확인을 표시합니다. 포커스 복원 또는 공지를 지연하지 마세요.
  • 미리 입력. 포커스 그룹에는 없습니다. 필요한 경우 인쇄 가능한 keydown에서 현재 수준의 사용 설정된 항목 라벨을 일치시키고 현재 항목 뒤의 다음 일치 항목에 포커스를 맞춥니다. Space를 가로채지 않습니다.

완료 전 확인

요청 사항이 동작을 보장할 수 없으므로 결과를 테스트하세요. 페이지를 열고 실제 키보드와 마우스로 다음을 각각 확인합니다. 실패한 항목은 수정하고 다시 테스트합니다.

  • 키보드 Tab는 첫 번째 트리거에 한 번 도달하고, ArrowDown는 입력하고, Escape는 한 수준을 닫고 포커스를 트리거로 되돌리고, Tab는 모든 메뉴를 닫습니다.
  • 포인터 열기 트리거를 클릭하면 트리거에 포커스가 맞춰지고 링이 없는 메뉴가 열립니다. 다시 클릭하면 메뉴가 닫힙니다.
  • 포인터 명령어. 명령어를 클릭하면 모든 메뉴가 닫히고 트리거에 포커스 링이 표시되지 않습니다.
  • 하위 메뉴. 하위 메뉴 트리거의 중간에서 하위 메뉴 항목으로 대각선으로 이동하면 하위 메뉴가 열린 상태로 유지되고, 관련 없는 동위 항목으로 이동하면 하위 메뉴가 닫힙니다.
  • 좁은 화면 375px에서 하위 메뉴가 자체 트리거를 가리지 않습니다.
  • 체크표시 선택한 항목에는 체크표시가 나타납니다.
  • 남은 음식이 없습니다. 콘솔 오류가 없고 트리거에 popovertarget, popovertargetaction 또는 aria-details가 없습니다.

메뉴 항목 콘텐츠 정렬

행에 아이콘, 라벨, 바로가기 또는 하위 메뉴 표시기가 있는 경우 아이콘 또는 체크표시를 위해 고정 크기의 선행 슬롯을 예약하고 후행 콘텐츠를 인라인 끝으로 푸시합니다. 아이콘은 크기가 알려져 있으므로 고정 슬롯을 사용하면 항목에 아이콘이 있는지 여부와 관계없이 라벨이 정렬된 상태로 유지됩니다. 행에 subgrid를 사용하지 마세요. 추가 인라인 하위 요소 (예: 배지)가 자체 그리드 항목이 되어 레이아웃이 손상됩니다. 이미 항목을 작동하는 키를 반복하는 경우 보조 기술에서 단축키 텍스트를 숨깁니다.

[focusgroup~="menu"] {
  --menu-icon-size: 1.25rem;
}

/* Target items only. A closed submenu popover inside the focusgroup must keep display: none. */
[focusgroup~="menu"] > button,
[focusgroup~="menu"] > [role="group"] > button {
  display: flex;
  align-items: center;
  gap: .5rem;
}

/* The leading slot holds the icon or check mark. */
[focusgroup~="menu"] button::before {
  content: "";
  flex: none;
  inline-size: var(--menu-icon-size);
}

/* Check mark for checked items. This selector must be at least as specific as the base
   ::before rule above, or the empty content wins and the check mark never shows. */
[focusgroup~="menu"] button[aria-checked="true"]::before {
  content: "✓";
}

/* Shortcut or submenu indicator at the inline end. */
[focusgroup~="menu"] .hint {
  margin-inline-start: auto;
}

버튼 내부의 <span class="hint" aria-hidden="true">에 후행 표시기 또는 바로가기를 넣습니다. 하위 메뉴 트리거는 이미 팝업을 알립니다.

점진적 개선 및 대체

포커스 그룹의 대체 및 브라우저 지원

마크업에 focusgroup="menu nomemory"를 유지합니다. 'focusGroup' in HTMLElement.prototype가 false이면 Up, Down, Home, End를 설치하고 각 메뉴 컨테이너에 role="menu"를 설정하고 역할이 없는 버튼에 role="menuitem"를 설정합니다. 선택 가능한 항목, <hr>, role="group"는 그대로 둡니다. 네이티브 포커스 그룹이 있는 경우 설치하지 마세요. 왼쪽, 오른쪽, 미리보기, 열기, 닫기는 어느 쪽이든 작성자 스크립트입니다.

if (!('focusGroup' in HTMLElement.prototype)) {
  document.querySelectorAll('[focusgroup~="menu"]').forEach(group => {
    group.setAttribute('role', 'menu');
    group.querySelectorAll('button:not([role])').forEach(item => {
      item.setAttribute('role', 'menuitem');
    });
    group.addEventListener('keydown', event => {
      if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
      // Own items only: a nested submenu has its own focusgroup, a role="group" wrapper does not.
      const items = [...group.querySelectorAll('button')]
        .filter(item => item.closest('[focusgroup]') === group)
        .filter(item => !item.disabled && item.getAttribute('aria-disabled') !== 'true');
      const current = items.indexOf(document.activeElement);
      if (current < 0) return;
      const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 :
        (current + (event.key === 'ArrowUp' ? -1 : 1) + items.length) % items.length;
      event.preventDefault();
      event.stopImmediatePropagation();
      items[next].focus({ preventScroll: true });
    }, true);
  });
}

Popover의 대체 및 브라우저 지원

Popover API는 대부분 점진적 개선이지만, 최상위 레이어 승격, 가벼운 닫기, popovertarget 호출과 같은 정의 동작에는 CSS 전용에 상응하는 기능이 없습니다. 이전 브라우저에는 폴리필이 필요하며, 폴리필을 제공하지 않으려면 수동 대체가 필요합니다.

폴리필: 이전 브라우저에서 popover 속성을 지원하려면 조건부로 @oddbird/popover-polyfill를 로드합니다. 필수: HTMLElement.prototype에서 popover 속성을 확인하여 기능을 감지하고 네이티브 지원이 누락된 경우에만 폴리필을 로드합니다. 무조건 로드하지 마세요.

번들러 또는 가져오기 맵 사용:

// MANDATORY: Feature detect 'popover' on HTMLElement.prototype.
if (!("popover" in HTMLElement.prototype)) {
  import("@oddbird/popover-polyfill");
}

번들러가 없는 경우 <script type="module"> 내의 CDN에서 가져옵니다.

<script type="module">
  if (!("popover" in HTMLElement.prototype)) {
    import("https://unpkg.com/@oddbird/popover-polyfill@latest/dist/popover.min.js");
  }
</script>

스타일 지정 주의사항: 폴리필은 실제 :popover-open 의사 클래스를 정의할 수 없으므로 대신 .\:popover-open 클래스를 적용합니다. 필수: :is() 또는 :where()를 사용하여 두 가지를 결합합니다. 그렇지 않으면 :popover-open가 없는 브라우저에서 전체 규칙을 삭제합니다.

[popover]:is(:popover-open, .\:popover-open) {
  display: block;
}

또는 폴리필이 없는 기존 대체 기능의 경우 position: fixed를 사용하고 getBoundingClientRect()를 통해 좌표를 수동으로 계산하거나, 사용 사례에 적합한 경우 inset: auto를 사용하여 기본 위치 지정에 의존합니다.

앵커 위치 지정에 대한 대체 및 브라우저 지원

CSS 앵커 위치 지정이 없으면 팝오버는 UA의 중앙 표시 영역 배치로 대체됩니다. 메뉴에 이 기능을 제공하지 마세요. 메뉴가 트리거에서 분리되고 중앙에 배치된 하위 메뉴가 상위 메뉴를 가립니다. @supports (position-area: block-end) 뒤에 고정된 규칙을 제어하고 실패 시 스크립트에서 메뉴를 배치합니다.

  • 메뉴를 트리거 아래 (최상위 수준) 또는 옆 (하위 메뉴)에 배치하고, 오버플로가 발생하면 다른 쪽으로 뒤집고 표시 영역에 고정합니다.
  • 메뉴가 열릴 때 toggle 핸들러에서 실행하고 결과를 메뉴에 left 및 top로 적용합니다. 좌표가 표시 영역을 기준으로 하도록 position: fixed를 inset: auto 및 margin: 0와 함께 사용합니다.
  • 앵커 위치 지정이 있는 브라우저가 영향을 받지 않도록 이러한 재정의를 @supports not (position-area: block-end) 내에 유지합니다.

showPopover({ source })가 암시적 앵커를 설정하지 않는 이전 구현을 타겟팅하는 경우(ToggleEvent.prototype에 source 없음) 메뉴별 CSS 규칙을 작성하는 대신 스크립트에서 각 트리거에 고유한 anchor-name를, 각 메뉴에 일치하는 position-anchor를 지정합니다.

앵커 위치 지정이 전혀 없는 브라우저의 경우 폴리필 경로에 관한 표시 영역 인식 드롭다운을 참고하세요.

Invoker 명령어의 대체 및 브라우저 지원

호출자가 없으면 command="toggle-popover"는 아무 작업도 하지 않으므로 스크립트가 메뉴를 전환해야 합니다.

  • 인스턴스가 아닌 인터페이스를 기능 감지합니다('commandForElement' in HTMLButtonElement.prototype).
  • 각 트리거 click에서 preventDefault()를 호출하여 호출자가 있는 브라우저가 두 번 전환할 수 없도록 합니다.
  • 메뉴가 열려 있으면 closeAndRestore(menu) 키를 눌러 닫습니다. 그렇지 않으면 menu.showPopover({ source: trigger })를 호출합니다.
  • 클릭이 눌린 트리거 (pointerTrigger)에서 발생하지 않는 한 enterOnOpen에서 메뉴를 표시하므로 키보드만 메뉴를 엽니다.
  • Enter 및 Space는 버튼 클릭을 생성하므로 계속 작동합니다.