Tạo trình đơn thả xuống

Tạo trình đơn thả xuống

Hướng dẫn này đề cập đến mọi trình đơn bật lên: trình đơn thả xuống, trình đơn theo bối cảnh, trình đơn mục bổ sung và thanh trình đơn mở các trình đơn đó. Ví dụ được thực hiện là trình đơn lệnh của ứng dụng, trong đó các mục thay đổi giao diện (tạo, lưu, lựa chọn ưu tiên).

Hai lựa chọn độc lập. Mọi nội dung khác ở đây đều áp dụng cho cả hai.

  • Phần tử, mỗi mục. Lệnh là một <button type="button">; đích đến là một <a href> vẫn mở trong thẻ mới, sao chép dưới dạng URL và hoạt động mà không cần tập lệnh. Trình đơn thả xuống có thể kết hợp các mục này một cách tự do: hai đường liên kết và sáu lệnh, hoặc một lệnh và sáu đường liên kết, không có gì thay đổi bên dưới.
  • Mô hình tương tác, theo trình đơn thả xuống. Các phím mũi tên, Home/End và Esc biến nó thành một trình đơn: thêm nhóm tiêu điểm, cho phép nhóm này suy luận menuitem trên mọi mục, cho dù là nút hay đường liên kết, và làm cho nhóm này trông giống như một trình đơn (một thanh liên tục hoặc một trình kích hoạt có chỉ báo thả xuống; xem phần "Thanh trình đơn hoặc các nút trình đơn riêng biệt" bên dưới). Một danh sách ngắn các đường liên kết mà người dùng Tab qua là thành phần điều hướng: không có focusgroup, không có role="menu", role="menuitem" hoặc aria-haspopup="menu"; giữ lại cửa sổ bật lên, Esc và khôi phục tiêu điểm, đồng thời xem hướng dẫn về thanh điều hướng.

Nội dung không bao giờ quyết định vai trò. Trình đơn thả xuống "Sản phẩm" gồm các đường liên kết có thể là một trình đơn; trình đơn thả xuống "Tài khoản" gồm các lệnh không thể là trình đơn điều hướng.

Chỉ sử dụng mẫu trình đơn cho một tiện ích tổng hợp có một điểm dừng thẻ và thao tác di chuyển bằng phím mũi tên. Sử dụng <div focusgroup="menu nomemory">, không phải phần tử HTML <menu>. Đừng thêm role="menu" hoặc role="menuitem" vào mã đánh dấu dùng chung: nhóm tiêu điểm gốc suy luận các vai trò đó và các vai trò rõ ràng sẽ chặn suy luận đó. Chỉ thêm các thành phần này vào phương án dự phòng cho trình duyệt không được hỗ trợ bên dưới. nomemory giúp việc mở lại bắt đầu từ mục đầu tiên. Focusgroup di chuyển tiêu điểm; tập lệnh xử lý việc xem trước, kích hoạt và khôi phục tiêu điểm.

Mở và đặt vị trí cho trình đơn

Đặt mỗi điều kiện kích hoạt thành một trình gọi lệnh bằng commandfor và command="toggle-popover". Trình duyệt sẽ mở và đóng cửa sổ bật lên khi nhấp, nhấn phím Enter và phím cách, đồng thời giữ trình kích hoạt bên trong nhóm đóng nhẹ của cửa sổ bật lên, vì vậy, việc nhấp vào một trình kích hoạt đang mở sẽ đóng trình kích hoạt đó. Đừng sử dụng popovertarget hoặc popovertargetaction; mẫu này sử dụng commandfor.

Đặt mỗi cửa sổ bật lên ngay sau trình kích hoạt của nó. Trình duyệt chỉ thêm mối quan hệ aria-details ngầm ẩn khi cửa sổ bật lên không phải là thành phần đồng cấp tiếp theo, vì vậy, vị trí này sẽ tránh được mối quan hệ đó mà không cần thêm thuộc tính.

<!-- 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>

Đặt tên cho từng trình đơn bằng aria-labelledby trỏ vào trình kích hoạt của trình đơn đó. Giữ các chỉ báo trình đơn phụ trang trí trong CSS hoặc thêm aria-hidden="true". Cung cấp cho trình kích hoạt có aria-expanded="true" một kiểu hoạt động hiển thị để người dùng có thể biết trình đơn nào đang mở hoặc được xem trước.

