From f7517f30678e4948330db00bc4d8cc933fa3c6d6 Mon Sep 17 00:00:00 2001 From: Cameron Dutro Date: Tue, 11 Apr 2023 14:13:18 -0700 Subject: [PATCH 01/11] Ensure roles are on li elements; add ability to skip axe rules for specific previews --- app/components/primer/alpha/action_list.rb | 1 + app/components/primer/alpha/action_list/item.rb | 14 ++++++++------ app/components/primer/alpha/dropdown/menu.rb | 2 +- test/accessibility_test.rb | 10 +++++++++- test/components/alpha/action_list_test.rb | 11 ++++++----- test/components/alpha/action_menu_test.rb | 16 +++++++--------- test/components/alpha/dropdown/menu_test.rb | 4 ++-- test/system/test_case.rb | 2 -- 8 files changed, 34 insertions(+), 26 deletions(-) diff --git a/app/components/primer/alpha/action_list.rb b/app/components/primer/alpha/action_list.rb index 0bf5833ed8..7b4a54b1fd 100644 --- a/app/components/primer/alpha/action_list.rb +++ b/app/components/primer/alpha/action_list.rb @@ -15,6 +15,7 @@ class ActionList < Primer::Component MENU_ROLE = :menu DEFAULT_MENU_ITEM_ROLE = :menuitem + DEFAULT_LIST_ITEM_ROLE = :listitem DEFAULT_SCHEME = :full SCHEME_MAPPINGS = { diff --git a/app/components/primer/alpha/action_list/item.rb b/app/components/primer/alpha/action_list/item.rb index 864d65bdc3..f06ef3fec7 100644 --- a/app/components/primer/alpha/action_list/item.rb +++ b/app/components/primer/alpha/action_list/item.rb @@ -230,12 +230,14 @@ def initialize( ) end - @content_arguments[:role] = role || - if @list.allows_selection? - ActionList::SELECT_VARIANT_ROLE_MAP[@list.select_variant] - elsif @list.acts_as_menu? - ActionList::DEFAULT_MENU_ITEM_ROLE - end + @system_arguments[:role] = role || + if @list.allows_selection? + ActionList::SELECT_VARIANT_ROLE_MAP[@list.select_variant] + elsif @list.acts_as_menu? + ActionList::DEFAULT_MENU_ITEM_ROLE + else + ActionList::DEFAULT_LIST_ITEM_ROLE + end @description_wrapper_arguments = { classes: class_names( diff --git a/app/components/primer/alpha/dropdown/menu.rb b/app/components/primer/alpha/dropdown/menu.rb index 45c4538f8f..daf643fa90 100644 --- a/app/components/primer/alpha/dropdown/menu.rb +++ b/app/components/primer/alpha/dropdown/menu.rb @@ -70,7 +70,7 @@ def initialize(as:, tag: TAG_DEFAULT, divider: false, **system_arguments) @system_arguments[:tag] = fetch_or_fallback(TAG_OPTIONS, tag, TAG_DEFAULT) @system_arguments[:tag] = :li if list? && divider? @system_arguments[:role] ||= :menuitem - @system_arguments[:role] = :separator if divider + @system_arguments[:role] = :presentation if divider @system_arguments[:classes] = class_names( @system_arguments[:classes], "dropdown-item" => !divider, diff --git a/test/accessibility_test.rb b/test/accessibility_test.rb index d4bbd16efb..aa9d61135e 100644 --- a/test/accessibility_test.rb +++ b/test/accessibility_test.rb @@ -16,6 +16,13 @@ class AccessibilityTest < System::TestCase Docs::NavigationTabComponentPreview ].freeze + EXCLUDES = { + Primer::Alpha::ActionListPreview => { + # Skip `:aria-required-children` because is broken in 4.5/4.6: https://github.com/dequelabs/axe-core/issues/3758 + with_manual_dividers: %i[aria-required-children] + } + }.freeze + ViewComponent::Preview.all.each do |klass| next if IGNORED_PREVIEWS.include?(klass.to_s) @@ -25,7 +32,8 @@ class AccessibilityTest < System::TestCase component_previews.each do |preview| define_method(:"test_#{component_uri.parameterize(separator: "_")}_#{preview}") do visit("/rails/view_components/#{component_uri}/#{preview}") - assert_accessible + excludes = EXCLUDES.dig(klass, preview) || [] + assert_accessible(excludes: excludes) puts "#{component_uri}##{preview} passed check." end end diff --git a/test/components/alpha/action_list_test.rb b/test/components/alpha/action_list_test.rb index d9b8fccd6e..a07b78ed83 100644 --- a/test/components/alpha/action_list_test.rb +++ b/test/components/alpha/action_list_test.rb @@ -117,8 +117,9 @@ def test_uses_list_role_when_select_disabled component.with_item(label: "Item 1") end - assert_selector("ul.ActionListWrap[role=list]") - assert page.find_css("button.ActionListContent").first["role"].nil? + assert_selector("ul.ActionListWrap[role=list]") do + assert_selector("li.ActionListItem[role=listitem]") + end end def test_uses_correct_menu_and_item_roles_when_single_select @@ -127,7 +128,7 @@ def test_uses_correct_menu_and_item_roles_when_single_select end assert_selector("ul.ActionListWrap[role=menu]") do - assert_selector("li.ActionListItem button[role=menuitemradio]") + assert_selector("li.ActionListItem[role=menuitemradio] button") end end @@ -137,7 +138,7 @@ def test_uses_correct_menu_and_item_roles_when_multi_select end assert_selector("ul.ActionListWrap[role=menu]") do - assert_selector("li.ActionListItem button[role=menuitemcheckbox]") + assert_selector("li.ActionListItem[role=menuitemcheckbox] button") end end @@ -147,7 +148,7 @@ def test_uses_correct_item_role_when_role_set_to_menu end assert_selector("ul.ActionListWrap[role=menu]") do - assert_selector("li.ActionListItem button[role=menuitem]") + assert_selector("li.ActionListItem[role=menuitem] button") end end diff --git a/test/components/alpha/action_menu_test.rb b/test/components/alpha/action_menu_test.rb index 8e8329670e..25852d54c9 100644 --- a/test/components/alpha/action_menu_test.rb +++ b/test/components/alpha/action_menu_test.rb @@ -23,9 +23,7 @@ def test_renders_with_relevant_accessibility_attributes assert_selector("action-menu") do assert_selector("button[id='menu-1-button'][aria-haspopup='true']", text: "Menu") assert_selector("ul[id='menu-1-list'][aria-labelledby='menu-1-button'][role='menu']", visible: false) do - assert_selector("li", visible: false) do - assert_selector("span[role='menuitem']") - end + assert_selector("li[role='menuitem'] span", visible: false) end end end @@ -70,14 +68,14 @@ def test_allows_some_tags_as_nested_menu_item assert_selector("action-menu") do assert_selector("button[aria-haspopup='true']", text: "Trigger") assert_selector("ul", visible: false) do - assert_selector("li", visible: false) do - assert_selector("button[role='menuitem']", text: "Does something", visible: false) + assert_selector("li[role='menuitem']", visible: false) do + assert_selector("button", text: "Does something", visible: false) end - assert_selector("li", visible: false) do - assert_selector("a[role='menuitem'][href='/']", text: "Site", visible: false) + assert_selector("li[role='menuitem']", visible: false) do + assert_selector("a[href='/']", text: "Site", visible: false) end - assert_selector("li", visible: false) do - assert_selector("clipboard-copy[role='menuitem']", text: "Copy text", visible: false) + assert_selector("li[role='menuitem']", visible: false) do + assert_selector("clipboard-copy", text: "Copy text", visible: false) end end end diff --git a/test/components/alpha/dropdown/menu_test.rb b/test/components/alpha/dropdown/menu_test.rb index 3097cda266..7d886f7795 100644 --- a/test/components/alpha/dropdown/menu_test.rb +++ b/test/components/alpha/dropdown/menu_test.rb @@ -56,7 +56,7 @@ def test_renders_dividers component.with_item(divider: true) end - assert_selector(".dropdown-divider[role='separator']") + assert_selector(".dropdown-divider[role='presentation']") end def test_renders_as_list @@ -71,7 +71,7 @@ def test_renders_as_list assert_selector("li") do assert_selector(".dropdown-item[role='menuitem']", text: "Item 1") end - assert_selector("li.dropdown-divider[role='separator']") + assert_selector("li.dropdown-divider[role='presentation']") assert_selector("li") do assert_selector(".dropdown-item[role='menuitem']", text: "Item 2") end diff --git a/test/system/test_case.rb b/test/system/test_case.rb index 3edb07f7b9..56e49cb5bd 100644 --- a/test/system/test_case.rb +++ b/test/system/test_case.rb @@ -13,13 +13,11 @@ class TestCase < ActionDispatch::SystemTestCase # Skip `:region` which relates to preview page structure rather than individual component. # Skip `:color-contrast` which requires primer design-level change. - # Skip `:aria-required-children` is broken in 4.5: https://github.com/dequelabs/axe-core/issues/3758 # Skip `:link-in-text-block` which is new and seems broken. AXE_RULES_TO_SKIP = %i[ region color-contrast color-contrast-enhanced - aria-required-children link-in-text-block ].freeze From 5e29af0552383333b6a45cbe6ace044d236c9949 Mon Sep 17 00:00:00 2001 From: Cameron Dutro Date: Tue, 11 Apr 2023 14:33:29 -0700 Subject: [PATCH 02/11] Add changeset --- .changeset/chatty-owls-shave.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/chatty-owls-shave.md diff --git a/.changeset/chatty-owls-shave.md b/.changeset/chatty-owls-shave.md new file mode 100644 index 0000000000..f0ba5ef0fa --- /dev/null +++ b/.changeset/chatty-owls-shave.md @@ -0,0 +1,5 @@ +--- +'@primer/view-components': patch +--- + +Address NavList Axe violation From dcc50a687faa335501bfef31ee10c489066354b9 Mon Sep 17 00:00:00 2001 From: Cameron Dutro Date: Tue, 11 Apr 2023 15:52:23 -0700 Subject: [PATCH 03/11] Fix keyboard handling --- .../alpha/action_menu/action_menu_element.ts | 2 +- app/components/primer/alpha/modal_dialog.ts | 25 ++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/app/components/primer/alpha/action_menu/action_menu_element.ts b/app/components/primer/alpha/action_menu/action_menu_element.ts index 3a3f6c4413..33fc959b56 100644 --- a/app/components/primer/alpha/action_menu/action_menu_element.ts +++ b/app/components/primer/alpha/action_menu/action_menu_element.ts @@ -91,7 +91,7 @@ export class ActionMenuElement extends HTMLElement { const checked = ariaChecked !== 'true' item.setAttribute('aria-checked', `${checked}`) if (this.selectVariant === 'single') { - const selector = menuItemSelectors.map(s => `li[aria-checked] ${s}`).join(',') + const selector = menuItemSelectors.map(s => `li[aria-checked]${s}`).join(',') for (const checkedItemContent of this.querySelectorAll(selector)) { const checkedItem = checkedItemContent.closest('li')! if (checkedItem !== item) { diff --git a/app/components/primer/alpha/modal_dialog.ts b/app/components/primer/alpha/modal_dialog.ts index 1ed9076264..d28884a460 100644 --- a/app/components/primer/alpha/modal_dialog.ts +++ b/app/components/primer/alpha/modal_dialog.ts @@ -11,7 +11,14 @@ const overlayStack: ModalDialogElement[] = [] function clickHandler(event: Event) { const target = event.target as HTMLElement - const button = target?.closest('button') + let button: HTMLButtonElement | null = null + + if (target instanceof HTMLButtonElement) { + button = target + } else { + button = target?.querySelector('button') + } + if (!button) return // If the user is clicking a valid dialog trigger @@ -42,6 +49,21 @@ function clickHandler(event: Event) { } } +function keydownHandler(event: Event) { + if ( + !(event instanceof KeyboardEvent) || + event.type !== 'keydown' || + event.key !== 'Enter' || + event.ctrlKey || + event.altKey || + event.metaKey || + event.shiftKey + ) + return + + clickHandler(event) +} + function mousedownHandler(event: Event) { const target = event.target as HTMLElement if (target?.closest('button')) return @@ -124,6 +146,7 @@ export class ModalDialogElement extends HTMLElement { if (!this.hasAttribute('role')) this.setAttribute('role', 'dialog') document.addEventListener('click', clickHandler) + document.addEventListener('keydown', keydownHandler) document.addEventListener('mousedown', mousedownHandler) this.addEventListener('keydown', e => this.#keydown(e)) From 0ec81923f9c817d419ac8ac73f5ec131205a6fb4 Mon Sep 17 00:00:00 2001 From: Cameron Dutro Date: Tue, 11 Apr 2023 21:23:23 -0700 Subject: [PATCH 04/11] Get actions working; remove unnecessary constant; add tests --- .../alpha/action_menu/action_menu_element.ts | 1 - .../primer/alpha/action_menu/list.rb | 3 +- .../app/controllers/action_menu_controller.rb | 3 + demo/app/views/action_menu/landing.html.erb | 3 + demo/config/routes.rb | 1 + previews/primer/alpha/action_menu_preview.rb | 70 +++++++++---------- test/system/alpha/action_menu_test.rb | 48 +++++++++++++ 7 files changed, 91 insertions(+), 38 deletions(-) create mode 100644 demo/app/views/action_menu/landing.html.erb diff --git a/app/components/primer/alpha/action_menu/action_menu_element.ts b/app/components/primer/alpha/action_menu/action_menu_element.ts index 33fc959b56..e060bf97b8 100644 --- a/app/components/primer/alpha/action_menu/action_menu_element.ts +++ b/app/components/primer/alpha/action_menu/action_menu_element.ts @@ -100,7 +100,6 @@ export class ActionMenuElement extends HTMLElement { } this.#setDynamicLabel() } - event.preventDefault() this.popoverElement?.hidePopover() } } diff --git a/app/components/primer/alpha/action_menu/list.rb b/app/components/primer/alpha/action_menu/list.rb index 501ad8e632..b6b6809476 100644 --- a/app/components/primer/alpha/action_menu/list.rb +++ b/app/components/primer/alpha/action_menu/list.rb @@ -9,7 +9,6 @@ class ActionMenu class List < Primer::Alpha::ActionList DEFAULT_ITEM_TAG = :span ITEM_TAG_OPTIONS = [:a, :button, :"clipboard-copy", DEFAULT_ITEM_TAG].freeze - ITEM_ACTION_OPTIONS = [:classes, :onclick, :href, :value].freeze # Adds a new item to the list. # @@ -31,7 +30,7 @@ def with_item(**system_arguments, &block) # rubocop:disable Style/IfUnlessModifier if content_arguments[:tag] == :a - content_arguments[:href] = system_arguments.delete(:href) + content_arguments[:href] ||= system_arguments.delete(:href) end # rubocop:enable Style/IfUnlessModifier diff --git a/demo/app/controllers/action_menu_controller.rb b/demo/app/controllers/action_menu_controller.rb index df3da0bec0..9d08935f87 100644 --- a/demo/app/controllers/action_menu_controller.rb +++ b/demo/app/controllers/action_menu_controller.rb @@ -4,6 +4,9 @@ class ActionMenuController < ApplicationController layout false + def landing + end + def deferred render "action_menu/deferred" end diff --git a/demo/app/views/action_menu/landing.html.erb b/demo/app/views/action_menu/landing.html.erb new file mode 100644 index 0000000000..143f386531 --- /dev/null +++ b/demo/app/views/action_menu/landing.html.erb @@ -0,0 +1,3 @@ +
+ Hello world! +
diff --git a/demo/config/routes.rb b/demo/config/routes.rb index 24dc641160..9cdd9d3b33 100644 --- a/demo/config/routes.rb +++ b/demo/config/routes.rb @@ -20,6 +20,7 @@ post "/example_check/error", to: "auto_check#error", as: :example_check_error post "/example_check/random", to: "auto_check#random", as: :example_check_random + get "/action_menu/landing_page", to: "action_menu#landing", as: :action_menu_landing get "/action_menu/deferred", to: "action_menu#deferred", as: :action_menu_deferred get "/action_menu/deferred_preload", to: "action_menu#deferred_preload", as: :action_menu_deferred_preload end diff --git a/previews/primer/alpha/action_menu_preview.rb b/previews/primer/alpha/action_menu_preview.rb index cbf9fc637e..fde7378881 100644 --- a/previews/primer/alpha/action_menu_preview.rb +++ b/previews/primer/alpha/action_menu_preview.rb @@ -14,12 +14,12 @@ def playground( ) render(Primer::Alpha::ActionMenu.new(select_variant: select_variant, anchor_align: anchor_align, anchor_side: anchor_side)) do |menu| menu.with_show_button { "Menu" } - menu.with_item(label: "Copy link", value: "") - menu.with_item(label: "Quote reply", value: "") - menu.with_item(label: "Reference in new issue", value: "") + menu.with_item(label: "Copy link") + menu.with_item(label: "Quote reply") + menu.with_item(label: "Reference in new issue") menu.with_divider - menu.with_item(label: "Edit", value: "") - menu.with_item(label: "Delete", scheme: :danger, value: "") + menu.with_item(label: "Edit") + menu.with_item(label: "Delete", scheme: :danger) end end @@ -28,14 +28,14 @@ def playground( def default render(Primer::Alpha::ActionMenu.new(menu_id: "menu-1")) do |menu| menu.with_show_button { |button| button.with_trailing_action_icon(icon: :"triangle-down"); "Menu" } - menu.with_item(label: "Copy link", value: "") - menu.with_item(label: "Quote reply", value: "") - menu.with_item(label: "Reference in new issue", value: "") + menu.with_item(label: "Copy link") + menu.with_item(label: "Quote reply") + menu.with_item(label: "Reference in new issue") menu.with_divider - menu.with_item(label: "Edit", value: "") do |item| + menu.with_item(label: "Edit") do |item| item.with_description.with_content("Change settings") end - menu.with_item(label: "Delete", scheme: :danger, value: "") do |item| + menu.with_item(label: "Delete", scheme: :danger) do |item| item.with_description.with_content("Sayonara") end end @@ -46,7 +46,7 @@ def default def with_icon_button render(Primer::Alpha::ActionMenu.new) do |menu| menu.with_show_button(icon: :star, "aria-label": "Menu") - menu.with_item(label: "Does something", value: "") + menu.with_item(label: "Does something") end end @@ -55,10 +55,10 @@ def with_icon_button def single_select render(Primer::Alpha::ActionMenu.new(select_variant: :single)) do |menu| menu.with_show_button { "Menu" } - menu.with_item(label: "Fast forward", value: "") - menu.with_item(label: "Recursive", value: "") - menu.with_item(label: "Ours", value: "") - menu.with_item(label: "Resolve", value: "") + menu.with_item(label: "Fast forward") + menu.with_item(label: "Recursive") + menu.with_item(label: "Ours") + menu.with_item(label: "Resolve") end end @@ -67,15 +67,15 @@ def single_select def multiple_select render(Primer::Alpha::ActionMenu.new(select_variant: :multiple)) do |menu| menu.with_show_button { "Menu" } - menu.with_item(label: "langermank", description_scheme: :inline, value: "") do |item| + menu.with_item(label: "langermank", description_scheme: :inline) do |item| item.with_leading_visual_avatar(src: "https://avatars.githubusercontent.com/u/18661030?v=4", alt: "Katie Langerman") item.with_description.with_content("Katie Langerman") end - menu.with_item(label: "jonrohan", description_scheme: :inline, value: "") do |item| + menu.with_item(label: "jonrohan", description_scheme: :inline) do |item| item.with_leading_visual_avatar(src: "https://avatars.githubusercontent.com/u/54012?s=96&v=4", alt: "Jon Rohan") item.with_description.with_content("Jon Rohan") end - menu.with_item(label: "broccolinisoup", description_scheme: :inline, value: "") do |item| + menu.with_item(label: "broccolinisoup", description_scheme: :inline) do |item| item.with_leading_visual_avatar(src: "https://avatars.githubusercontent.com/u/1446503?v=4", alt: "Armağan Ersöz") item.with_description.with_content("Armağan Ersöz") end @@ -98,9 +98,9 @@ def links def single_selected_item render(Primer::Alpha::ActionMenu.new(select_variant: :single)) do |menu| menu.with_show_button { "Menu" } - menu.with_item(label: "Copy link", value: "") - menu.with_item(label: "Quote reply", active: true, value: "") - menu.with_item(label: "Reference in new issue", value: "") + menu.with_item(label: "Copy link") + menu.with_item(label: "Quote reply", active: true) + menu.with_item(label: "Reference in new issue") end end @@ -109,9 +109,9 @@ def single_selected_item def single_select_with_internal_label render(Primer::Alpha::ActionMenu.new(select_variant: :single, dynamic_label: true, dynamic_label_prefix: "Menu")) do |menu| menu.with_show_button { "Menu" } - menu.with_item(label: "Copy link", value: "") - menu.with_item(label: "Quote reply", active: true, value: "") - menu.with_item(label: "Reference in new issue", value: "") + menu.with_item(label: "Copy link") + menu.with_item(label: "Quote reply", active: true) + menu.with_item(label: "Reference in new issue") end end @@ -120,15 +120,15 @@ def single_select_with_internal_label def multiple_selected_items render(Primer::Alpha::ActionMenu.new(select_variant: :multiple)) do |menu| menu.with_show_button { "Menu" } - menu.with_item(label: "langermank", description_scheme: :inline, value: "", active: true) do |item| + menu.with_item(label: "langermank", description_scheme: :inline, active: true) do |item| item.with_leading_visual_avatar(src: "https://avatars.githubusercontent.com/u/18661030?v=4", alt: "Katie Langerman") item.with_description.with_content("Katie Langerman") end - menu.with_item(label: "jonrohan", description_scheme: :inline, value: "") do |item| + menu.with_item(label: "jonrohan", description_scheme: :inline) do |item| item.with_leading_visual_avatar(src: "https://avatars.githubusercontent.com/u/54012?s=96&v=4", alt: "Jon Rohan") item.with_description.with_content("Jon Rohan") end - menu.with_item(label: "broccolinisoup", description_scheme: :inline, value: "", active: true) do |item| + menu.with_item(label: "broccolinisoup", description_scheme: :inline, active: true) do |item| item.with_leading_visual_avatar(src: "https://avatars.githubusercontent.com/u/1446503?v=4", alt: "Armağan Ersöz") item.with_description.with_content("Armağan Ersöz") end @@ -156,9 +156,9 @@ def with_deferred_preloaded_content def with_actions render(Primer::Alpha::ActionMenu.new) do |component| component.with_show_button { "Trigger" } - component.with_item(label: "Does something", tag: :button, classes: "do-something-js") - component.with_item(label: "Site", tag: :a, href: "/") - component.with_item(label: "Copy text", tag: :"clipboard-copy", value: "Text to copy") + component.with_item(label: "Alert", tag: :button, content_arguments: { onclick: "alert('foo')" }) + component.with_item(label: "Navigate", tag: :a, content_arguments: { href: UrlHelpers.action_menu_landing_path }) + component.with_item(label: "Copy text", tag: :"clipboard-copy", content_arguments: { value: "Text to copy" }) end end @@ -167,7 +167,7 @@ def with_actions def with_disabled_items render(Primer::Alpha::ActionMenu.new) do |component| component.with_show_button { "Trigger" } - component.with_item(label: "Does something", tag: :button, value: "", disabled: true) + component.with_item(label: "Does something", tag: :button, disabled: true) component.with_item(label: "Site", tag: :a, href: "/", disabled: true) end end @@ -193,7 +193,7 @@ def align_end(menu_id: "menu-1") def block_description render(Primer::Alpha::ActionMenu.new(menu_id: "menu-1")) do |menu| menu.with_show_button { |button| button.with_trailing_action_icon(icon: :"triangle-down"); "Menu" } - menu.with_item(label: "Item label", value: "") do |item| + menu.with_item(label: "Item label") do |item| item.with_description.with_content("Block description") end end @@ -204,7 +204,7 @@ def block_description def inline_description render(Primer::Alpha::ActionMenu.new(menu_id: "menu-1")) do |menu| menu.with_show_button { |button| button.with_trailing_action_icon(icon: :"triangle-down"); "Menu" } - menu.with_item(label: "Item label", value: "", description_scheme: :inline) do |item| + menu.with_item(label: "Item label", description_scheme: :inline) do |item| item.with_description.with_content("Inline description") end end @@ -215,7 +215,7 @@ def inline_description def leading_visual render(Primer::Alpha::ActionMenu.new(menu_id: "menu-1")) do |menu| menu.with_show_button { |button| button.with_trailing_action_icon(icon: :"triangle-down"); "Menu" } - menu.with_item(label: "Item label", value: "", description_scheme: :inline) do |item| + menu.with_item(label: "Item label", description_scheme: :inline) do |item| item.with_leading_visual_icon(icon: :gear) item.with_description.with_content("Inline description") end @@ -227,7 +227,7 @@ def leading_visual def leading_visual_single_select render(Primer::Alpha::ActionMenu.new(menu_id: "menu-1", select_variant: :single)) do |menu| menu.with_show_button { |button| button.with_trailing_action_icon(icon: :"triangle-down"); "Menu" } - menu.with_item(label: "Item label", value: "", description_scheme: :inline) do |item| + menu.with_item(label: "Item label", description_scheme: :inline) do |item| item.with_leading_visual_icon(icon: :gear) item.with_description.with_content("Inline description") end diff --git a/test/system/alpha/action_menu_test.rb b/test/system/alpha/action_menu_test.rb index de053a911a..3b17c88d30 100644 --- a/test/system/alpha/action_menu_test.rb +++ b/test/system/alpha/action_menu_test.rb @@ -26,5 +26,53 @@ def test_anchor_align assert_selector("anchored-position[align=end]") end + + def test_action_onclick + visit_preview(:with_actions) + + find("action-menu button[aria-controls]").click + + accept_alert do + find("action-menu ul li:first-child").click + end + end + + def test_action_anchor + visit_preview(:with_actions) + + find("action-menu button[aria-controls]").click + find("action-menu ul li:nth-child(2)").click + + assert_selector ".action-menu-landing-page", text: "Hello world!" + end + + def test_action_clipboard_copy + visit_preview(:with_actions) + + # Stub out the clipboard b/c configuring Cuprite with the right permissions stumped + # the hell out of me and ChatGPT. + page.evaluate_script(<<~JS) + (() => { + navigator.clipboard.writeText = async (text) => { + this.text = text; + return Promise.resolve(null); + }; + + navigator.clipboard.readText = async () => { + return Promise.resolve(this.text); + }; + })() + JS + + find("action-menu button[aria-controls]").click + find("action-menu ul li:nth-child(3)").click + + clipboard_text = page.evaluate_async_script(<<~JS) + const [done] = arguments; + navigator.clipboard.readText().then(done).catch((e) => done(e)); + JS + + assert_equal clipboard_text, "Text to copy" + end end end From d3c9f5feb73a92d46123845010ee8fdaa344744c Mon Sep 17 00:00:00 2001 From: Cameron Dutro Date: Tue, 11 Apr 2023 21:23:23 -0700 Subject: [PATCH 05/11] Get actions working; remove unnecessary constant; add tests --- .../primer/alpha/action_menu/list.rb | 3 +- .../app/controllers/action_menu_controller.rb | 3 + demo/app/views/action_menu/landing.html.erb | 3 + demo/config/routes.rb | 1 + previews/primer/alpha/action_menu_preview.rb | 70 +++++++++---------- test/system/alpha/action_menu_test.rb | 48 +++++++++++++ 6 files changed, 91 insertions(+), 37 deletions(-) create mode 100644 demo/app/views/action_menu/landing.html.erb diff --git a/app/components/primer/alpha/action_menu/list.rb b/app/components/primer/alpha/action_menu/list.rb index 501ad8e632..b6b6809476 100644 --- a/app/components/primer/alpha/action_menu/list.rb +++ b/app/components/primer/alpha/action_menu/list.rb @@ -9,7 +9,6 @@ class ActionMenu class List < Primer::Alpha::ActionList DEFAULT_ITEM_TAG = :span ITEM_TAG_OPTIONS = [:a, :button, :"clipboard-copy", DEFAULT_ITEM_TAG].freeze - ITEM_ACTION_OPTIONS = [:classes, :onclick, :href, :value].freeze # Adds a new item to the list. # @@ -31,7 +30,7 @@ def with_item(**system_arguments, &block) # rubocop:disable Style/IfUnlessModifier if content_arguments[:tag] == :a - content_arguments[:href] = system_arguments.delete(:href) + content_arguments[:href] ||= system_arguments.delete(:href) end # rubocop:enable Style/IfUnlessModifier diff --git a/demo/app/controllers/action_menu_controller.rb b/demo/app/controllers/action_menu_controller.rb index df3da0bec0..9d08935f87 100644 --- a/demo/app/controllers/action_menu_controller.rb +++ b/demo/app/controllers/action_menu_controller.rb @@ -4,6 +4,9 @@ class ActionMenuController < ApplicationController layout false + def landing + end + def deferred render "action_menu/deferred" end diff --git a/demo/app/views/action_menu/landing.html.erb b/demo/app/views/action_menu/landing.html.erb new file mode 100644 index 0000000000..143f386531 --- /dev/null +++ b/demo/app/views/action_menu/landing.html.erb @@ -0,0 +1,3 @@ +
+ Hello world! +
diff --git a/demo/config/routes.rb b/demo/config/routes.rb index 24dc641160..9cdd9d3b33 100644 --- a/demo/config/routes.rb +++ b/demo/config/routes.rb @@ -20,6 +20,7 @@ post "/example_check/error", to: "auto_check#error", as: :example_check_error post "/example_check/random", to: "auto_check#random", as: :example_check_random + get "/action_menu/landing_page", to: "action_menu#landing", as: :action_menu_landing get "/action_menu/deferred", to: "action_menu#deferred", as: :action_menu_deferred get "/action_menu/deferred_preload", to: "action_menu#deferred_preload", as: :action_menu_deferred_preload end diff --git a/previews/primer/alpha/action_menu_preview.rb b/previews/primer/alpha/action_menu_preview.rb index cbf9fc637e..fde7378881 100644 --- a/previews/primer/alpha/action_menu_preview.rb +++ b/previews/primer/alpha/action_menu_preview.rb @@ -14,12 +14,12 @@ def playground( ) render(Primer::Alpha::ActionMenu.new(select_variant: select_variant, anchor_align: anchor_align, anchor_side: anchor_side)) do |menu| menu.with_show_button { "Menu" } - menu.with_item(label: "Copy link", value: "") - menu.with_item(label: "Quote reply", value: "") - menu.with_item(label: "Reference in new issue", value: "") + menu.with_item(label: "Copy link") + menu.with_item(label: "Quote reply") + menu.with_item(label: "Reference in new issue") menu.with_divider - menu.with_item(label: "Edit", value: "") - menu.with_item(label: "Delete", scheme: :danger, value: "") + menu.with_item(label: "Edit") + menu.with_item(label: "Delete", scheme: :danger) end end @@ -28,14 +28,14 @@ def playground( def default render(Primer::Alpha::ActionMenu.new(menu_id: "menu-1")) do |menu| menu.with_show_button { |button| button.with_trailing_action_icon(icon: :"triangle-down"); "Menu" } - menu.with_item(label: "Copy link", value: "") - menu.with_item(label: "Quote reply", value: "") - menu.with_item(label: "Reference in new issue", value: "") + menu.with_item(label: "Copy link") + menu.with_item(label: "Quote reply") + menu.with_item(label: "Reference in new issue") menu.with_divider - menu.with_item(label: "Edit", value: "") do |item| + menu.with_item(label: "Edit") do |item| item.with_description.with_content("Change settings") end - menu.with_item(label: "Delete", scheme: :danger, value: "") do |item| + menu.with_item(label: "Delete", scheme: :danger) do |item| item.with_description.with_content("Sayonara") end end @@ -46,7 +46,7 @@ def default def with_icon_button render(Primer::Alpha::ActionMenu.new) do |menu| menu.with_show_button(icon: :star, "aria-label": "Menu") - menu.with_item(label: "Does something", value: "") + menu.with_item(label: "Does something") end end @@ -55,10 +55,10 @@ def with_icon_button def single_select render(Primer::Alpha::ActionMenu.new(select_variant: :single)) do |menu| menu.with_show_button { "Menu" } - menu.with_item(label: "Fast forward", value: "") - menu.with_item(label: "Recursive", value: "") - menu.with_item(label: "Ours", value: "") - menu.with_item(label: "Resolve", value: "") + menu.with_item(label: "Fast forward") + menu.with_item(label: "Recursive") + menu.with_item(label: "Ours") + menu.with_item(label: "Resolve") end end @@ -67,15 +67,15 @@ def single_select def multiple_select render(Primer::Alpha::ActionMenu.new(select_variant: :multiple)) do |menu| menu.with_show_button { "Menu" } - menu.with_item(label: "langermank", description_scheme: :inline, value: "") do |item| + menu.with_item(label: "langermank", description_scheme: :inline) do |item| item.with_leading_visual_avatar(src: "https://avatars.githubusercontent.com/u/18661030?v=4", alt: "Katie Langerman") item.with_description.with_content("Katie Langerman") end - menu.with_item(label: "jonrohan", description_scheme: :inline, value: "") do |item| + menu.with_item(label: "jonrohan", description_scheme: :inline) do |item| item.with_leading_visual_avatar(src: "https://avatars.githubusercontent.com/u/54012?s=96&v=4", alt: "Jon Rohan") item.with_description.with_content("Jon Rohan") end - menu.with_item(label: "broccolinisoup", description_scheme: :inline, value: "") do |item| + menu.with_item(label: "broccolinisoup", description_scheme: :inline) do |item| item.with_leading_visual_avatar(src: "https://avatars.githubusercontent.com/u/1446503?v=4", alt: "Armağan Ersöz") item.with_description.with_content("Armağan Ersöz") end @@ -98,9 +98,9 @@ def links def single_selected_item render(Primer::Alpha::ActionMenu.new(select_variant: :single)) do |menu| menu.with_show_button { "Menu" } - menu.with_item(label: "Copy link", value: "") - menu.with_item(label: "Quote reply", active: true, value: "") - menu.with_item(label: "Reference in new issue", value: "") + menu.with_item(label: "Copy link") + menu.with_item(label: "Quote reply", active: true) + menu.with_item(label: "Reference in new issue") end end @@ -109,9 +109,9 @@ def single_selected_item def single_select_with_internal_label render(Primer::Alpha::ActionMenu.new(select_variant: :single, dynamic_label: true, dynamic_label_prefix: "Menu")) do |menu| menu.with_show_button { "Menu" } - menu.with_item(label: "Copy link", value: "") - menu.with_item(label: "Quote reply", active: true, value: "") - menu.with_item(label: "Reference in new issue", value: "") + menu.with_item(label: "Copy link") + menu.with_item(label: "Quote reply", active: true) + menu.with_item(label: "Reference in new issue") end end @@ -120,15 +120,15 @@ def single_select_with_internal_label def multiple_selected_items render(Primer::Alpha::ActionMenu.new(select_variant: :multiple)) do |menu| menu.with_show_button { "Menu" } - menu.with_item(label: "langermank", description_scheme: :inline, value: "", active: true) do |item| + menu.with_item(label: "langermank", description_scheme: :inline, active: true) do |item| item.with_leading_visual_avatar(src: "https://avatars.githubusercontent.com/u/18661030?v=4", alt: "Katie Langerman") item.with_description.with_content("Katie Langerman") end - menu.with_item(label: "jonrohan", description_scheme: :inline, value: "") do |item| + menu.with_item(label: "jonrohan", description_scheme: :inline) do |item| item.with_leading_visual_avatar(src: "https://avatars.githubusercontent.com/u/54012?s=96&v=4", alt: "Jon Rohan") item.with_description.with_content("Jon Rohan") end - menu.with_item(label: "broccolinisoup", description_scheme: :inline, value: "", active: true) do |item| + menu.with_item(label: "broccolinisoup", description_scheme: :inline, active: true) do |item| item.with_leading_visual_avatar(src: "https://avatars.githubusercontent.com/u/1446503?v=4", alt: "Armağan Ersöz") item.with_description.with_content("Armağan Ersöz") end @@ -156,9 +156,9 @@ def with_deferred_preloaded_content def with_actions render(Primer::Alpha::ActionMenu.new) do |component| component.with_show_button { "Trigger" } - component.with_item(label: "Does something", tag: :button, classes: "do-something-js") - component.with_item(label: "Site", tag: :a, href: "/") - component.with_item(label: "Copy text", tag: :"clipboard-copy", value: "Text to copy") + component.with_item(label: "Alert", tag: :button, content_arguments: { onclick: "alert('foo')" }) + component.with_item(label: "Navigate", tag: :a, content_arguments: { href: UrlHelpers.action_menu_landing_path }) + component.with_item(label: "Copy text", tag: :"clipboard-copy", content_arguments: { value: "Text to copy" }) end end @@ -167,7 +167,7 @@ def with_actions def with_disabled_items render(Primer::Alpha::ActionMenu.new) do |component| component.with_show_button { "Trigger" } - component.with_item(label: "Does something", tag: :button, value: "", disabled: true) + component.with_item(label: "Does something", tag: :button, disabled: true) component.with_item(label: "Site", tag: :a, href: "/", disabled: true) end end @@ -193,7 +193,7 @@ def align_end(menu_id: "menu-1") def block_description render(Primer::Alpha::ActionMenu.new(menu_id: "menu-1")) do |menu| menu.with_show_button { |button| button.with_trailing_action_icon(icon: :"triangle-down"); "Menu" } - menu.with_item(label: "Item label", value: "") do |item| + menu.with_item(label: "Item label") do |item| item.with_description.with_content("Block description") end end @@ -204,7 +204,7 @@ def block_description def inline_description render(Primer::Alpha::ActionMenu.new(menu_id: "menu-1")) do |menu| menu.with_show_button { |button| button.with_trailing_action_icon(icon: :"triangle-down"); "Menu" } - menu.with_item(label: "Item label", value: "", description_scheme: :inline) do |item| + menu.with_item(label: "Item label", description_scheme: :inline) do |item| item.with_description.with_content("Inline description") end end @@ -215,7 +215,7 @@ def inline_description def leading_visual render(Primer::Alpha::ActionMenu.new(menu_id: "menu-1")) do |menu| menu.with_show_button { |button| button.with_trailing_action_icon(icon: :"triangle-down"); "Menu" } - menu.with_item(label: "Item label", value: "", description_scheme: :inline) do |item| + menu.with_item(label: "Item label", description_scheme: :inline) do |item| item.with_leading_visual_icon(icon: :gear) item.with_description.with_content("Inline description") end @@ -227,7 +227,7 @@ def leading_visual def leading_visual_single_select render(Primer::Alpha::ActionMenu.new(menu_id: "menu-1", select_variant: :single)) do |menu| menu.with_show_button { |button| button.with_trailing_action_icon(icon: :"triangle-down"); "Menu" } - menu.with_item(label: "Item label", value: "", description_scheme: :inline) do |item| + menu.with_item(label: "Item label", description_scheme: :inline) do |item| item.with_leading_visual_icon(icon: :gear) item.with_description.with_content("Inline description") end diff --git a/test/system/alpha/action_menu_test.rb b/test/system/alpha/action_menu_test.rb index de053a911a..3b17c88d30 100644 --- a/test/system/alpha/action_menu_test.rb +++ b/test/system/alpha/action_menu_test.rb @@ -26,5 +26,53 @@ def test_anchor_align assert_selector("anchored-position[align=end]") end + + def test_action_onclick + visit_preview(:with_actions) + + find("action-menu button[aria-controls]").click + + accept_alert do + find("action-menu ul li:first-child").click + end + end + + def test_action_anchor + visit_preview(:with_actions) + + find("action-menu button[aria-controls]").click + find("action-menu ul li:nth-child(2)").click + + assert_selector ".action-menu-landing-page", text: "Hello world!" + end + + def test_action_clipboard_copy + visit_preview(:with_actions) + + # Stub out the clipboard b/c configuring Cuprite with the right permissions stumped + # the hell out of me and ChatGPT. + page.evaluate_script(<<~JS) + (() => { + navigator.clipboard.writeText = async (text) => { + this.text = text; + return Promise.resolve(null); + }; + + navigator.clipboard.readText = async () => { + return Promise.resolve(this.text); + }; + })() + JS + + find("action-menu button[aria-controls]").click + find("action-menu ul li:nth-child(3)").click + + clipboard_text = page.evaluate_async_script(<<~JS) + const [done] = arguments; + navigator.clipboard.readText().then(done).catch((e) => done(e)); + JS + + assert_equal clipboard_text, "Text to copy" + end end end From 49a4d10b1e1e9204fcfbc2ef7f530a65daaf40ce Mon Sep 17 00:00:00 2001 From: Cameron Dutro Date: Wed, 12 Apr 2023 13:21:06 -0700 Subject: [PATCH 06/11] WIP --- app/components/primer/alpha/action_list/item.rb | 16 ++++++++-------- .../alpha/action_menu/action_menu_element.ts | 6 ++++-- app/components/primer/alpha/action_menu/list.rb | 2 +- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/app/components/primer/alpha/action_list/item.rb b/app/components/primer/alpha/action_list/item.rb index f06ef3fec7..8fd6ce1d23 100644 --- a/app/components/primer/alpha/action_list/item.rb +++ b/app/components/primer/alpha/action_list/item.rb @@ -230,14 +230,14 @@ def initialize( ) end - @system_arguments[:role] = role || - if @list.allows_selection? - ActionList::SELECT_VARIANT_ROLE_MAP[@list.select_variant] - elsif @list.acts_as_menu? - ActionList::DEFAULT_MENU_ITEM_ROLE - else - ActionList::DEFAULT_LIST_ITEM_ROLE - end + @content_arguments[:role] = role || + if @list.allows_selection? + ActionList::SELECT_VARIANT_ROLE_MAP[@list.select_variant] + elsif @list.acts_as_menu? + ActionList::DEFAULT_MENU_ITEM_ROLE + else + ActionList::DEFAULT_LIST_ITEM_ROLE + end @description_wrapper_arguments = { classes: class_names( diff --git a/app/components/primer/alpha/action_menu/action_menu_element.ts b/app/components/primer/alpha/action_menu/action_menu_element.ts index 33fc959b56..69b0070d8a 100644 --- a/app/components/primer/alpha/action_menu/action_menu_element.ts +++ b/app/components/primer/alpha/action_menu/action_menu_element.ts @@ -91,7 +91,7 @@ export class ActionMenuElement extends HTMLElement { const checked = ariaChecked !== 'true' item.setAttribute('aria-checked', `${checked}`) if (this.selectVariant === 'single') { - const selector = menuItemSelectors.map(s => `li[aria-checked]${s}`).join(',') + const selector = menuItemSelectors.map(s => `li[aria-checked] ${s}`).join(',') for (const checkedItemContent of this.querySelectorAll(selector)) { const checkedItem = checkedItemContent.closest('li')! if (checkedItem !== item) { @@ -100,7 +100,9 @@ export class ActionMenuElement extends HTMLElement { } this.#setDynamicLabel() } - event.preventDefault() + if (event instanceof KeyboardEvent) { + event.preventDefault() + } this.popoverElement?.hidePopover() } } diff --git a/app/components/primer/alpha/action_menu/list.rb b/app/components/primer/alpha/action_menu/list.rb index b6b6809476..ba464f5a9b 100644 --- a/app/components/primer/alpha/action_menu/list.rb +++ b/app/components/primer/alpha/action_menu/list.rb @@ -34,7 +34,7 @@ def with_item(**system_arguments, &block) end # rubocop:enable Style/IfUnlessModifier - system_arguments[:tabindex] = -1 + content_arguments[:tabindex] = -1 system_arguments[:autofocus] = "" if system_arguments[:autofocus] if system_arguments[:disabled] From b0c7f27d887f920d5fa17864c4ebe7f3ffaf121b Mon Sep 17 00:00:00 2001 From: Cameron Dutro Date: Wed, 12 Apr 2023 16:30:53 -0700 Subject: [PATCH 07/11] Ok, everything is finally working --- .../primer/alpha/action_menu/action_menu_element.ts | 13 +++++++++++-- app/components/primer/alpha/action_menu/list.rb | 4 ++-- previews/primer/alpha/action_menu_preview.rb | 2 +- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/app/components/primer/alpha/action_menu/action_menu_element.ts b/app/components/primer/alpha/action_menu/action_menu_element.ts index 69b0070d8a..dc58d8fd6f 100644 --- a/app/components/primer/alpha/action_menu/action_menu_element.ts +++ b/app/components/primer/alpha/action_menu/action_menu_element.ts @@ -100,10 +100,19 @@ export class ActionMenuElement extends HTMLElement { } this.#setDynamicLabel() } - if (event instanceof KeyboardEvent) { + if (event instanceof KeyboardEvent && event.target instanceof HTMLButtonElement) { + // prevent buttons from being clicked twice event.preventDefault() } - this.popoverElement?.hidePopover() + // Hide popover after current event loop to prevent changes in focus from + // altering the target of the event. Not doing this specifically affects + // tags. It causes the event to be sent to the currently focused element + // instead of the anchor, which effectively prevents navigation, i.e. it + // appears as if hitting enter does nothing. Curiously, clicking instead + // works fine. + if (this.selectVariant !== 'multiple') { + setTimeout(() => this.popoverElement?.hidePopover()) + } } } diff --git a/app/components/primer/alpha/action_menu/list.rb b/app/components/primer/alpha/action_menu/list.rb index ba464f5a9b..b826a4409c 100644 --- a/app/components/primer/alpha/action_menu/list.rb +++ b/app/components/primer/alpha/action_menu/list.rb @@ -7,7 +7,7 @@ class ActionMenu # This component is part of <%= link_to_component(Primer::Alpha::ActionMenu) %> and should not be # used as a standalone component. class List < Primer::Alpha::ActionList - DEFAULT_ITEM_TAG = :span + DEFAULT_ITEM_TAG = :button ITEM_TAG_OPTIONS = [:a, :button, :"clipboard-copy", DEFAULT_ITEM_TAG].freeze # Adds a new item to the list. @@ -34,7 +34,7 @@ def with_item(**system_arguments, &block) end # rubocop:enable Style/IfUnlessModifier - content_arguments[:tabindex] = -1 + system_arguments[:tabindex] = -1 system_arguments[:autofocus] = "" if system_arguments[:autofocus] if system_arguments[:disabled] diff --git a/previews/primer/alpha/action_menu_preview.rb b/previews/primer/alpha/action_menu_preview.rb index fde7378881..57444a9d89 100644 --- a/previews/primer/alpha/action_menu_preview.rb +++ b/previews/primer/alpha/action_menu_preview.rb @@ -156,7 +156,7 @@ def with_deferred_preloaded_content def with_actions render(Primer::Alpha::ActionMenu.new) do |component| component.with_show_button { "Trigger" } - component.with_item(label: "Alert", tag: :button, content_arguments: { onclick: "alert('foo')" }) + component.with_item(label: "Alert", tag: :button, content_arguments: { onclick: "alert('Foo')", onkeydown: "if (event.key === 'Enter') { alert('Foo') }" }) component.with_item(label: "Navigate", tag: :a, content_arguments: { href: UrlHelpers.action_menu_landing_path }) component.with_item(label: "Copy text", tag: :"clipboard-copy", content_arguments: { value: "Text to copy" }) end From 1ca8d77e2087a32764f05e7bdc7bed913e9f868f Mon Sep 17 00:00:00 2001 From: Cameron Dutro Date: Thu, 13 Apr 2023 10:19:46 -0700 Subject: [PATCH 08/11] Keyboard tests, but also WIP roles --- .gitignore | 1 + .../primer/alpha/action_list/item.rb | 4 +- app/components/primer/focus_group.ts | 2 +- previews/primer/alpha/action_menu_preview.rb | 6 +- test/accessibility_test.rb | 11 +++- test/components/alpha/action_list_test.rb | 8 +-- test/components/alpha/action_menu_test.rb | 20 +++---- test/system/alpha/action_menu_test.rb | 59 ++++++++++++++++--- 8 files changed, 80 insertions(+), 31 deletions(-) diff --git a/.gitignore b/.gitignore index 6c333eed59..c717f901f6 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ demo/app/assets/stylesheets/prim* /.yardoc /doc previews/docs/ +test/snapshots/ test/snapshots/docs/ demo/test/snapshots/ previews/pages/forms/inputs/*.md.erb diff --git a/app/components/primer/alpha/action_list/item.rb b/app/components/primer/alpha/action_list/item.rb index 8fd6ce1d23..7dcaad6f9d 100644 --- a/app/components/primer/alpha/action_list/item.rb +++ b/app/components/primer/alpha/action_list/item.rb @@ -231,7 +231,9 @@ def initialize( end @content_arguments[:role] = role || - if @list.allows_selection? + if @content_arguments[:tag] == :a + nil + elsif @list.allows_selection? ActionList::SELECT_VARIANT_ROLE_MAP[@list.select_variant] elsif @list.acts_as_menu? ActionList::DEFAULT_MENU_ITEM_ROLE diff --git a/app/components/primer/focus_group.ts b/app/components/primer/focus_group.ts index da9522afb5..e43275c2a5 100644 --- a/app/components/primer/focus_group.ts +++ b/app/components/primer/focus_group.ts @@ -1,6 +1,6 @@ import '@oddbird/popover-polyfill' -const menuItemSelector = '[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"]' +const menuItemSelector = '[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"],a' const popoverSelector = (() => { try { diff --git a/previews/primer/alpha/action_menu_preview.rb b/previews/primer/alpha/action_menu_preview.rb index 8c4fb2cdf9..6a98ca6409 100644 --- a/previews/primer/alpha/action_menu_preview.rb +++ b/previews/primer/alpha/action_menu_preview.rb @@ -156,11 +156,7 @@ def with_deferred_preloaded_content def with_actions render(Primer::Alpha::ActionMenu.new) do |component| component.with_show_button { "Trigger" } -<<<<<<< HEAD - component.with_item(label: "Alert", tag: :button, content_arguments: { onclick: "alert('Foo')", onkeydown: "if (event.key === 'Enter') { alert('Foo') }" }) -======= - component.with_item(label: "Alert", tag: :button, content_arguments: { onclick: "alert('foo')" }) ->>>>>>> 0ec81923f9c817d419ac8ac73f5ec131205a6fb4 + component.with_item(label: "Alert", tag: :button, content_arguments: { onclick: "alert('Foo')", onkeydown: "if (event.key === 'Enter') { alert(event.key) }" }) component.with_item(label: "Navigate", tag: :a, content_arguments: { href: UrlHelpers.action_menu_landing_path }) component.with_item(label: "Copy text", tag: :"clipboard-copy", content_arguments: { value: "Text to copy" }) end diff --git a/test/accessibility_test.rb b/test/accessibility_test.rb index aa9d61135e..f092c46aec 100644 --- a/test/accessibility_test.rb +++ b/test/accessibility_test.rb @@ -17,13 +17,18 @@ class AccessibilityTest < System::TestCase ].freeze EXCLUDES = { + # Skip `:aria-required-children` because is broken in 4.5/4.6: https://github.com/dequelabs/axe-core/issues/3758 Primer::Alpha::ActionListPreview => { - # Skip `:aria-required-children` because is broken in 4.5/4.6: https://github.com/dequelabs/axe-core/issues/3758 - with_manual_dividers: %i[aria-required-children] + all: %i[aria-required-children] + }, + + Primer::Alpha::NavListPreview => { + all: %i[aria-required-children] } }.freeze ViewComponent::Preview.all.each do |klass| + next unless [Primer::Alpha::ActionMenuPreview].include?(klass) next if IGNORED_PREVIEWS.include?(klass.to_s) component_previews = klass.instance_methods(false) @@ -32,7 +37,7 @@ class AccessibilityTest < System::TestCase component_previews.each do |preview| define_method(:"test_#{component_uri.parameterize(separator: "_")}_#{preview}") do visit("/rails/view_components/#{component_uri}/#{preview}") - excludes = EXCLUDES.dig(klass, preview) || [] + excludes = (EXCLUDES.dig(klass, preview) || []) + (EXCLUDES.dig(klass, :all) || []) assert_accessible(excludes: excludes) puts "#{component_uri}##{preview} passed check." end diff --git a/test/components/alpha/action_list_test.rb b/test/components/alpha/action_list_test.rb index a07b78ed83..3799eacd65 100644 --- a/test/components/alpha/action_list_test.rb +++ b/test/components/alpha/action_list_test.rb @@ -118,7 +118,7 @@ def test_uses_list_role_when_select_disabled end assert_selector("ul.ActionListWrap[role=list]") do - assert_selector("li.ActionListItem[role=listitem]") + assert_selector("li.ActionListItem button[role=listitem]") end end @@ -128,7 +128,7 @@ def test_uses_correct_menu_and_item_roles_when_single_select end assert_selector("ul.ActionListWrap[role=menu]") do - assert_selector("li.ActionListItem[role=menuitemradio] button") + assert_selector("li.ActionListItem button[role=menuitemradio]") end end @@ -138,7 +138,7 @@ def test_uses_correct_menu_and_item_roles_when_multi_select end assert_selector("ul.ActionListWrap[role=menu]") do - assert_selector("li.ActionListItem[role=menuitemcheckbox] button") + assert_selector("li.ActionListItem button[role=menuitemcheckbox]") end end @@ -148,7 +148,7 @@ def test_uses_correct_item_role_when_role_set_to_menu end assert_selector("ul.ActionListWrap[role=menu]") do - assert_selector("li.ActionListItem[role=menuitem] button") + assert_selector("li.ActionListItem button[role=menuitem]") end end diff --git a/test/components/alpha/action_menu_test.rb b/test/components/alpha/action_menu_test.rb index 25852d54c9..ebc3a5ec30 100644 --- a/test/components/alpha/action_menu_test.rb +++ b/test/components/alpha/action_menu_test.rb @@ -23,12 +23,12 @@ def test_renders_with_relevant_accessibility_attributes assert_selector("action-menu") do assert_selector("button[id='menu-1-button'][aria-haspopup='true']", text: "Menu") assert_selector("ul[id='menu-1-list'][aria-labelledby='menu-1-button'][role='menu']", visible: false) do - assert_selector("li[role='menuitem'] span", visible: false) + assert_selector("li button[role='menuitem']", visible: false) end end end - def test_falls_back_to_span_if_disallowed_tag_is_given + def test_falls_back_to_button_if_disallowed_tag_is_given without_fetch_or_fallback_raises do render_inline Primer::Alpha::ActionMenu.new(menu_id: "bad-menu") do |component| component.with_show_button { "Trigger" } @@ -36,7 +36,7 @@ def test_falls_back_to_span_if_disallowed_tag_is_given end end - assert_selector("span.ActionListContent", visible: false) + assert_selector("button.ActionListContent", visible: false) end def test_falls_back_to_default_anchor_align_and_anchor_side_if_non_allowed_option_is_set @@ -68,14 +68,14 @@ def test_allows_some_tags_as_nested_menu_item assert_selector("action-menu") do assert_selector("button[aria-haspopup='true']", text: "Trigger") assert_selector("ul", visible: false) do - assert_selector("li[role='menuitem']", visible: false) do - assert_selector("button", text: "Does something", visible: false) + assert_selector("li", visible: false) do + assert_selector("button[role='menuitem']", text: "Alert", visible: false) end - assert_selector("li[role='menuitem']", visible: false) do - assert_selector("a[href='/']", text: "Site", visible: false) + assert_selector("li", visible: false) do + assert_selector("a", text: "Navigate", visible: false) end - assert_selector("li[role='menuitem']", visible: false) do - assert_selector("clipboard-copy", text: "Copy text", visible: false) + assert_selector("li", visible: false) do + assert_selector("clipboard-copy[role='menuitem']", text: "Copy text", visible: false) end end end @@ -125,7 +125,7 @@ def test_renders_a_tag_when_href_provided assert_selector("li a[href='/']", text: "Settings") assert_selector("li a[href='/']", text: "Your repositories") - assert_selector("li span[aria-disabled=true]", text: "Disabled") + assert_selector("li button[aria-disabled=true]", text: "Disabled") end end end diff --git a/test/system/alpha/action_menu_test.rb b/test/system/alpha/action_menu_test.rb index 3b17c88d30..d6bdb21585 100644 --- a/test/system/alpha/action_menu_test.rb +++ b/test/system/alpha/action_menu_test.rb @@ -37,6 +37,19 @@ def test_action_onclick end end + def test_action_keydown + visit_preview(:with_actions) + + page.evaluate_script(<<~JS) + document.querySelector('action-menu button[aria-controls]').focus() + JS + + accept_alert do + # open menu, "click" on first item + page.driver.browser.keyboard.type(:enter, :enter) + end + end + def test_action_anchor visit_preview(:with_actions) @@ -46,11 +59,20 @@ def test_action_anchor assert_selector ".action-menu-landing-page", text: "Hello world!" end - def test_action_clipboard_copy + def test_action_anchor_keydown visit_preview(:with_actions) - # Stub out the clipboard b/c configuring Cuprite with the right permissions stumped - # the hell out of me and ChatGPT. + page.evaluate_script(<<~JS) + document.querySelector('action-menu button[aria-controls]').focus() + JS + + # open menu, arrow down to second item, "click" second item + page.driver.browser.keyboard.type(:enter, :down, :enter) + + assert_selector ".action-menu-landing-page", text: "Hello world!" + end + + def stub_clipboard! page.evaluate_script(<<~JS) (() => { navigator.clipboard.writeText = async (text) => { @@ -63,16 +85,39 @@ def test_action_clipboard_copy }; })() JS + end + + def read_clipboard + page.evaluate_async_script(<<~JS) + const [done] = arguments; + navigator.clipboard.readText().then(done).catch((e) => done(e)); + JS + end + + def test_action_clipboard_copy + visit_preview(:with_actions) + + stub_clipboard! find("action-menu button[aria-controls]").click find("action-menu ul li:nth-child(3)").click - clipboard_text = page.evaluate_async_script(<<~JS) - const [done] = arguments; - navigator.clipboard.readText().then(done).catch((e) => done(e)); + assert_equal read_clipboard, "Text to copy" + end + + def test_action_clipboard_copy_keydown + visit_preview(:with_actions) + + stub_clipboard! + + page.evaluate_script(<<~JS) + document.querySelector('action-menu button[aria-controls]').focus() JS - assert_equal clipboard_text, "Text to copy" + # open menu, arrow down to third item, "click" third item + page.driver.browser.keyboard.type(:enter, :down, :down, :enter) + + assert_equal read_clipboard, "Text to copy" end end end From f0162861dc99b86be4cc6785f48c5837cd13ba79 Mon Sep 17 00:00:00 2001 From: Cameron Dutro Date: Thu, 13 Apr 2023 15:01:32 -0700 Subject: [PATCH 09/11] Roles are FINALLY working --- app/components/primer/alpha/action_list.rb | 2 +- .../primer/alpha/action_list/item.rb | 10 ++---- .../alpha/action_menu/action_menu_element.ts | 35 +++++++++++++------ .../primer/alpha/action_menu/list.rb | 2 +- app/components/primer/focus_group.ts | 2 +- test/accessibility_test.rb | 12 +------ test/system/alpha/action_menu_test.rb | 13 +++++++ 7 files changed, 45 insertions(+), 31 deletions(-) diff --git a/app/components/primer/alpha/action_list.rb b/app/components/primer/alpha/action_list.rb index 7b4a54b1fd..22bd82bf53 100644 --- a/app/components/primer/alpha/action_list.rb +++ b/app/components/primer/alpha/action_list.rb @@ -108,7 +108,7 @@ def initialize( "ActionListWrap--divided" => @show_dividers ) - @role = role || allows_selection? ? MENU_ROLE : DEFAULT_ROLE + @role = role || (allows_selection? ? MENU_ROLE : DEFAULT_ROLE) @system_arguments[:role] = @role @list_wrapper_arguments = {} diff --git a/app/components/primer/alpha/action_list/item.rb b/app/components/primer/alpha/action_list/item.rb index 7dcaad6f9d..d216a1a9de 100644 --- a/app/components/primer/alpha/action_list/item.rb +++ b/app/components/primer/alpha/action_list/item.rb @@ -191,8 +191,6 @@ def initialize( "ActionListItem--disabled" => @disabled ) - @system_arguments[:role] = :none - @system_arguments[:data] ||= {} @system_arguments[:data][:targets] = "#{list_class.custom_element_name}.items" @@ -231,16 +229,14 @@ def initialize( end @content_arguments[:role] = role || - if @content_arguments[:tag] == :a - nil - elsif @list.allows_selection? + if @list.allows_selection? ActionList::SELECT_VARIANT_ROLE_MAP[@list.select_variant] elsif @list.acts_as_menu? ActionList::DEFAULT_MENU_ITEM_ROLE - else - ActionList::DEFAULT_LIST_ITEM_ROLE end + @system_arguments[:role] = @list.acts_as_menu? ? :none : nil + @description_wrapper_arguments = { classes: class_names( "ActionListItem-descriptionWrap", diff --git a/app/components/primer/alpha/action_menu/action_menu_element.ts b/app/components/primer/alpha/action_menu/action_menu_element.ts index dc58d8fd6f..8279f881bf 100644 --- a/app/components/primer/alpha/action_menu/action_menu_element.ts +++ b/app/components/primer/alpha/action_menu/action_menu_element.ts @@ -11,7 +11,7 @@ const popoverSelector = (() => { type SelectVariant = 'single' | 'multiple' | null -const menuItemSelectors = ['[role="menuitem"]', '[role="menuitemcheckbox"]', '[role="menuitemradio"]', '[role="none"]'] +const menuItemSelectors = ['[role="menuitem"]', '[role="menuitemcheckbox"]', '[role="menuitemradio"]'] export class ActionMenuElement extends HTMLElement { #abortController: AbortController @@ -74,17 +74,20 @@ export class ActionMenuElement extends HTMLElement { } handleEvent(event: Event) { + if (event.target === this.invokerElement && this.#isEnterKeydown(event)) { + if (this.#firstItem) { + event.preventDefault() + this.popoverElement?.showPopover() + this.#firstItem.focus() + return + } + } + if (!this.popoverElement?.matches(popoverSelector)) return if (event.type === 'focusout' && !this.contains((event as FocusEvent).relatedTarget as Node)) { this.popoverElement?.hidePopover() - } else if ( - (event instanceof KeyboardEvent && - event.type === 'keydown' && - !(event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) && - event.key === 'Enter') || - (event instanceof MouseEvent && event.type === 'click') - ) { + } else if (this.#isEnterKeydown(event) || (event instanceof MouseEvent && event.type === 'click')) { const item = (event.target as Element).closest(menuItemSelectors.join(','))?.closest('li') if (!item) return const ariaChecked = item.getAttribute('aria-checked') @@ -120,8 +123,7 @@ export class ActionMenuElement extends HTMLElement { if (!this.dynamicLabel) return const invoker = this.invokerElement if (!invoker) return - const selector = menuItemSelectors.map(s => `${s}[aria-checked=true]`).join(',') - const item = this.querySelector(selector) + const item = this.querySelector('[aria-checked=true]') if (item && this.dynamicLabel) { this.#originalLabel ||= invoker.textContent || '' const prefixSpan = document.createElement('span') @@ -134,6 +136,19 @@ export class ActionMenuElement extends HTMLElement { invoker.textContent = this.#originalLabel } } + + #isEnterKeydown(event: Event): boolean { + return ( + event instanceof KeyboardEvent && + event.type === 'keydown' && + !(event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) && + event.key === 'Enter' + ) + } + + get #firstItem(): HTMLElement | null { + return this.querySelector(menuItemSelectors.join(',')) + } } if (!window.customElements.get('action-menu')) { diff --git a/app/components/primer/alpha/action_menu/list.rb b/app/components/primer/alpha/action_menu/list.rb index b826a4409c..7b6c1a1b3d 100644 --- a/app/components/primer/alpha/action_menu/list.rb +++ b/app/components/primer/alpha/action_menu/list.rb @@ -34,7 +34,7 @@ def with_item(**system_arguments, &block) end # rubocop:enable Style/IfUnlessModifier - system_arguments[:tabindex] = -1 + content_arguments[:tabindex] = -1 system_arguments[:autofocus] = "" if system_arguments[:autofocus] if system_arguments[:disabled] diff --git a/app/components/primer/focus_group.ts b/app/components/primer/focus_group.ts index e43275c2a5..da9522afb5 100644 --- a/app/components/primer/focus_group.ts +++ b/app/components/primer/focus_group.ts @@ -1,6 +1,6 @@ import '@oddbird/popover-polyfill' -const menuItemSelector = '[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"],a' +const menuItemSelector = '[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"]' const popoverSelector = (() => { try { diff --git a/test/accessibility_test.rb b/test/accessibility_test.rb index f092c46aec..ca2524ec09 100644 --- a/test/accessibility_test.rb +++ b/test/accessibility_test.rb @@ -16,19 +16,9 @@ class AccessibilityTest < System::TestCase Docs::NavigationTabComponentPreview ].freeze - EXCLUDES = { - # Skip `:aria-required-children` because is broken in 4.5/4.6: https://github.com/dequelabs/axe-core/issues/3758 - Primer::Alpha::ActionListPreview => { - all: %i[aria-required-children] - }, - - Primer::Alpha::NavListPreview => { - all: %i[aria-required-children] - } - }.freeze + EXCLUDES = {}.freeze ViewComponent::Preview.all.each do |klass| - next unless [Primer::Alpha::ActionMenuPreview].include?(klass) next if IGNORED_PREVIEWS.include?(klass.to_s) component_previews = klass.instance_methods(false) diff --git a/test/system/alpha/action_menu_test.rb b/test/system/alpha/action_menu_test.rb index d6bdb21585..0ad55ffa53 100644 --- a/test/system/alpha/action_menu_test.rb +++ b/test/system/alpha/action_menu_test.rb @@ -119,5 +119,18 @@ def test_action_clipboard_copy_keydown assert_equal read_clipboard, "Text to copy" end + + def test_first_item_is_focused_on_invoker_keydown + visit_preview(:with_actions) + + page.evaluate_script(<<~JS) + document.querySelector('action-menu button[aria-controls]').focus() + JS + + # open menu + page.driver.browser.keyboard.type(:enter) + + assert_equal page.evaluate_script("document.activeElement").text, "Alert" + end end end From 51ce90538cf1f5da7e55e9a8ce5566cc0b76bf72 Mon Sep 17 00:00:00 2001 From: Cameron Dutro Date: Thu, 13 Apr 2023 15:03:22 -0700 Subject: [PATCH 10/11] Remove unused constant --- app/components/primer/alpha/action_list.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/components/primer/alpha/action_list.rb b/app/components/primer/alpha/action_list.rb index 22bd82bf53..6ce85a62f1 100644 --- a/app/components/primer/alpha/action_list.rb +++ b/app/components/primer/alpha/action_list.rb @@ -15,7 +15,6 @@ class ActionList < Primer::Component MENU_ROLE = :menu DEFAULT_MENU_ITEM_ROLE = :menuitem - DEFAULT_LIST_ITEM_ROLE = :listitem DEFAULT_SCHEME = :full SCHEME_MAPPINGS = { From 0579860051496e4689f954dfb1ae7fa500f9a1dd Mon Sep 17 00:00:00 2001 From: Cameron Dutro Date: Thu, 13 Apr 2023 15:06:52 -0700 Subject: [PATCH 11/11] Fix remaining test and linting issues --- demo/app/controllers/action_menu_controller.rb | 3 +-- test/components/alpha/action_list_test.rb | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/demo/app/controllers/action_menu_controller.rb b/demo/app/controllers/action_menu_controller.rb index 9d08935f87..340b574b18 100644 --- a/demo/app/controllers/action_menu_controller.rb +++ b/demo/app/controllers/action_menu_controller.rb @@ -4,8 +4,7 @@ class ActionMenuController < ApplicationController layout false - def landing - end + def landing; end def deferred render "action_menu/deferred" diff --git a/test/components/alpha/action_list_test.rb b/test/components/alpha/action_list_test.rb index 3799eacd65..28c3f61068 100644 --- a/test/components/alpha/action_list_test.rb +++ b/test/components/alpha/action_list_test.rb @@ -118,7 +118,7 @@ def test_uses_list_role_when_select_disabled end assert_selector("ul.ActionListWrap[role=list]") do - assert_selector("li.ActionListItem button[role=listitem]") + assert_selector("li.ActionListItem button") end end