Repository: adobe/spectrum-web-components (TypeScript)

Issue:
[Bug][a11y]:  Picker: VoiceOver fails to announce label in Chrome
### Code of conduct

- [X] I agree to follow this project's code of conduct.

### Impacted component(s)

Picker

### Expected behavior

The button element in the sp-picker component's shadow DOM is labeled internally using aria-labelledby:
1. by the button itself, which has an aria-label that comes from the textContent of the associated sp-field-label component,
2. by the icon slot within the shadow DOM,
3. and by the label slot within the shadow DOM, that will contain either the selected value or the assigned placeholder text.

VoiceOver should announce the menu button using the accessibility name assembled from elements referenced in the aria-labelledby attribute, but instead it only announces the component description, "menu popup, collapsed button."

### Actual behavior

Steps to reproduce the problem:
1. With VoiceOver on, open https://opensource.adobe.com/spectrum-web-components/components/picker/#sizes.
2. Navigate to focus the Dropdown menu button, or Picker, labelled "Selection type:" with a placeholder value "Selection type" under the heading Sizes and within the Medium tab panel.
3. VoiceOver announces the Dropdown menu button as "menu popup, collapsed button," without including the accessibility name, which, if you look at the Accessibility properties panel in the Dev tools, does seem to be properly assembled and communicated as "Selection type: Selection type." 

The issue does not reproduced using Safari or Webkit.

### Related Chromium bug

