mirror of
https://github.com/bitwarden/browser
synced 2026-02-24 00:23:17 +00:00
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 <jprusik@users.noreply.github.com> --------- Co-authored-by: Jonathan Prusik <jprusik@users.noreply.github.com>
This commit is contained in:
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -11,6 +11,7 @@ export type IconProps = {
|
||||
color?: string;
|
||||
disabled?: boolean;
|
||||
theme: Theme;
|
||||
ariaHidden?: boolean;
|
||||
};
|
||||
|
||||
export type Option = {
|
||||
|
||||
@@ -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`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 8" fill="none">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 14 8"
|
||||
fill="none"
|
||||
aria-hidden="${ariaHidden}"
|
||||
>
|
||||
<path
|
||||
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
|
||||
d="M13.53.47a.75.75 0 0 0-1.06 0L7 5.94 1.53.47A.75.75 0 1 0 .47 1.53l6 6a.75.75 0 0 0 1.06 0l6-6a.75.75 0 0 0 0-1.06Z"
|
||||
|
||||
@@ -4,11 +4,16 @@ import { html } from "lit";
|
||||
import { IconProps } from "../common-types";
|
||||
import { buildIconColorRule, ruleNames, themes } from "../constants/styles";
|
||||
|
||||
export function AngleUp({ color, disabled, theme }: IconProps) {
|
||||
export function AngleUp({ ariaHidden = true, color, disabled, theme }: IconProps) {
|
||||
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
|
||||
|
||||
return html`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 8" fill="none">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 14 8"
|
||||
fill="none"
|
||||
aria-hidden="${ariaHidden}"
|
||||
>
|
||||
<path
|
||||
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
|
||||
d="M.47 7.53a.75.75 0 0 0 1.06 0L7 2.06l5.47 5.47a.75.75 0 1 0 1.06-1.06l-6-6a.75.75 0 0 0-1.06 0l-6 6a.75.75 0 0 0 0 1.06Z"
|
||||
|
||||
@@ -4,11 +4,16 @@ import { html } from "lit";
|
||||
import { IconProps } from "../common-types";
|
||||
import { buildIconColorRule, ruleNames, themes } from "../constants/styles";
|
||||
|
||||
export function Business({ color, disabled, theme }: IconProps) {
|
||||
export function Business({ ariaHidden = true, color, disabled, theme }: IconProps) {
|
||||
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
|
||||
|
||||
return html`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 16" fill="none">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 12 16"
|
||||
fill="none"
|
||||
aria-hidden="${ariaHidden}"
|
||||
>
|
||||
<path
|
||||
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
|
||||
d="M3.25 3a.75.75 0 0 0 0 1.5h1.5a.75.75 0 0 0 0-1.5h-1.5ZM7.25 3a.75.75 0 0 0 0 1.5h1.5a.75.75 0 0 0 0-1.5h-1.5ZM7.25 6a.75.75 0 0 0 0 1.5h1.5a.75.75 0 0 0 0-1.5h-1.5ZM6.5 9.75A.75.75 0 0 1 7.25 9h1.5a.75.75 0 0 1 0 1.5h-1.5a.75.75 0 0 1-.75-.75ZM2.5 6.75A.75.75 0 0 1 3.25 6h1.5a.75.75 0 0 1 0 1.5h-1.5a.75.75 0 0 1-.75-.75ZM3.25 9a.75.75 0 0 0 0 1.5h1.5a.75.75 0 0 0 0-1.5h-1.5Z"
|
||||
|
||||
@@ -4,11 +4,16 @@ import { html } from "lit";
|
||||
import { IconProps } from "../common-types";
|
||||
import { buildIconColorRule, ruleNames, themes } from "../constants/styles";
|
||||
|
||||
export function Close({ color, disabled, theme }: IconProps) {
|
||||
export function Close({ ariaHidden = true, color, disabled, theme }: IconProps) {
|
||||
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
|
||||
|
||||
return html`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14" fill="none">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
aria-hidden="${ariaHidden}"
|
||||
>
|
||||
<path
|
||||
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
|
||||
d="M.22.22a.75.75 0 0 1 1.06 0L7 5.94 12.72.22a.75.75 0 1 1 1.06 1.06L8.06 7l5.72 5.72a.75.75 0 1 1-1.06 1.06L7 8.06l-5.72 5.72a.75.75 0 0 1-1.06-1.06L5.94 7 .22 1.28a.75.75 0 0 1 0-1.06Z"
|
||||
|
||||
@@ -4,11 +4,16 @@ import { html } from "lit";
|
||||
import { IconProps } from "../common-types";
|
||||
import { buildIconColorRule, ruleNames, themes } from "../constants/styles";
|
||||
|
||||
export function CollectionShared({ color, disabled, theme }: IconProps) {
|
||||
export function CollectionShared({ ariaHidden = true, color, disabled, theme }: IconProps) {
|
||||
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
|
||||
|
||||
return html`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14" fill="none">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
aria-hidden="${ariaHidden}"
|
||||
>
|
||||
<path
|
||||
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
|
||||
d="M3.5.75A.75.75 0 0 1 4.25 0h5.5a.75.75 0 0 1 0 1.5h-5.5A.75.75 0 0 1 3.5.75ZM2.25 2a.75.75 0 0 0 0 1.5h9.5a.75.75 0 0 0 0-1.5h-9.5ZM6 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM10 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM7 11.46a1.928 1.928 0 0 0-.586-1.386 2.035 2.035 0 0 0-2.828 0A1.928 1.928 0 0 0 3 11.461c0 .298.241.539.54.539h2.92a.54.54 0 0 0 .54-.54ZM8 11.46a2.928 2.928 0 0 0-.371-1.426A2.005 2.005 0 0 1 9 9.5a2.035 2.035 0 0 1 1.414.574A1.928 1.928 0 0 1 11 11.461a.54.54 0 0 1-.54.539H7.904c.063-.168.097-.35.097-.54Z"
|
||||
|
||||
@@ -4,11 +4,16 @@ import { html } from "lit";
|
||||
import { IconProps } from "../common-types";
|
||||
import { buildIconColorRule, ruleNames, themes } from "../constants/styles";
|
||||
|
||||
export function ExclamationTriangle({ color, disabled, theme }: IconProps) {
|
||||
export function ExclamationTriangle({ ariaHidden = true, color, disabled, theme }: IconProps) {
|
||||
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
|
||||
|
||||
return html`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 15" fill="none">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 15"
|
||||
fill="none"
|
||||
aria-hidden="${ariaHidden}"
|
||||
>
|
||||
<path
|
||||
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
|
||||
d="M9 11C9 11.5523 8.55229 12 8 12C7.44772 12 7 11.5523 7 11C7 10.4477 7.44772 10 8 10C8.55229 10 9 10.4477 9 11Z"
|
||||
|
||||
@@ -4,11 +4,16 @@ import { html } from "lit";
|
||||
import { IconProps } from "../common-types";
|
||||
import { buildIconColorRule, ruleNames, themes } from "../constants/styles";
|
||||
|
||||
export function ExternalLink({ color, disabled, theme }: IconProps) {
|
||||
export function ExternalLink({ ariaHidden = true, color, disabled, theme }: IconProps) {
|
||||
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
|
||||
|
||||
return html`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14" fill="none">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
aria-hidden="${ariaHidden}"
|
||||
>
|
||||
<path
|
||||
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
|
||||
d="M1.5 2.75c0-.69.56-1.25 1.25-1.25h3.5a.75.75 0 0 0 0-1.5h-3.5A2.75 2.75 0 0 0 0 2.75v8.5A2.75 2.75 0 0 0 2.75 14h8.5A2.75 2.75 0 0 0 14 11.25v-3.5a.75.75 0 0 0-1.5 0v3.5c0 .69-.56 1.25-1.25 1.25h-8.5c-.69 0-1.25-.56-1.25-1.25v-8.5Z"
|
||||
|
||||
@@ -4,11 +4,16 @@ import { html } from "lit";
|
||||
import { IconProps } from "../common-types";
|
||||
import { buildIconColorRule, ruleNames, themes } from "../constants/styles";
|
||||
|
||||
export function Family({ color, disabled, theme }: IconProps) {
|
||||
export function Family({ ariaHidden = true, color, disabled, theme }: IconProps) {
|
||||
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
|
||||
|
||||
return html`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden="${ariaHidden}"
|
||||
>
|
||||
<path
|
||||
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
|
||||
fill-rule="evenodd"
|
||||
|
||||
@@ -4,11 +4,16 @@ import { html } from "lit";
|
||||
import { IconProps } from "../common-types";
|
||||
import { buildIconColorRule, ruleNames, themes } from "../constants/styles";
|
||||
|
||||
export function Folder({ color, disabled, theme }: IconProps) {
|
||||
export function Folder({ ariaHidden = true, color, disabled, theme }: IconProps) {
|
||||
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
|
||||
|
||||
return html`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 13" fill="none">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 13"
|
||||
fill="none"
|
||||
aria-hidden="${ariaHidden}"
|
||||
>
|
||||
<path
|
||||
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
|
||||
d="M2 0a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8L6.586.586A2 2 0 0 0 5.172 0H2Zm5.379 3.5L5.525 1.646a.5.5 0 0 0-.353-.146H2a.5.5 0 0 0-.5.5v9a.5.5 0 0 0 .5.5h12a.5.5 0 0 0 .5-.5V4a.5.5 0 0 0-.5-.5H7.379Z"
|
||||
|
||||
@@ -4,11 +4,16 @@ import { html } from "lit";
|
||||
import { IconProps } from "../common-types";
|
||||
import { buildIconColorRule, ruleNames, themes } from "../constants/styles";
|
||||
|
||||
export function Globe({ color, disabled, theme }: IconProps) {
|
||||
export function Globe({ ariaHidden = true, color, disabled, theme }: IconProps) {
|
||||
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
|
||||
|
||||
return html`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden="${ariaHidden}"
|
||||
>
|
||||
<path
|
||||
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
|
||||
d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0Zm0 14.5c.23 0 .843-.226 1.487-1.514.524-1.048.906-2.526.994-4.236H5.519c.088 1.71.47 3.188.994 4.236C7.157 14.274 7.77 14.5 8 14.5ZM5.52 7.25h4.96c-.087-1.71-.47-3.188-.993-4.236C8.843 1.726 8.23 1.5 8 1.5c-.23 0-.843.226-1.487 1.514C5.99 4.062 5.607 5.54 5.52 7.25Zm6.463 0h2.474a6.506 6.506 0 0 0-3.766-5.168c.718 1.305 1.197 3.125 1.292 5.168Zm-7.966 0c.095-2.043.574-3.863 1.292-5.168A6.506 6.506 0 0 0 1.543 7.25h2.474Zm7.966 1.5c-.095 2.043-.574 3.863-1.292 5.168a6.506 6.506 0 0 0 3.766-5.168h-2.474Zm-6.677 5.185c-.718-1.305-1.197-3.125-1.292-5.168H1.54a6.506 6.506 0 0 0 3.766 5.168Z"
|
||||
|
||||
@@ -4,11 +4,16 @@ import { html } from "lit";
|
||||
import { IconProps } from "../common-types";
|
||||
import { buildIconColorRule, ruleNames, themes } from "../constants/styles";
|
||||
|
||||
export function PencilSquare({ color, disabled, theme }: IconProps) {
|
||||
export function PencilSquare({ ariaHidden = true, color, disabled, theme }: IconProps) {
|
||||
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
|
||||
|
||||
return html`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
aria-hidden="${ariaHidden}"
|
||||
>
|
||||
<path
|
||||
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
|
||||
d="M11.013.677a1.75 1.75 0 0 1 2.474 0l.836.836a1.75 1.75 0 0 1 0 2.475L9.03 9.28a.75.75 0 0 1-.348.197l-3 .75a.75.75 0 0 1-.91-.91l.75-3a.75.75 0 0 1 .198-.348L11.013.677Zm1.414 1.06a.25.25 0 0 0-.354 0l-.646.647a.75.75 0 0 1 .103.086l1 1a.751.751 0 0 1 .087.103l.646-.646a.25.25 0 0 0 0-.353l-.836-.836Zm-.854 2.88a.752.752 0 0 1-.103-.087l-1-1a.756.756 0 0 1-.087-.103L6.928 6.884 6.531 8.47l1.586-.397 3.456-3.456Z"
|
||||
|
||||
@@ -4,11 +4,16 @@ import { html } from "lit";
|
||||
import { IconProps } from "../common-types";
|
||||
import { buildIconColorRule, ruleNames, themes } from "../constants/styles";
|
||||
|
||||
export function Shield({ color, theme }: IconProps) {
|
||||
export function Shield({ ariaHidden = true, color, theme }: IconProps) {
|
||||
const shapeColor = color || themes[theme].brandLogo;
|
||||
|
||||
return html`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 16" fill="none">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 14 16"
|
||||
fill="none"
|
||||
aria-hidden="${ariaHidden}"
|
||||
>
|
||||
<path
|
||||
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
|
||||
d="M13.469.2A.647.647 0 0 0 13 0H1a.639.639 0 0 0-.468.2.641.641 0 0 0-.2.468v8a4.81 4.81 0 0 0 .348 1.777c.216.557.507 1.083.865 1.563.367.48.779.925 1.229 1.329.417.383.857.741 1.317 1.073.4.284.82.553 1.26.807.44.254.75.425.932.515.183.09.33.16.44.208.087.041.181.062.277.06a.58.58 0 0 0 .27-.063c.113-.05.259-.118.444-.208s.5-.262.932-.515c.432-.253.857-.523 1.26-.807.46-.332.9-.69 1.319-1.073.45-.404.861-.849 1.228-1.33.357-.48.648-1.005.865-1.562a4.79 4.79 0 0 0 .348-1.777v-8A.63.63 0 0 0 13.47.2Zm-1.547 8.54c0 2.9-4.921 5.392-4.921 5.392V1.714h4.92v7.027Z"
|
||||
|
||||
@@ -4,11 +4,16 @@ import { html } from "lit";
|
||||
import { IconProps } from "../common-types";
|
||||
import { buildIconColorRule, ruleNames, themes } from "../constants/styles";
|
||||
|
||||
export function User({ color, disabled, theme }: IconProps) {
|
||||
export function User({ ariaHidden = true, color, disabled, theme }: IconProps) {
|
||||
const shapeColor = disabled ? themes[theme].secondary["300"] : color || themes[theme].text.main;
|
||||
|
||||
return html`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 15" fill="none">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 14 15"
|
||||
fill="none"
|
||||
aria-hidden="${ariaHidden}"
|
||||
>
|
||||
<path
|
||||
class=${css(buildIconColorRule(shapeColor, ruleNames.fill))}
|
||||
d="M9.203 7.339a4 4 0 1 0-4.407 0A7.033 7.033 0 0 0 2.05 8.953a6.655 6.655 0 0 0-1.517 2.162A6.393 6.393 0 0 0 0 13.667C0 14.403.597 15 1.333 15h11.334c.736 0 1.333-.597 1.333-1.333 0-.876-.181-1.743-.533-2.552a6.654 6.654 0 0 0-1.517-2.162 7.032 7.032 0 0 0-2.747-1.614ZM9.5 4a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0Zm2.592 7.714c.247.57.384 1.175.405 1.786H1.503a4.897 4.897 0 0 1 .405-1.786 5.156 5.156 0 0 1 1.177-1.675 5.534 5.534 0 0 1 1.787-1.136A5.805 5.805 0 0 1 7 8.5c.732 0 1.456.137 2.128.403.673.265 1.28.652 1.787 1.136a5.156 5.156 0 0 1 1.177 1.675Z"
|
||||
|
||||
@@ -132,6 +132,7 @@ export const mockI18n = {
|
||||
saveFailure: "Error saving",
|
||||
saveFailureDetails: "Oh no! We couldn't save this. Try entering the details manually.",
|
||||
saveLogin: "Save login",
|
||||
selectItemAriaLabel: "Select $ITEMTYPE$, $ITEMNAME$",
|
||||
typeLogin: "Login",
|
||||
updateLoginAction: "Update login",
|
||||
updateLogin: "Update existing login",
|
||||
|
||||
@@ -13,11 +13,19 @@ const { css } = createEmotion({
|
||||
});
|
||||
|
||||
export type OptionItemProps = Option & {
|
||||
contextLabel?: string;
|
||||
theme: Theme;
|
||||
handleSelection: () => void;
|
||||
};
|
||||
|
||||
export function OptionItem({ icon, text, value, theme, handleSelection }: OptionItemProps) {
|
||||
export function OptionItem({
|
||||
contextLabel,
|
||||
icon,
|
||||
text,
|
||||
theme,
|
||||
value,
|
||||
handleSelection,
|
||||
}: OptionItemProps) {
|
||||
const handleSelectionKeyUpProxy = (event: KeyboardEvent) => {
|
||||
const listenedForKeys = new Set(["Enter", "Space"]);
|
||||
if (listenedForKeys.has(event.code) && event.target instanceof Element) {
|
||||
@@ -29,12 +37,18 @@ export function OptionItem({ icon, text, value, theme, handleSelection }: Option
|
||||
|
||||
const iconProps: IconProps = { color: themes[theme].text.main, theme };
|
||||
const itemIcon = icon?.(iconProps);
|
||||
const ariaLabel =
|
||||
contextLabel && text
|
||||
? chrome.i18n.getMessage("selectItemAriaLabel", [contextLabel, text])
|
||||
: text;
|
||||
|
||||
return html`<div
|
||||
class=${optionItemStyles}
|
||||
key=${value}
|
||||
tabindex="0"
|
||||
title=${text}
|
||||
role="option"
|
||||
aria-label=${ariaLabel}
|
||||
@click=${handleSelection}
|
||||
@keyup=${handleSelectionKeyUpProxy}
|
||||
>
|
||||
|
||||
@@ -33,17 +33,41 @@ export function OptionItems({
|
||||
const isSafari = false;
|
||||
|
||||
return html`
|
||||
<div class=${optionsStyles({ theme, topOffset })} key="container">
|
||||
<div
|
||||
class=${optionsStyles({ theme, topOffset })}
|
||||
key="container"
|
||||
@keyup=${(e: KeyboardEvent) => handleMenuKeyUp(e)}
|
||||
>
|
||||
${label ? html`<div class=${optionsLabelStyles({ theme })}>${label}</div>` : nothing}
|
||||
<div class=${optionsWrapper({ isSafari, theme })}>
|
||||
${options.map((option) =>
|
||||
OptionItem({ ...option, theme, handleSelection: () => handleOptionSelection(option) }),
|
||||
OptionItem({
|
||||
...option,
|
||||
theme,
|
||||
contextLabel: label,
|
||||
handleSelection: () => handleOptionSelection(option),
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function handleMenuKeyUp(event: KeyboardEvent) {
|
||||
const items = [
|
||||
...(event.currentTarget as HTMLElement).querySelectorAll<HTMLElement>('[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}
|
||||
|
||||
|
||||
@@ -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`
|
||||
<div class=${optionSelectionStyles({ menuIsEndJustified: this.menuIsEndJustified })}>
|
||||
<div
|
||||
class=${optionSelectionStyles({ menuIsEndJustified: this.menuIsEndJustified })}
|
||||
@focusout=${this.handleFocusOut}
|
||||
>
|
||||
${OptionSelectionButton({
|
||||
disabled: this.disabled,
|
||||
icon: this.selection?.icon,
|
||||
|
||||
Reference in New Issue
Block a user