Chọn một biểu mẫu cho các điều kiện kích hoạt cấp cao nhất. Vai trò, kiểu bàn phím và kiểu dáng phải khớp nhau: các nút riêng biệt về mặt hình ảnh báo hiệu một điểm dừng Tab cho mỗi nút.

  • Thanh trình đơn. role="menubar" trên vùng chứa (không bao giờ trên cửa sổ bật lên), được tạo kiểu dưới dạng một thanh liên tục, không có khoảng trống giữa các nút kích hoạt. Một điểm dừng Tab; ArrowLeft/ArrowRight di chuyển giữa các trình kích hoạt; di chuột để mở trình đơn. Dùng cho thanh lệnh của ứng dụng.
  • Các nút trình đơn riêng biệt. Mỗi điểm kích hoạt là một điểm dừng Tab riêng và trông giống như một nút độc lập. Không có role="menubar", không có phím mũi tên giữa các trình kích hoạt, không có bản xem trước khi lấy tiêu điểm, không có thao tác chuyển đổi khi di chuột. Gói trong <nav> nếu người dùng di chuyển, nếu không thì không có vai trò. Dùng cho thanh công cụ và tiêu đề trang.

Phần còn lại của hướng dẫn này mô tả biểu mẫu thanh trình đơn. Đối với các nút riêng biệt, hãy giữ mọi thứ bên trong mỗi trình đơn và bỏ hành vi ở cấp thanh trình đơn.

Định vị trình đơn bằng tính năng Định vị neo CSS

Sử dụng tính năng định vị neo CSS, không phải toạ độ JavaScript. Cả command="toggle-popover" và showPopover({ source: trigger }) đều đặt một neo ngầm định, vì vậy, đừng thêm quy tắc anchor-name hoặc position-anchor cho mỗi trình đơn. Hành vi của trình đơn không được phụ thuộc vào vị trí neo.

