Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Several ActionMenu a11y fixes #2260

Merged
merged 14 commits into from
Sep 29, 2023
Merged
7 changes: 7 additions & 0 deletions .changeset/sour-colts-prove.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@primer/view-components': patch
---

ActionMenu: hide the menu when focus leaves the component; focus the first list item when the menu is activated with the mouse; allow disabling list items while still permitting them to be focused with the keyboard

<!-- Changed components: Primer::Alpha::ActionMenu -->
283 changes: 204 additions & 79 deletions app/components/primer/alpha/action_menu/action_menu_element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export class ActionMenuElement extends HTMLElement {
#abortController: AbortController
#originalLabel = ''
#inputName = ''
#invokerBeingClicked = false

get selectVariant(): SelectVariant {
return this.getAttribute('data-select-variant') as SelectVariant
Expand Down Expand Up @@ -52,7 +53,7 @@ export class ActionMenuElement extends HTMLElement {
}

get popoverElement(): HTMLElement | null {
return this.invokerElement?.popoverTargetElement || null
return (this.invokerElement?.popoverTargetElement as HTMLElement) || null
}

get invokerElement(): HTMLButtonElement | null {
Expand Down Expand Up @@ -94,8 +95,10 @@ export class ActionMenuElement extends HTMLElement {
this.addEventListener('click', this, {signal})
this.addEventListener('mouseover', this, {signal})
this.addEventListener('focusout', this, {signal})
this.addEventListener('mousedown', this, {signal})
this.#setDynamicLabel()
this.#updateInput()
this.#softDisableItems()

if (this.includeFragment) {
this.includeFragment.addEventListener('include-fragment-replaced', this, {
Expand All @@ -104,104 +107,231 @@ export class ActionMenuElement extends HTMLElement {
}
}

#softDisableItems() {
const {signal} = this.#abortController

for (const item of this.#items) {
item.addEventListener('click', this.#potentiallyDisallowActivation.bind(this), {signal})
item.addEventListener('keydown', this.#potentiallyDisallowActivation.bind(this), {signal})
}
}

#potentiallyDisallowActivation(event: Event) {
if (!this.#isActivation(event)) return

const item = (event.target as HTMLElement).closest(menuItemSelectors.join(','))
if (!item) return

if (item.getAttribute('aria-disabled')) {
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
}
}

disconnectedCallback() {
this.#abortController.abort()
}

#isKeyboardActivation(event: Event): boolean {
return (
event instanceof KeyboardEvent &&
event.type === 'keydown' &&
!(event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) &&
(event.key === 'Enter' || event.key === ' ')
)
}

#isMouseActivation(event: Event): boolean {
return event instanceof MouseEvent && event.type === 'click'
}

#isActivation(event: Event): boolean {
return this.#isMouseActivation(event) || this.#isKeyboardActivation(event)
}

handleEvent(event: Event) {
const activation = this.#isActivationKeydown(event)
if (event.target === this.invokerElement && activation) {
if (this.#firstItem) {
event.preventDefault()
this.popoverElement?.showPopover()
this.#firstItem.focus()
return
}
const targetIsInvoker = this.invokerElement?.contains(event.target as HTMLElement)
const eventIsActivation = this.#isActivation(event)

if (targetIsInvoker && event.type === 'mousedown') {
this.#invokerBeingClicked = true
return
}

// Prevent safari bug that dismisses menu on mousedown instead of allowing
// the click event to propagate to the button
if (event.type === 'mousedown') {
event.preventDefault()
return
}

if (targetIsInvoker && eventIsActivation) {
this.#handleInvokerActivated(event)
this.#invokerBeingClicked = false
return
}

// Ignore events within dialogs within menus
if ((event.target as Element)?.closest('dialog') || (event.target as Element)?.closest('modal-dialog')) {
return
}

// If a dialog has been rendered within the menu, we do not want to hide
// the entire menu, as that will also hide the Dialog. Instead we want to
// show the Dialog while hiding just the visible part of the menu.
if ((activation || event.type === 'click') && (event.target as HTMLElement)?.closest('[data-show-dialog-id]')) {
const dialogInvoker = (event.target as HTMLElement)!.closest('[data-show-dialog-id]')
const dialog = this.ownerDocument.getElementById(dialogInvoker?.getAttribute('data-show-dialog-id') || '')
if (dialogInvoker && dialog && this.contains(dialogInvoker) && this.contains(dialog)) {
this.querySelector<HTMLElement>('.ActionListWrap')!.style.display = 'none'
const dialog_controller = new AbortController()
const {signal} = dialog_controller
const handleDialogClose = () => {
dialog_controller.abort()
this.querySelector<HTMLElement>('.ActionListWrap')!.style.display = ''
if (this.popoverElement?.matches(':popover-open')) {
this.popoverElement?.hidePopover()
}
if (event.type === 'focusout') {
if (this.#invokerBeingClicked) return

// Give the browser time to focus the next element
requestAnimationFrame(() => {
if (!this.contains(document.activeElement) || document.activeElement === this.invokerElement) {
this.#handleFocusOut()
}
dialog.addEventListener('close', handleDialogClose, {signal})
dialog.addEventListener('cancel', handleDialogClose, {signal})
return
}
})

return
}

if (!this.popoverElement?.matches(':popover-open')) return
const item = (event.target as Element).closest(menuItemSelectors.join(','))
const targetIsItem = item !== null

if (event.type === 'include-fragment-replaced') {
if (this.#firstItem) this.#firstItem.focus()
} else if (activation || (event instanceof MouseEvent && event.type === 'click')) {
// Hide popover after current event loop to prevent changes in focus from
// altering the target of the event. Not doing this specifically affects
// <a> 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(() => {
if (this.popoverElement?.matches(':popover-open')) {
this.popoverElement?.hidePopover()
}
})
if (targetIsItem && eventIsActivation) {
const dialogInvoker = item.closest('[data-show-dialog-id]')

if (dialogInvoker) {
const dialog = this.ownerDocument.getElementById(dialogInvoker.getAttribute('data-show-dialog-id') || '')

if (dialog && this.contains(dialogInvoker) && this.contains(dialog)) {
this.#handleDialogItemActivated(event, dialog)
return
}
}

// The rest of the code below deals with single/multiple selection behavior, and should not
// interfere with events fired by menu items whose behavior is specified outside the library.
if (this.selectVariant !== 'multiple' && this.selectVariant !== 'single') return
this.#activateItem(event, item)
this.#handleItemActivated(event, item)
return
}

const item = (event.target as Element).closest(menuItemSelectors.join(','))
if (!item) return
const ariaChecked = item.getAttribute('aria-checked')
const checked = ariaChecked !== 'true'
if (event.type === 'include-fragment-replaced') {
this.#handleIncludeFragmentReplaced()
}
}

if (this.selectVariant === 'single') {
// Only check, never uncheck here. Single-select mode does not allow unchecking a checked item.
if (checked) {
item.setAttribute('aria-checked', 'true')
}
#handleInvokerActivated(event: Event) {
event.preventDefault()
event.stopPropagation()

for (const checkedItem of this.querySelectorAll('[aria-checked]')) {
if (checkedItem !== item) {
checkedItem.setAttribute('aria-checked', 'false')
}
}
if (this.#isOpen()) {
this.#hide()
} else {
this.#show()
this.#firstItem?.focus()
}
}

this.#setDynamicLabel()
} else {
// multi-select mode allows unchecking a checked item
item.setAttribute('aria-checked', `${checked}`)
#handleDialogItemActivated(event: Event, dialog: HTMLElement) {
this.querySelector<HTMLElement>('.ActionListWrap')!.style.display = 'none'
const dialog_controller = new AbortController()
const {signal} = dialog_controller
const handleDialogClose = () => {
dialog_controller.abort()
this.querySelector<HTMLElement>('.ActionListWrap')!.style.display = ''
if (this.#isOpen()) {
this.#hide()
}
}
dialog.addEventListener('close', handleDialogClose, {signal})
dialog.addEventListener('cancel', handleDialogClose, {signal})
}

#handleItemActivated(event: Event, item: Element) {
// Hide popover after current event loop to prevent changes in focus from
// altering the target of the event. Not doing this specifically affects
// <a> 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(() => {
if (this.#isOpen()) {
this.#hide()
}
})
}

// The rest of the code below deals with single/multiple selection behavior, and should not
// interfere with events fired by menu items whose behavior is specified outside the library.
if (this.selectVariant !== 'multiple' && this.selectVariant !== 'single') return

this.#updateInput()
const ariaChecked = item.getAttribute('aria-checked')
const checked = ariaChecked !== 'true'

if (event instanceof KeyboardEvent && event.target instanceof HTMLButtonElement) {
// prevent buttons from being clicked twice
event.preventDefault()
if (this.selectVariant === 'single') {
// Only check, never uncheck here. Single-select mode does not allow unchecking a checked item.
if (checked) {
item.setAttribute('aria-checked', 'true')
}

for (const checkedItem of this.querySelectorAll('[aria-checked]')) {
if (checkedItem !== item) {
checkedItem.setAttribute('aria-checked', 'false')
}
}

this.#setDynamicLabel()
} else {
// multi-select mode allows unchecking a checked item
item.setAttribute('aria-checked', `${checked}`)
}

this.#updateInput()

if (event instanceof KeyboardEvent && event.target instanceof HTMLButtonElement) {
// prevent buttons from being clicked twice
event.preventDefault()
return
}
}

#activateItem(event: Event, item: Element) {
const eventWillActivateByDefault =
(event instanceof MouseEvent && event.type === 'click') ||
(event instanceof KeyboardEvent &&
event.type === 'keydown' &&
!(event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) &&
event.key === 'Enter')

// if the event will result in activating the current item by default, i.e. is a
// mouse click or keyboard enter, bail out
if (eventWillActivateByDefault) return

// otherwise, event will not result in activation by default, so we stop it and
// simulate a click
event.stopPropagation()
const elem = item as HTMLElement
elem.click()
}

#handleIncludeFragmentReplaced() {
if (this.#firstItem) this.#firstItem.focus()
this.#softDisableItems()
}

// Close when focus leaves menu
#handleFocusOut() {
this.#hide()
}

#show() {
this.popoverElement?.showPopover()
}

#hide() {
this.popoverElement?.hidePopover()
}

#isOpen() {
return this.popoverElement?.matches(':popover-open')
}

#setDynamicLabel() {
if (!this.dynamicLabel) return
const invokerLabel = this.invokerLabel
Expand Down Expand Up @@ -261,18 +391,13 @@ export class ActionMenuElement extends HTMLElement {
}
}

#isActivationKeydown(event: Event): boolean {
return (
event instanceof KeyboardEvent &&
event.type === 'keydown' &&
!(event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) &&
(event.key === 'Enter' || event.key === ' ')
)
}

get #firstItem(): HTMLElement | null {
return this.querySelector(menuItemSelectors.join(','))
}

get #items(): HTMLElement[] {
return Array.from(this.querySelectorAll(menuItemSelectors.join(',')))
}
}

if (!window.customElements.get('action-menu')) {
Expand Down
2 changes: 0 additions & 2 deletions app/components/primer/alpha/action_menu/list.rb
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,6 @@ def organize_arguments(data: {}, **system_arguments)
system_arguments,
{ aria: { disabled: true } }
)

content_arguments[:disabled] = "" if content_arguments[:tag] == :button
end

{ data: data, **system_arguments, content_arguments: content_arguments }
Expand Down
Loading