From 82d0925f4eb2727b8c635d6aeef0355fb8e8c602 Mon Sep 17 00:00:00 2001 From: Daniel Riera Date: Thu, 15 May 2025 13:32:05 -0400 Subject: [PATCH] PM-21620 finalize a11y UX concerns for option selection (#14777) * PM-21620 finalize a11y UX concerns for option selection * SR should announce that the button has a menu popup collapsed and expanded when they open it * support up and down keys -close menu when other menu expanded * dynamic aria label * type safety * instanceOf to replace as Node for type * default aria hidden prop that can be overridden * update mock and make message more descriptive * Update apps/browser/src/autofill/content/components/icons/collection-shared.ts Co-authored-by: Jonathan Prusik --------- Co-authored-by: Jonathan Prusik --- apps/browser/src/_locales/en/messages.json | 17 ++++++-- .../buttons/option-selection-button.ts | 3 ++ .../content/components/common-types.ts | 1 + .../content/components/icons/angle-down.ts | 9 +++- .../content/components/icons/angle-up.ts | 9 +++- .../content/components/icons/business.ts | 9 +++- .../content/components/icons/close.ts | 9 +++- .../components/icons/collection-shared.ts | 9 +++- .../components/icons/exclamation-triangle.ts | 9 +++- .../content/components/icons/external-link.ts | 9 +++- .../content/components/icons/family.ts | 9 +++- .../content/components/icons/folder.ts | 9 +++- .../content/components/icons/globe.ts | 9 +++- .../content/components/icons/pencil-square.ts | 9 +++- .../content/components/icons/shield.ts | 9 +++- .../autofill/content/components/icons/user.ts | 9 +++- .../components/lit-stories/mock-data.ts | 1 + .../option-selection/option-item.ts | 16 +++++++- .../option-selection/option-items.ts | 28 ++++++++++++- .../option-selection/option-selection.ts | 41 ++++++++++++++++--- 20 files changed, 187 insertions(+), 37 deletions(-) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index df35facff3c..a14d769d4cd 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1090,13 +1090,24 @@ }, "notificationLoginSaveConfirmation": { "message": "saved to Bitwarden.", - "description": "Shown to user after item is saved." }, "notificationLoginUpdatedConfirmation": { "message": "updated in Bitwarden.", "description": "Shown to user after item is updated." }, + "selectItemAriaLabel": { + "message": "Select $ITEMTYPE$, $ITEMNAME$", + "description": "Used by screen readers. $1 is the item type (like vault or folder), $2 is the selected item name.", + "placeholders": { + "itemType": { + "content": "$1" + }, + "itemName": { + "content": "$2" + } + } + }, "saveAsNewLoginAction": { "message": "Save as new login", "description": "Button text for saving login details as a new entry." @@ -3585,7 +3596,7 @@ "orgTrustWarning1": { "message": "This organization has an Enterprise policy that will enroll you in account recovery. Enrollment will allow organization administrators to change your password. Only proceed if you recognize this organization and the fingerprint phrase displayed below matches the organization's fingerprint." }, - "trustUser":{ + "trustUser": { "message": "Trust user" }, "sendsNoItemsTitle": { @@ -5325,4 +5336,4 @@ "noPermissionsViewPage": { "message": "You do not have permissions to view this page. Try logging in with a different account." } -} +} \ No newline at end of file diff --git a/apps/browser/src/autofill/content/components/buttons/option-selection-button.ts b/apps/browser/src/autofill/content/components/buttons/option-selection-button.ts index e3c7e0d54e6..3912c791d34 100644 --- a/apps/browser/src/autofill/content/components/buttons/option-selection-button.ts +++ b/apps/browser/src/autofill/content/components/buttons/option-selection-button.ts @@ -33,6 +33,9 @@ export function OptionSelectionButton({ class=${selectionButtonStyles({ disabled, toggledOn, theme })} title=${text} type="button" + aria-haspopup="menu" + aria-expanded=${toggledOn} + aria-controls="option-menu" @click=${handleButtonClick} > ${buttonIcon ?? nothing} diff --git a/apps/browser/src/autofill/content/components/common-types.ts b/apps/browser/src/autofill/content/components/common-types.ts index 740b6963b16..5967f6205a9 100644 --- a/apps/browser/src/autofill/content/components/common-types.ts +++ b/apps/browser/src/autofill/content/components/common-types.ts @@ -11,6 +11,7 @@ export type IconProps = { color?: string; disabled?: boolean; theme: Theme; + ariaHidden?: boolean; }; export type Option = { diff --git a/apps/browser/src/autofill/content/components/icons/angle-down.ts b/apps/browser/src/autofill/content/components/icons/angle-down.ts index 27cd5ab81c5..2bd54f7dbee 100644 --- a/apps/browser/src/autofill/content/components/icons/angle-down.ts +++ b/apps/browser/src/autofill/content/components/icons/angle-down.ts @@ -4,11 +4,16 @@ import { html } from "lit"; import { IconProps } from "../common-types"; import { buildIconColorRule, ruleNames, themes } from "../constants/styles"; -export function AngleDown({ color, disabled, theme }: IconProps) { +export function AngleDown({ ariaHidden = true, color, disabled, theme }: IconProps) { const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main; return html` - + + + + + + + + + +
diff --git a/apps/browser/src/autofill/content/components/option-selection/option-items.ts b/apps/browser/src/autofill/content/components/option-selection/option-items.ts index bb7f5afd0fd..854b7c05df1 100644 --- a/apps/browser/src/autofill/content/components/option-selection/option-items.ts +++ b/apps/browser/src/autofill/content/components/option-selection/option-items.ts @@ -33,17 +33,41 @@ export function OptionItems({ const isSafari = false; return html` -
+
handleMenuKeyUp(e)} + > ${label ? html`
${label}
` : nothing}
${options.map((option) => - OptionItem({ ...option, theme, handleSelection: () => handleOptionSelection(option) }), + OptionItem({ + ...option, + theme, + contextLabel: label, + handleSelection: () => handleOptionSelection(option), + }), )}
`; } +function handleMenuKeyUp(event: KeyboardEvent) { + const items = [ + ...(event.currentTarget as HTMLElement).querySelectorAll('[tabindex="0"]'), + ]; + const index = items.indexOf(document.activeElement as HTMLElement); + const direction = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0; + + if (index === -1 || direction === 0) { + return; + } + + event.preventDefault(); + items[(index + direction + items.length) % items.length]?.focus(); +} + const optionsStyles = ({ theme, topOffset }: { theme: Theme; topOffset: number }) => css` ${typography.body1} diff --git a/apps/browser/src/autofill/content/components/option-selection/option-selection.ts b/apps/browser/src/autofill/content/components/option-selection/option-selection.ts index 49b51852a39..c7dceb2b5b4 100644 --- a/apps/browser/src/autofill/content/components/option-selection/option-selection.ts +++ b/apps/browser/src/autofill/content/components/option-selection/option-selection.ts @@ -48,10 +48,18 @@ export class OptionSelection extends LitElement { @state() private selection?: Option; - private handleButtonClick = (event: Event) => { + private static currentOpenInstance: OptionSelection | null = null; + + private handleButtonClick = async (event: Event) => { if (!this.disabled) { - // Menu is about to be shown - if (!this.showMenu) { + const isOpening = !this.showMenu; + + if (isOpening) { + if (OptionSelection.currentOpenInstance && OptionSelection.currentOpenInstance !== this) { + OptionSelection.currentOpenInstance.showMenu = false; + } + OptionSelection.currentOpenInstance = this; + this.menuTopOffset = this.offsetTop; // Distance from right edge of button to left edge of the viewport @@ -71,9 +79,29 @@ export class OptionSelection extends LitElement { optionsMenuItemMaxWidth + optionItemIconWidth + 2 + 8 + 12 * 2; this.menuIsEndJustified = distanceFromViewportRightEdge < maxDifferenceThreshold; + } else { + if (OptionSelection.currentOpenInstance === this) { + OptionSelection.currentOpenInstance = null; + } } - this.showMenu = !this.showMenu; + this.showMenu = isOpening; + + if (this.showMenu) { + await this.updateComplete; + const firstItem = this.querySelector('#option-menu [tabindex="0"]') as HTMLElement; + firstItem?.focus(); + } + } + }; + + private handleFocusOut = (event: FocusEvent) => { + const relatedTarget = event.relatedTarget; + if (!(relatedTarget instanceof Node) || !this.contains(relatedTarget)) { + this.showMenu = false; + if (OptionSelection.currentOpenInstance === this) { + OptionSelection.currentOpenInstance = null; + } } }; @@ -95,7 +123,10 @@ export class OptionSelection extends LitElement { } return html` -
+
${OptionSelectionButton({ disabled: this.disabled, icon: this.selection?.icon,