[Issue 1448092: VoiceOver fails to announce label for web component menu button](https://bugs.chromium.org/p/chromium/issues/detail?id=1448092)

### What browsers are you seeing the problem in?

Chrome, Microsoft Edge

### How can we reproduce this issue?

1. With VoiceOver on, open https://opensource.adobe.com/spectrum-web-components/components/picker/#sizes.
2. Navigate to focus the Dropdown menu button, or Picker, labelled "Selection type:" with a placeholder value "Selection type" under the heading Sizes and within the Medium tab panel.
3. VoiceOver announces the Dropdown menu button as "menu popup, collapsed button," without including the accessibility name, which, if you look at the Accessibility properties panel in the Dev tools, does seem to be properly assembled and communicated as "Selection type: Selection type." 

The issue does not reproduced using Safari or Webkit.

### Sample code that illustrates the problem

See CodePen example: https://codepen.io/majornista/pen/BaqMQJg




Fix (patch):
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 49b26d9dd4f..79856383a90 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -10,7 +10,7 @@ executors:
 parameters:
     current_golden_images_hash:
         type: string
-        default: 31192df5022fe2dfc067ca7b4c8d72b0d2d98510
+        default: 0c939bbe6b6fe9440a5273d073e014e361354361
     wireit_cache_name:
         type: string
         default: wireit
diff --git a/packages/field-label/src/FieldLabel.ts b/packages/field-label/src/FieldLabel.ts
index 0e44b8f1ea0..5efc2e5021c 100644
--- a/packages/field-label/src/FieldLabel.ts
+++ b/packages/field-label/src/FieldLabel.ts
@@ -37,6 +37,9 @@ import {
 import styles from './field-label.css.js';
 
 type AcceptsFocusVisisble = HTMLElement & { forceFocusVisible?(): void };
+type Labelable = Focusable & {
+    applyFocusElementLabel?: (label?: string) => void;
+};
 
 /**
  * @element sp-field-label
@@ -71,7 +74,7 @@ export class FieldLabel extends SizedMixin(SpectrumElement) {
     @property({ type: String, reflect: true, attribute: 'side-aligned' })
     public sideAligned?: 'start' | 'end';
 
-    private target?: HTMLElement;
+    private target?: Labelable;
 
     private handleClick(event: Event): void {
         if (!this.target || this.disabled || event.defaultPrevented) return;
@@ -89,31 +92,34 @@ export class FieldLabel extends SizedMixin(SpectrumElement) {
 
     private resolvedElement = new ElementResolutionController(this);
 
-    private addTarget(target: Focusable): void {
-        this.target = target.focusElement || target;
-        const targetParent = this.target.getRootNode() as HTMLElement;
-        if (targetParent === (this.getRootNode() as HTMLElement)) {
-            conditionAttributeWithId(this.target, 'aria-labelledby', [this.id]);
-        } else {
-            this.target.setAttribute('aria-label', this.labelText);
-        }
-    }
-
-    private removeTarget(): void {
+    private applyTargetLabel(target?: Labelable): void {
+        // Apply new target when provided
+        this.target = target || this.target;
         if (this.target) {
-            const targetParent = this.target.getRootNode() as HTMLElement;
-            if (targetParent === (this.getRootNode() as HTMLElement)) {
-                conditionAttributeWithoutId(this.target, 'aria-labelledby', [
-                    this.id,
-                ]);
+            // When target is available add or remove label information
+            // depending on the value of `apply`.
+            const applyLabel = this.target.applyFocusElementLabel;
+            const focusable = this.target.focusElement || this.target;
+            const targetParent = focusable.getRootNode() as HTMLElement;
+            if (typeof applyLabel !== 'undefined') {
+                applyLabel(this.labelText);
+            } else if (targetParent === (this.getRootNode() as HTMLElement)) {
+                const conditionAttribute = target
+                    ? conditionAttributeWithId
+                    : conditionAttributeWithoutId;
+                conditionAttribute(focusable, 'aria-labelledby', [this.id]);
             } else {
-                this.target.removeAttribute('aria-label');
+                if (target) {
+                    focusable.setAttribute('aria-label', this.labelText);
+                } else {
+                    focusable.removeAttribute('aria-label');
+                }
             }
         }
     }
 
     private async manageTarget(): Promise<void> {
-        this.removeTarget();
+        this.applyTargetLabel();
         const target = this.resolvedElement.element as Focusable;
         if (!target) {
             this.target = target;
@@ -125,7 +131,7 @@ export class FieldLabel extends SizedMixin(SpectrumElement) {
         if (typeof target.updateComplete !== 'undefined') {
             await target.updateComplete;
         }
-        this.addTarget(target);
+        this.applyTargetLabel(target);
     }
 
     private get labelText(): string {
diff --git a/packages/picker/src/Picker.ts b/packages/picker/src/Picker.ts
index 365d9b78b62..f662838e853 100644
--- a/packages/picker/src/Picker.ts
+++ b/packages/picker/src/Picker.ts
@@ -20,10 +20,14 @@ import {
     SizedMixin,
     TemplateResult,
 } from '@spectrum-web-components/base';
-import { classMap } from '@spectrum-web-components/base/src/directives.js';
+import {
+    classMap,
+    ifDefined,
+} from '@spectrum-web-components/base/src/directives.js';
 import {
     property,
     query,
+    state,
 } from '@spectrum-web-components/base/src/decorators.js';
 
 import pickerStyles from './picker.css.js';
@@ -77,6 +81,9 @@ export class PickerBase extends SizedMixin(Focusable) {
 
     protected isMobile = new MatchMediaController(this, IS_MOBILE);
 
+    @state()
+    appliedLabel?: string;
+
     @query('#button')
     public button!: HTMLButtonElement;
 
@@ -373,7 +380,15 @@ export class PickerBase extends SizedMixin(Focusable) {
             return content;
         }
         return html`
-            <slot name="label">${this.label}</slot>
+            <slot name="label">
+                <span
+                    aria-hidden=${ifDefined(
+                        this.appliedLabel ? undefined : 'true'
+                    )}
+                >
+                    ${this.label}
+                </span>
+            </slot>
         `;
     }
 
@@ -382,30 +397,56 @@ export class PickerBase extends SizedMixin(Focusable) {
             'visually-hidden': this.icons === 'only' && !!this.value,
             placeholder: !this.value,
         };
+        const appliedLabel = this.appliedLabel || this.label;
         return [
             html`
+                </span>
                 <span id="icon" ?hidden=${this.icons === 'none'}>
                     ${this.selectedItemContent.icon}
                 </span>
                 <span id="label" class=${classMap(labelClasses)}>
                     ${this.renderLabelContent(this.selectedItemContent.content)}
                 </span>
-                ${this.invalid
-                    ? html`
-                          <sp-icon-alert
-                              class="validation-icon"
-                          ></sp-icon-alert>
-                      `
-                    : nothing}
+                ${
+                    this.value && this.selectedItem
+                        ? html`
+                              <span
+                                  aria-hidden="true"
+                                  class="visually-hidden"
+                                  id="applied-label"
+                              >
+                                  ${appliedLabel}
+                                  <slot name="label"></slot>
+                              </span>
+                          `
+                        : html`
+                              <span hidden id="applied-label">
+                                  ${appliedLabel}
+                              </span>
+                          `
+                }
+                ${
+                    this.invalid
+                        ? html`
+                              <sp-icon-alert
+                                  class="validation-icon"
+                              ></sp-icon-alert>
+                          `
+                        : nothing
+                }
                 <sp-icon-chevron100
-                    class="picker ${chevronClass[
-                        this.size as DefaultElementSize
-                    ]}"
+                    class="picker ${
+                        chevronClass[this.size as DefaultElementSize]
+                    }"
                 ></sp-icon-chevron100>
             `,
         ];
     }
 
+    applyFocusElementLabel = (value?: string): void => {
+        this.appliedLabel = value;
+    };
+
     // a helper to throw focus to the button is needed because Safari
     // won't include buttons in the tab order even with tabindex="0"
     protected override render(): TemplateResult {
@@ -418,7 +459,7 @@ export class PickerBase extends SizedMixin(Focusable) {
             <button
                 aria-haspopup="true"
                 aria-expanded=${this.open ? 'true' : 'false'}
-                aria-labelledby="button icon label"
+                aria-labelledby="icon label applied-label"
                 id="button"
                 class="button"
                 @blur=${this.onButtonBlur}
diff --git a/packages/picker/src/picker.css b/packages/picker/src/picker.css
index 0dd395f7843..916f07b75a2 100644
--- a/packages/picker/src/picker.css
+++ b/packages/picker/src/picker.css
@@ -93,10 +93,6 @@ sp-popover {
     white-space: nowrap;
 }
 
-:host([dir='ltr']) #label.visually-hidden + .picker {
-    margin-left: auto;
-}
-
-:host([dir='rtl']) #label.visually-hidden + .picker {
-    margin-right: auto;
+#label.visually-hidden ~ .picker {
+    margin-inline-start: auto;
 }
diff --git a/packages/picker/stories/picker.stories.ts b/packages/picker/stories/picker.stories.ts
index 34ecc4f87aa..442940a5887 100644
--- a/packages/picker/stories/picker.stories.ts
+++ b/packages/picker/stories/picker.stories.ts
@@ -108,13 +108,66 @@ export const Default = (args: StoryArgs): TemplateResult => {
             label="Select a Country with a very long label, too long, in fact"
             ${spreadProps(args)}
         >
-            <sp-menu-item>Deselect</sp-menu-item>
-            <sp-menu-item>Select Inverse</sp-menu-item>
-            <sp-menu-item>Feather...</sp-menu-item>
-            <sp-menu-item>Select and Mask...</sp-menu-item>
+            <sp-menu-item value="option-1">Deselect</sp-menu-item>
+            <sp-menu-item value="option-2">Select Inverse</sp-menu-item>
+            <sp-menu-item value="option-3">Feather...</sp-menu-item>
+            <sp-menu-item value="option-4">Select and Mask...</sp-menu-item>
             <sp-menu-divider></sp-menu-divider>
-            <sp-menu-item>Save Selection</sp-menu-item>
-            <sp-menu-item disabled>Make Work Path</sp-menu-item>
+            <sp-menu-item value="option-5">Save Selection</sp-menu-item>
+            <sp-menu-item disabled value="option-6">
+                Make Work Path
+            </sp-menu-item>
+        </sp-picker>
+        <p>This is some text.</p>
+        <p>This is some text.</p>
+        <p>
+            This is a
+            <a href="#anchor">link</a>
+            .
+        </p>
+    `;
+};
+
+export const noVisibleLabel = (args: StoryArgs): TemplateResult => {
+    return html`
+        <sp-picker
+            @change=${handleChange(args)}
+            label="Where do you live?"
+            ${spreadProps(args)}
+        >
+            <sp-menu-item value="option-1">Deselect</sp-menu-item>
+            <sp-menu-item value="option-2">Select Inverse</sp-menu-item>
+            <sp-menu-item value="option-3">Feather...</sp-menu-item>
+            <sp-menu-item value="option-4">Select and Mask...</sp-menu-item>
+            <sp-menu-divider></sp-menu-divider>
+            <sp-menu-item value="option-5">Save Selection</sp-menu-item>
+            <sp-menu-item disabled value="option-6">
+                Make Work Path
+            </sp-menu-item>
+        </sp-picker>
+        <p>This is some text.</p>
+        <p>This is some text.</p>
+        <p>
+            This is a
+            <a href="#anchor">link</a>
+            .
+        </p>
+    `;
+};
+
+export const slottedLabel = (args: StoryArgs): TemplateResult => {
+    return html`
+        <sp-picker @change=${handleChange(args)} ${spreadProps(args)}>
+            <span slot="label">Where do you live?</span>
+            <sp-menu-item value="option-1">Deselect</sp-menu-item>
+            <sp-menu-item value="option-2">Select Inverse</sp-menu-item>
+            <sp-menu-item value="option-3">Feather...</sp-menu-item>
+            <sp-menu-item value="option-4">Select and Mask...</sp-menu-item>
+            <sp-menu-divider></sp-menu-divider>
+            <sp-menu-item value="option-5">Save Selection</sp-menu-item>
+            <sp-menu-item disabled value="option-6">
+                Make Work Path
+            </sp-menu-item>
         </sp-picker>
         <p>This is some text.</p>
         <p>This is some text.</p>