/* 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);
}

Giữ --below ở cuối để chỉ áp dụng khi cả hai bên đều không vừa. Trình đơn này mở ngay bên dưới trình kích hoạt, vì vậy, trên màn hình hẹp, trình đơn này sẽ che các mục đồng cấp bên dưới trình kích hoạt; đó là sự đánh đổi được chấp nhận để giữ trình đơn bên cạnh trình kích hoạt. Ngoài ra, hãy cho phép trình đơn phụ thu nhỏ vào khu vực (min-inline-size: 0, max-inline-size trừ đi phần thụt lề). Phương án dự phòng không phù hợp sẽ bị bỏ qua và nếu không có phương án nào phù hợp, trình duyệt sẽ giữ nguyên vị trí cơ sở, vì vậy, một trình đơn phụ quá rộng vài pixel sẽ tự động che đi trình kích hoạt của nó.

Để biết cách hoạt động của tính năng lật dự phòng trên một cửa sổ bật lên đơn giản, hãy xem phần Trình đơn thả xuống nhận biết khung nhìn. Hướng dẫn đó định vị một nhóm nút, chứ không phải trình đơn; đối với trình đơn, vị trí của trình đơn phụ và phương án dự phòng --below ở trên sẽ được ưu tiên.

Tạo ảnh động khi mở và đóng

Không bắt buộc. Dưới 150 mili giây, chỉ có opacity và một translate nhỏ, tương tự cho trình đơn phụ, bên trong @media (prefers-reduced-motion: no-preference). Tiêu điểm di chuyển trên toggle, vì vậy, quá trình chuyển đổi không bao giờ làm chậm người dùng bàn phím.

@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 không phải là bản lặp lại của [popover]: cửa sổ bật lên đã đóng là display: none và hiệu ứng chuyển đổi không chạy từ trạng thái chưa kết xuất, vì vậy, cửa sổ này cung cấp các giá trị trước cho trạng thái mở. Nếu không có thuộc tính này, thì chỉ có ảnh động đóng mới hoạt động. display và overlay có allow-discrete giúp cửa sổ bật lên hiển thị và nằm ở lớp trên cùng cho đến khi quá trình chuyển đổi đóng kết thúc.

Bàn phím và tiêu điểm

Đừng thêm hệ thống roving-tabindex thứ hai vào bên trong một focusgroup. Tập lệnh chỉ bao gồm những gì trình gọi và nhóm tiêu điểm không làm:

  • Một điểm dừng thẻ cho thanh trình đơn. Tab nhập vào trình kích hoạt cấp cao nhất đầu tiên. ArrowLeft và ArrowRight di chuyển giữa các trình kích hoạt cấp cao nhất và ngắt dòng ở một trong hai đầu. Home và End trên một trình kích hoạt sẽ mở trình đơn của trình kích hoạt đó ở mục đầu tiên hoặc mục cuối cùng.
    • Lưu ý: trình kích hoạt Tab trả về. APG cho phép sử dụng trình kích hoạt đầu tiên hoặc trình kích hoạt cuối cùng. Hướng dẫn chỉ định điểm dừng đầu tiên, nhưng bản minh hoạ di chuyển điểm dừng thẻ bằng ArrowLeft và ArrowRight, vì vậy, sau khi thao tác bằng bàn phím, Shift+Tab sẽ quay lại trình kích hoạt cuối cùng đạt được. Việc sử dụng con trỏ không di chuyển con trỏ, vì vậy, cùng một trình tự có thể kích hoạt một điều kiện kích hoạt khác. Bạn có thể chọn một trong hai hành vi này và duy trì hành vi đó.
  • Xem trước. Khi bạn tập trung vào một trình kích hoạt cấp cao nhất, trình đơn của trình kích hoạt đó sẽ xuất hiện và đặt aria-expanded="true". Tiêu điểm vẫn nằm trên trình kích hoạt và không có mục nào được lấy tiêu điểm. Làm mờ trình kích hoạt sẽ đóng trình đơn chỉ xem trước.
  • Phím Enter và phím cách trên bản xem trước. Họ đóng bản xem trước và tiếp tục tập trung vào điều kiện kích hoạt. Thao tác toggle-popover gốc sẽ xử lý việc này, vì vậy, tập lệnh không được chặn các phím này.
  • Mũi tên xuống trên bản xem trước. Chuyển đổi thành trình đơn đang mở và tập trung vào mục đầu tiên.
  • Nhấp con trỏ vào một điều kiện kích hoạt đang mở hoặc đang được xem trước. Đóng trình đơn ngay lập tức; không vào trình đơn.
    • Sự khác biệt đã biết về trình kích hoạt trình đơn phụ. Thao tác nhấp con trỏ vào trình kích hoạt trình đơn phụ cũng bật/tắt theo cách gốc, vì vậy, thao tác này có thể đóng trình đơn phụ mà thao tác di chuột đã xem trước hoặc mở trình đơn phụ chưa xuất hiện. Trình đơn gốc chỉ mở chứ không bao giờ đóng. Điều này không gây hại nên hướng dẫn chấp nhận.
  • Nhấp chuột vào một trình kích hoạt đã đóng. Mở tệp đó theo cách gốc. Tiêu điểm vẫn nằm trên trình kích hoạt; không có mục nào được lấy tiêu điểm. Chỉ những lần mở bàn phím mới được tính vào trình đơn.
  • Con trỏ trên một mục. Lấy tiêu điểm mà không có vòng, vì vậy, di chuột và lấy tiêu điểm là một mục.
  • Con trỏ di chuột qua một trình kích hoạt cấp cao nhất khác trong khi trình đơn đang mở. Đóng và xem trước mục được di chuột qua, tập trung vào trình kích hoạt của mục đó mà không có vòng. Không có gì mở: di chuột không có tác dụng.
  • Con trỏ trên trình kích hoạt trình đơn phụ. Xem trước trình đơn phụ sau một khoảng thời gian ngắn (tiêu điểm con trỏ chạy cùng một bản xem trước khi lấy tiêu điểm). Một mục đồng cấp đóng mục đó.
  • Trong trình đơn. ArrowUp và ArrowDown di chuyển giữa các mục đã bật. Home và End di chuyển đến mục đầu tiên và mục cuối cùng được bật, do tập lệnh xử lý.
  • Mũi tên trái hoặc Mũi tên phải trong trình đơn cấp cao nhất đang mở. Đóng trình đơn và xem trước trình đơn liền kề, tập trung vào trình kích hoạt của trình đơn đó, gói ở hai đầu. Sau đó, ArrowDown sẽ nhập mã này. Phím Trái và Phải không bao giờ đưa người dùng vào trong trình đơn.
  • ArrowRight trên trình kích hoạt trình đơn phụ. Mở trình đơn phụ và lấy tiêu điểm là mục đầu tiên trong trình đơn phụ.
  • Mũi tên phải trên một mục trong trình đơn phụ không có trình đơn phụ con. Đóng hệ phân cấp và xem trước trình đơn cấp cao nhất tiếp theo trong khi tiêu điểm vẫn nằm trên điều kiện kích hoạt của trình đơn đó. Sau đó, ArrowDown sẽ nhập mã này.
  • Mũi tên trái hoặc Escape trong trình đơn phụ. Chỉ đóng trình đơn phụ đó và khôi phục tiêu điểm về trình kích hoạt của trình đơn phụ đó. Trình đơn dành cho cha mẹ vẫn mở.
  • Nhấn phím Enter hoặc phím Cách trên một lệnh. Kích hoạt mục đó và đóng tất cả trình đơn đang mở.
  • Thẻ. Thoát khỏi hệ thống trình đơn. Đừng giữ tiêu điểm hoặc thực hiện từng lệnh. Khi đóng, tiêu điểm không được nằm trong nội dung bị ẩn.

Sự kiện command kích hoạt trước khi toggle-popover mở trình đơn, vì vậy, tiêu điểm không thể di chuyển vào trình đơn ở đó. Thay vào đó, hãy di chuyển tiêu điểm trong sự kiện toggle. HTML chỉ khôi phục tiêu điểm cho popover="auto" đầu tiên trong một ngăn xếp, vì vậy, các cấp lồng nhau phải khôi phục tiêu điểm cho trình kích hoạt một cách rõ ràng.

Triển khai hoạt động tương tác

Triển khai các hành vi nêu trên cho mã đánh dấu của riêng bạn. Mũi tên, Home/End, xử lý xuống dòng và Tab để thoát là mã keydown thông thường. Dưới đây là một số phần dễ bị nhầm lẫn, được nhóm theo loại dữ liệu đầu vào.

Xử lý bàn phím và tiêu điểm

  1. Một đường dẫn mở. Việc mở bằng bàn phím sẽ gọi trigger.click(), vì vậy, toggle-popover của trình gọi là cách duy nhất để mở trình đơn. Đánh dấu trình đơn để trình xử lý toggle nhập trình đơn, chỉ mở bàn phím. Khi con trỏ mở, toggle sẽ tập trung vào trình kích hoạt mà không có vòng, vì pointerdown đã ngăn tiêu điểm và các phím mũi tên vẫn phải hoạt động.
  2. Xem trước khi lấy tiêu điểm. Việc lấy tiêu điểm một điều kiện kích hoạt sẽ gọi showPopover({ source: trigger }) để trình đơn neo vào điều kiện kích hoạt đó. Bỏ qua tiêu điểm quay lại từ bên trong trình đơn của trình kích hoạt hoặc từ một cửa sổ khác (gắn cờ trên cửa sổ blur, xoá dấu kiểm sau cửa sổ focus hoặc chuyển đổi ứng dụng sẽ mở lại trình đơn gần đây nhất). Ẩn trình đơn được xem trước trên blur trừ phi tiêu điểm di chuyển vào trình đơn đó.
  3. Di chuyển tiêu điểm trong toggle, không phải command. command kích hoạt trước khi trình đơn mở ra.
  4. Tự khôi phục tiêu điểm về trình kích hoạt. Tập trung vào điều kiện kích hoạt trước hidePopover(). Sau khi kích hoạt con trỏ, hãy khôi phục tiêu điểm mà không có vòng (focus({ focusVisible: false })) hoặc trình kích hoạt sẽ hiển thị đường viền tiêu điểm bàn phím sau khi nhấp chuột; các thao tác kích hoạt bàn phím sẽ giữ lại vòng. Một lượt nhấp có event.detail === 0 đến từ bàn phím.
  5. Đóng từng cấp một. Gọi stopPropagation() trên Escape và ArrowLeft trong trình đơn phụ, hoặc trình nghe của trình đơn mẹ cũng đóng trình đơn phụ.

Xử lý con trỏ

  1. Nút bật/tắt nhấp chuột. Gọi preventDefault() trên pointerdown để lượt nhấp không di chuyển tiêu điểm và bắt đầu bản xem trước giữa lượt nhấp. Ghi nhớ nút kích hoạt đã được nhấn. Khi bạn nhấp vào một trình kích hoạt đã đóng hoặc đã xem trước, trình kích hoạt đó sẽ mở hoặc đóng theo cách thông thường và không bao giờ vào trình đơn; chỉ khi mở bằng bàn phím thì mới vào trình đơn. Đừng chặn lượt nhấp.
  2. Trạng thái di chuột theo tiêu điểm. Tạo kiểu riêng cho :hover và :focus-visible. Khi pointermove trên một mục, hãy gọi focus({ focusVisible: false }); các trình duyệt bỏ qua tuỳ chọn này sẽ không vẽ vòng tròn cho tiêu điểm con trỏ.
  3. Di chuột để mở trình đơn. Khi pointerenter một trình kích hoạt cấp cao nhất trong khi một trình đơn cấp cao nhất khác đang mở: ẩn trình đơn đó, lấy tiêu điểm vào trình kích hoạt được di chuột qua mà không có vòng và xem trước khi lấy tiêu điểm (xem ở trên). Không có gì mở: không làm gì cả.
  4. Tam giác an toàn. Cầu nối CSS cố định quá hẹp: cầu nối này chỉ hoạt động khi con trỏ bắt đầu gần cạnh của phần tử mẹ. Hãy để CSS vẽ cầu và chỉ sử dụng tập lệnh để báo cáo con trỏ:
    • Chỉ chạy thao tác này khi matchMedia('(hover: hover)').matches; thao tác này không liên quan đến thao tác chạm và bàn phím.
    • Bắt đầu khi con trỏ rời khỏi trình kích hoạt trình đơn phụ có trình đơn phụ đang mở. Bỏ qua trình đơn phụ --below không có cạnh bên.
    • Đặt --bridge-x và --bridge-y (con trỏ, tính bằng px so với trình đơn phụ), --bridge-edge (x gần cạnh của trình đơn phụ: 0 khi con trỏ ở bên trái, chiều rộng của trình đơn phụ khi lật) và --bridge-height trên trình đơn phụ, và data-bridge để bật cầu nối.
    • Dừng sau khoảng 300 mili giây, khi con trỏ đến một mục trong trình đơn phụ hoặc khi trình đơn phụ đóng. Xoá trình nghe và thuộc tính. Không có gì chạy sau đó.
    • CSS cung cấp cho trình đơn phụ một ::before được cắt thành hình tam giác từ con trỏ đến các góc gần cạnh. Nó nằm trên các mục đồng cấp trên đường dẫn của con trỏ, vì vậy chúng không bao giờ nhận được thao tác di chuột để đóng trình đơn phụ. clip-path cũng giới hạn thử nghiệm nhấn, vì vậy, phần còn lại của trình đơn dành cho cha mẹ vẫn có thể tương tác.
    • Trình đơn phụ cần có overflow: visible (một cửa sổ bật lên sẽ cắt theo mặc định) và phần tử giả mở rộng ra ngoài trình đơn phụ, vì vậy, hãy bù trừ các điểm của hình dạng theo mức đó.
    • Tiếp theo: đường cong (lệnh đường cong shape()) theo dõi cung con trỏ thực tốt hơn hình tam giác thẳng.
[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 });
}

Trình xử lý di chuột theo tiêu điểm (mục 2) vẫn là trình xử lý pointermove đơn giản: không cần logic tam giác.

Ngoài ra:

  • Nằm ngoài tiêu điểm. Đóng mọi trình đơn đang mở khi tiêu điểm nằm ngoài hệ thống trình đơn (trình nghe document focusin).
  • Kích hoạt lệnh. Thông báo, sau đó ẩn trình đơn gốc và khôi phục tiêu điểm về điều kiện kích hoạt, không có vòng nếu thao tác kích hoạt đến từ con trỏ. Đối với các mục có thể đánh dấu (menuitemcheckbox, menuitemradio), hãy đợi khoảng 300 mili giây trước khi đóng để có thể nhìn thấy dấu kiểm đã thay đổi. Chỉ tạo kiểu cho trình kích hoạt đã đóng ở trạng thái hoạt động trên :focus-visible; nếu không, tiêu điểm được khôi phục (không có vòng) sẽ làm nổi bật trình kích hoạt sau khi con trỏ chọn.
  • aria-expanded. Chỉ đặt thuộc tính này trong trình nghe toggle. Nó bao gồm các trạng thái xem trước và mở, đồng thời aria-expanded của trình kích hoạt vẫn là "true" trong khi trình đơn của trình kích hoạt được xem trước hoặc mở.
  • Trình trợ giúp. Các phương án dự phòng bên dưới giả định các trình trợ giúp mà bạn xác định cho mã đánh dấu: triggers (tất cả nút [commandfor]), menuFor(trigger), isOpen(menu), closeAndRestore(menu, { fromPointer }) (tập trung vào trình kích hoạt, không có vòng khi fromPointer, sau đó là menu.hidePopover()), enterOnOpen (WeakSet trình đơn để nhập sau khi mở) và pointerTrigger (trình kích hoạt được nhấn gần đây nhất bằng con trỏ).
  • Sử dụng popover="auto" cho trình đơn phụ, được đặt sau trình kích hoạt trình đơn phụ bên trong focusgroup của trình đơn mẹ. Không bao giờ popover="manual".
  • Việc lồng ghép cho phép trình duyệt giữ cho trình đơn mẹ luôn mở, đóng trình đơn phụ đồng cấp, đóng trình đơn phụ con khi trình đơn gốc đóng và xử lý thao tác đóng nhẹ khi nhấp vào bên ngoài.
  • Trình kích hoạt trình đơn phụ giữ aria-haspopup="menu" và aria-expanded; trình nghe toggle giữ aria-expanded đồng bộ.
  • Tập lệnh bàn phím xử lý Escape và ArrowLeft lồng nhau như mô tả ở trên.
  • Trình kích hoạt trình đơn phụ cũng không bao giờ chạy lệnh: một chế độ điều khiển không thể vừa kích hoạt vừa mở và Enter không thể có cả hai ý nghĩa. Đặt hành động mặc định làm mục trình đơn con đầu tiên hoặc thêm một mục lệnh riêng biệt bên cạnh trình kích hoạt. Nếu bạn buộc phải dùng một chế độ điều khiển kết hợp, hãy ghi lại chế độ đó dưới dạng một giải pháp hỗ trợ tiếp cận.

Dấu phân tách và các mục có thể đánh dấu chọn

  • Tách các nhóm mặt hàng bằng <hr> bên trong focusgroup: một separator gốc, không thể lấy tiêu điểm, bị focusgroup bỏ qua và dự phòng. Không bao giờ có <div> được tạo kiểu.
  • Tuỳ chọn boolean là một <button type="button"> có aria-checked và role="menuitemcheckbox" rõ ràng. Focusgroup không suy luận các vai trò này, vì vậy, đây là nơi duy nhất mà mã đánh dấu dùng chung đặt vai trò của mục.
  • Một trong các nhóm là <button type="button"> có role="menuitemradio" và aria-checked, bên trong role="group" có aria-label, trong cùng một nhóm tiêu điểm.
  • Nút bật/tắt tập lệnh aria-checked.
  • Enter trên hộp đánh dấu sẽ bật/tắt và đóng trình đơn; Space bật/tắt và giữ nguyên trạng thái mở (APG). Nút chọn đóng trên một trong hai phím.
  • Hiển thị dấu kiểm từ [aria-checked="true"]::before, không phải phần tử biểu tượng riêng biệt (xem CSS bên dưới).

Không được đề cập ở đây

  • Phản hồi về việc kích hoạt. Trình đơn gốc sẽ nhấp nháy mục đã chọn trước khi đóng. Nếu muốn, hãy hiển thị thông báo xác nhận bằng hình ảnh trong khoảng 100 mili giây trước hidePopover(); không trì hoãn việc khôi phục tiêu điểm hoặc thông báo.
  • Typeahead. Focusgroup không có. Nếu cần, trên keydown có thể in, hãy so khớp nhãn mục đã bật của cấp hiện tại và tập trung vào kết quả khớp tiếp theo sau mục hiện tại, gói. Không chặn Space.

Xác minh trước khi hoàn tất

Chỉ dẫn không thể đảm bảo hành vi, vì vậy, hãy kiểm tra kết quả. Mở trang và kiểm tra từng mục này bằng bàn phím và chuột thật; sửa và kiểm tra lại mọi mục không thành công:

  • Bàn phím. Tab đến trình kích hoạt đầu tiên một lần; ArrowDown nhập; Escape đóng một cấp và trả lại tiêu điểm cho trình kích hoạt; Tab đóng mọi trình đơn.
  • Con trỏ mở. Khi bạn nhấp vào một trình kích hoạt, trình đơn của trình kích hoạt đó sẽ mở ra và được lấy tiêu điểm nhưng không có vòng tròn; khi bạn nhấp lại, trình đơn sẽ đóng.
  • Lệnh con trỏ. Khi bạn nhấp vào một lệnh, mọi trình đơn sẽ đóng lại và không có vòng lấy tiêu điểm nào xuất hiện trên trình kích hoạt.
  • Trình đơn phụ. Khi di chuyển theo đường chéo từ giữa trình kích hoạt trình đơn phụ đến một mục trong trình đơn phụ, trình đơn phụ sẽ vẫn mở; khi di chuyển đến một mục đồng cấp không liên quan, trình đơn phụ sẽ đóng.
  • Màn hình hẹp. Ở kích thước 375 px, không có trình đơn con nào che khuất trình kích hoạt của chính nó.
  • Dấu kiểm. Các mục đã được đánh dấu sẽ có dấu kiểm.
  • Không có thức ăn thừa. Không có lỗi bảng điều khiển và không có popovertarget, popovertargetaction hoặc aria-details trên trình kích hoạt.

Căn chỉnh nội dung mục trong trình đơn

Khi một hàng có biểu tượng, nhãn và phím tắt hoặc chỉ báo trình đơn phụ, hãy dành một vùng đầu có kích thước cố định cho biểu tượng hoặc dấu kiểm và đẩy nội dung cuối sang cuối cùng dòng. Biểu tượng có kích thước đã biết, vì vậy, một vùng cố định sẽ giúp các nhãn được căn chỉnh cho dù mục có biểu tượng hay không. Đừng sử dụng subgrid cho các hàng: mọi phần tử con cùng dòng bổ sung (ví dụ: huy hiệu) sẽ trở thành mục lưới riêng và làm hỏng bố cục. Ẩn văn bản phím tắt khỏi công nghệ hỗ trợ khi văn bản đó chỉ lặp lại một phím đã vận hành mục.

[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;
}

Đặt chỉ báo hoặc phím tắt ở cuối trong <span class="hint" aria-hidden="true"> bên trong nút; trình kích hoạt trình đơn phụ đã thông báo cửa sổ bật lên của trình đơn phụ.

Cải tiến tăng dần và dự phòng

Dự phòng và hỗ trợ trình duyệt cho focusgroup

Giữ focusgroup="menu nomemory" trong mã đánh dấu. Nếu 'focusGroup' in HTMLElement.prototype là false, hãy cài đặt các phím Mũi tên lên, Mũi tên xuống, Home và End, đặt role="menu" trên mỗi vùng chứa trình đơn và role="menuitem" trên các nút không có vai trò; giữ nguyên các mục có thể đánh dấu, <hr> và role="group". Đừng bao giờ cài đặt polyfill này khi có nhóm tiêu điểm gốc. Trái, Phải, xem trước, mở và đóng đều là tập lệnh của tác giả.

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);
  });
}

Phương án dự phòng và hỗ trợ trình duyệt cho Popover

Popover API chủ yếu là cải tiến tăng dần, nhưng các hành vi xác định của API này (quảng bá lớp trên cùng, đóng nhẹ và gọi popovertarget) không có hành vi tương đương chỉ dùng CSS. Các trình duyệt cũ cần có polyfill hoặc phương án dự phòng thủ công nếu bạn không muốn cung cấp polyfill.

Polyfill: Để hỗ trợ thuộc tính popover trong các trình duyệt cũ, hãy tải @oddbird/popover-polyfill theo điều kiện. BẮT BUỘC: Phát hiện tính năng bằng cách kiểm tra thuộc tính popover trên HTMLElement.prototype và chỉ tải polyfill khi thiếu tính năng hỗ trợ gốc — KHÔNG tải polyfill vô điều kiện.

Với trình đóng gói hoặc bản đồ nhập:

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

Nếu không có trình đóng gói, hãy nhập từ CDN bên trong <script type="module">:

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

Lưu ý về kiểu: Polyfill không thể xác định lớp giả :popover-open thực, vì vậy, lớp này sẽ áp dụng lớp .\:popover-open. BẮT BUỘC: Kết hợp cả hai bằng :is() hoặc :where(), nếu không, các trình duyệt thiếu :popover-open sẽ loại bỏ toàn bộ quy tắc:

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

Ngoài ra, đối với phương án dự phòng cũ không có polyfill, hãy sử dụng position: fixed và tính toán toạ độ theo cách thủ công thông qua getBoundingClientRect() hoặc dựa vào vị trí mặc định bằng inset: auto nếu điều đó phù hợp với trường hợp sử dụng.

Phương án dự phòng và hỗ trợ trình duyệt cho tính năng Định vị neo

Nếu không có tính năng Định vị neo CSS, cửa sổ bật lên sẽ quay lại vị trí khung nhìn chính giữa của UA. Đừng bao giờ sử dụng cách này cho trình đơn: cách này tách trình đơn khỏi trình kích hoạt và trình đơn phụ ở giữa sẽ che mất trình đơn mẹ. Kiểm soát các quy tắc cố định phía sau @supports (position-area: block-end) và đặt vị trí trình đơn từ tập lệnh khi không thành công:

  • Đặt trình đơn bên dưới trình kích hoạt (cấp cao nhất) hoặc bên cạnh trình đơn (trình đơn phụ), lật sang phía bên kia và kẹp vào khung nhìn khi trình đơn tràn.
  • Chạy trình xử lý này trong trình xử lý toggle khi trình đơn mở ra, đồng thời áp dụng kết quả dưới dạng left và top trên trình đơn. Sử dụng position: fixed với inset: auto và margin: 0 để toạ độ tương ứng với khung nhìn.
  • Hãy giữ các cơ chế ghi đè này bên trong @supports not (position-area: block-end) để các trình duyệt có tính năng định vị neo không bị ảnh hưởng.

Nếu nhắm đến các phương thức triển khai trước đó, trong đó showPopover({ source }) không thiết lập neo ngầm định (không có source trên ToggleEvent.prototype), hãy cung cấp cho mỗi trình kích hoạt một anchor-name riêng biệt và mỗi trình đơn một position-anchor phù hợp từ tập lệnh, thay vì viết các quy tắc CSS cho mỗi trình đơn.

Đối với các trình duyệt hoàn toàn không có tính năng định vị neo, hãy xem bài viết Trình đơn thả xuống nhận biết khung nhìn để biết tuyến polyfill.

Phương án dự phòng và hỗ trợ trình duyệt cho các lệnh của Trình gọi

Nếu không có trình gọi, command="toggle-popover" sẽ không làm gì cả, vì vậy, tập lệnh phải bật/tắt trình đơn.

  • Phát hiện tính năng của giao diện, chứ không phải của một thực thể: 'commandForElement' in HTMLButtonElement.prototype.
  • Trên mỗi trình kích hoạt click, hãy gọi preventDefault() để trình duyệt có trình gọi không thể bật/tắt hai lần.
  • Nếu trình đơn đang mở, hãy đóng trình đơn bằng phím closeAndRestore(menu). Nếu không, hãy gọi menu.showPopover({ source: trigger }).
  • Đánh dấu trình đơn trong enterOnOpen trừ phi lượt nhấp đến từ trình kích hoạt được nhấn (pointerTrigger), vì vậy, chỉ bàn phím mới mở trình đơn.
  • Enter và Space vẫn hoạt động vì chúng tạo ra một lượt nhấp vào các nút.