From 168c0a52e42931cfc9ef05875e85653b7936c732 Mon Sep 17 00:00:00 2001
From: Elliott Marquez <5981958+e111077@users.noreply.github.com>
Date: Tue, 2 Apr 2024 13:05:35 -0700
Subject: [PATCH] theme-switcher is now a select (#1320)

---
 .../components/litdev-ripple-icon-button.ts   |  30 ++-
 .../src/components/theme-switcher.ts          | 235 ++++++++++++++++--
 .../src/icons/auto-mode-icon.ts               |   5 +-
 .../lit-dev-content/src/icons/check-icon.ts   |  18 ++
 .../src/icons/dark-mode-icon.ts               |   5 +-
 .../src/icons/light-mode-icon.ts              |   5 +-
 6 files changed, 266 insertions(+), 32 deletions(-)
 create mode 100644 packages/lit-dev-content/src/icons/check-icon.ts

diff --git a/packages/lit-dev-content/src/components/litdev-ripple-icon-button.ts b/packages/lit-dev-content/src/components/litdev-ripple-icon-button.ts
index 572242075..5fa7b0a27 100644
--- a/packages/lit-dev-content/src/components/litdev-ripple-icon-button.ts
+++ b/packages/lit-dev-content/src/components/litdev-ripple-icon-button.ts
@@ -27,6 +27,30 @@ export class LitDevRippleIconButton extends LitElement {
   @property({attribute: 'button-title'})
   buttonTitle = '';
 
+  /**
+   * Aria haspopup for the button.
+   */
+  @property()
+  haspopup = '';
+
+  /**
+   * Aria expanded for the button.
+   */
+  @property()
+  expanded = '';
+
+  /**
+   * Aria controls for the button.
+   */
+  @property()
+  controls = '';
+
+  /**
+   * Sets the role for the inner button.
+   */
+  @property({attribute: 'button-role'})
+  buttonRole = '';
+
   /**
    * Href for the link button. If defined, this component switches to using an
    * anchor element instead of a button.
@@ -140,8 +164,12 @@ export class LitDevRippleIconButton extends LitElement {
       <button
         class="root"
         part="root button"
+        role=${this.buttonRole ? this.buttonRole : nothing}
         aria-live=${this.live ? this.live : nothing}
         aria-label=${this.label ? this.label : nothing}
+        aria-haspopup=${this.haspopup ? this.haspopup : nothing}
+        aria-expanded=${this.expanded ? this.expanded : nothing}
+        aria-controls=${this.controls ? this.controls : nothing}
         ?disabled=${this.disabled}
         title=${this.buttonTitle ?? (nothing as unknown as string)}
       >
@@ -165,7 +193,7 @@ export class LitDevRippleIconButton extends LitElement {
   }
 
   protected renderContent() {
-    return html` <div id="ripple"></div>
+    return html`<div id="ripple"></div>
       <slot></slot>`;
   }
 }
diff --git a/packages/lit-dev-content/src/components/theme-switcher.ts b/packages/lit-dev-content/src/components/theme-switcher.ts
index c48b1d40c..097eca920 100644
--- a/packages/lit-dev-content/src/components/theme-switcher.ts
+++ b/packages/lit-dev-content/src/components/theme-switcher.ts
@@ -4,8 +4,8 @@
  * SPDX-License-Identifier: BSD-3-Clause
  */
 
-import {LitElement, html, isServer} from 'lit';
-import {property, customElement} from 'lit/decorators.js';
+import {LitElement, html, isServer, css, PropertyValues} from 'lit';
+import {property, customElement, state} from 'lit/decorators.js';
 import {
   getCurrentMode,
   applyColorThemeListeners,
@@ -14,7 +14,12 @@ import {
 import {autoModeIcon} from '../icons/auto-mode-icon.js';
 import {darkModeIcon} from '../icons/dark-mode-icon.js';
 import {lightModeIcon} from '../icons/light-mode-icon.js';
+import {checkIcon} from '../icons/check-icon.js';
 import './litdev-ripple-icon-button.js';
+import '@material/web/menu/menu.js';
+import '@material/web/menu/menu-item.js';
+import {FocusState, type CloseMenuEvent} from '@material/web/menu/menu.js';
+import {isElementInSubtree} from '@material/web/menu/internal/controllers/shared.js';
 
 applyColorThemeListeners();
 
@@ -31,7 +36,7 @@ const Modes = {
   },
   auto: {
     icon: autoModeIcon,
-    label: 'Match system mode',
+    label: 'System',
     title: 'Color Mode Toggle (system)',
   },
 } as const;
@@ -44,40 +49,220 @@ export class ThemeSwitcher extends LitElement {
   @property()
   mode: ColorMode = 'auto';
 
+  @state()
+  menuOpen = false;
+
+  @state()
+  defaultFocus: FocusState = FocusState.NONE;
+
   render() {
     const modeOptions = Modes[this.mode];
-    return html`<litdev-ripple-icon-button
-      @click=${this._click}
-      live="assertive"
-      .label=${modeOptions.label}
-      .buttonTitle=${modeOptions.title}
-    >
-      ${modeOptions.icon}
-    </litdev-ripple-icon-button>`;
+    return html`
+      <litdev-ripple-icon-button
+        @click=${() => {
+          this.menuOpen = !this.menuOpen;
+        }}
+        @keydown=${this._handleKeydown}
+        label="Theme selector"
+        haspopup="listbox"
+        .expanded=${this.menuOpen ? 'true' : 'false'}
+        controls="menu"
+        button-role="combobox"
+        .buttonTitle=${modeOptions.title}
+        id="button"
+      >
+        <span aria-label=${modeOptions.label}>${modeOptions.icon()}</span>
+      </litdev-ripple-icon-button>
+      <md-menu
+        id="menu"
+        anchor="button"
+        tabindex="-1"
+        role="listbox"
+        stay-open-on-focusout
+        .open=${this.menuOpen}
+        .defaultFocus=${this.defaultFocus}
+        @opening=${() => {
+          this.shadowRoot
+            ?.querySelector?.('#button span')
+            ?.removeAttribute?.('aria-live');
+        }}
+        @opened=${() => {
+          if (this.defaultFocus !== FocusState.NONE) {
+            return;
+          }
+
+          (
+            this.shadowRoot?.querySelector?.(
+              'md-menu-item[selected]'
+            ) as HTMLElement
+          )?.focus?.();
+        }}
+        @closed=${() => {
+          this.menuOpen = false;
+        }}
+        @close-menu=${this._onCloseMenu}
+      >
+        ${Object.keys(Modes).map(
+          (mode) => html`
+            <md-menu-item
+              aria-selected=${mode === this.mode ? 'true' : 'false'}
+              ?selected=${mode === this.mode}
+              data-mode=${mode}
+            >
+              <span slot="headline">${Modes[mode as ColorMode].label}</span>
+              ${Modes[mode as ColorMode].icon('start')}
+              ${mode === this.mode
+                ? checkIcon('end')
+                : html`<span slot="end"></span>`}
+            </md-menu-item>
+          `
+        )}
+      </md-menu>
+    `;
+  }
+
+  update(changed: PropertyValues<this>) {
+    if (changed.get('mode')) {
+      this.dispatchEvent(new ChangeColorModeEvent(this.mode));
+    }
+
+    super.update(changed);
   }
 
   firstUpdated() {
     this.mode = getCurrentMode();
+    this.addEventListener('focusout', this._focusout);
   }
 
-  private _click() {
-    let nextMode!: ColorMode;
-
-    switch (this.mode) {
-      case 'auto':
-        nextMode = 'dark';
-        break;
-      case 'dark':
-        nextMode = 'light';
-        break;
-      case 'light':
-        nextMode = 'auto';
-        break;
+  private _onCloseMenu(e: CloseMenuEvent) {
+    const nextMode = e.detail.itemPath[0]?.dataset?.mode as
+      | ColorMode
+      | undefined;
+    if (!nextMode) {
+      return;
     }
 
-    this.dispatchEvent(new ChangeColorModeEvent(nextMode));
     this.mode = nextMode;
   }
+
+  /**
+   * Handles opening the select on keydown and typahead selection when the menu
+   * is closed. Taken from md-select's implementation.
+   */
+  private _handleKeydown(event: KeyboardEvent) {
+    const menu = this.shadowRoot?.querySelector?.('md-menu');
+
+    if (this.menuOpen || !menu) {
+      return;
+    }
+
+    const typeaheadController = menu.typeaheadController;
+    const isOpenKey =
+      event.code === 'Space' ||
+      event.code === 'ArrowDown' ||
+      event.code === 'ArrowUp' ||
+      event.code === 'End' ||
+      event.code === 'Home' ||
+      event.code === 'Enter';
+
+    // Do not open if currently typing ahead because the user may be typing the
+    // spacebar to match a word with a space
+    if (!typeaheadController.isTypingAhead && isOpenKey) {
+      event.preventDefault();
+      this.menuOpen = true;
+
+      // https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/#kbd_label
+      switch (event.code) {
+        case 'Space':
+        case 'ArrowDown':
+        case 'Enter':
+          // We will handle focusing last selected item in this.handleOpening()
+          this.defaultFocus = FocusState.NONE;
+          break;
+        case 'End':
+          this.defaultFocus = FocusState.LAST_ITEM;
+          break;
+        case 'ArrowUp':
+        case 'Home':
+          this.defaultFocus = FocusState.FIRST_ITEM;
+          break;
+        default:
+          break;
+      }
+      return;
+    }
+
+    const isPrintableKey = event.key.length === 1;
+
+    // Handles typing ahead when the menu is closed by delegating the event to
+    // the underlying menu's typeaheadController
+    if (isPrintableKey) {
+      typeaheadController.onKeydown(event);
+      event.preventDefault();
+
+      const {lastActiveRecord} = typeaheadController;
+
+      if (!lastActiveRecord) {
+        return;
+      }
+
+      const labelEl = this.shadowRoot?.querySelector?.('#button span');
+
+      labelEl?.setAttribute?.('aria-live', 'polite');
+      this.mode = lastActiveRecord[1].dataset.mode as ColorMode;
+    }
+  }
+
+  /**
+   * Handles closing the menu when the focus leaves the select's subtree.
+   * Taken from md-select's implementation.
+   */
+  private _focusout(event: FocusEvent) {
+    // Don't close the menu if we are switching focus between menu,
+    // select-option, and field
+    if (event.relatedTarget && isElementInSubtree(event.relatedTarget, this)) {
+      return;
+    }
+
+    this.menuOpen = false;
+  }
+
+  static override styles = css`
+    :host {
+      position: relative;
+      --md-sys-color-primary: var(--sys-color-primary);
+      --md-sys-color-surface: var(--sys-color-surface);
+      --md-sys-color-on-surface: var(--sys-color-on-surface);
+      --md-sys-color-on-surface-variant: var(--sys-color-on-surface-variant);
+      --md-sys-color-surface-container: var(--sys-color-surface-container);
+      --md-sys-color-secondary-container: var(--sys-color-primary-container);
+      --md-sys-color-on-secondary-container: var(
+        --sys-color-on-primary-container
+      );
+      --md-focus-ring-color: var(--sys-color-secondary);
+    }
+
+    md-menu-item[selected] {
+      --md-focus-ring-color: var(--sys-color-secondary-container);
+    }
+
+    md-menu {
+      min-width: 208px;
+    }
+
+    #button > span {
+      display: flex;
+    }
+
+    [slot='end'] {
+      width: 24px;
+      height: 24px;
+    }
+
+    [slot='headline'] {
+      font-family: Manrope, sans-serif;
+    }
+  `;
 }
 
 if (isServer) {
diff --git a/packages/lit-dev-content/src/icons/auto-mode-icon.ts b/packages/lit-dev-content/src/icons/auto-mode-icon.ts
index bc2c5cdd8..ea68f38ac 100644
--- a/packages/lit-dev-content/src/icons/auto-mode-icon.ts
+++ b/packages/lit-dev-content/src/icons/auto-mode-icon.ts
@@ -4,16 +4,17 @@
  * SPDX-License-Identifier: BSD-3-Clause
  */
 
-import {html} from 'lit';
+import {html, nothing} from 'lit';
 
 // Source: https://fonts.google.com/icons?selected=Material+Symbols+Outlined:routine:FILL@0;wght@400;GRAD@0;opsz@24&icon.query=routine
-export const autoModeIcon = html`
+export const autoModeIcon = (slot = '') => html`
   <svg
     fill="currentColor"
     width="24"
     height="24"
     aria-hidden="true"
     viewBox="0 -960 960 960"
+    slot=${slot || nothing}
   >
     <path
       d="M396-396q-32-32-58.5-67T289-537q-5 14-6.5 28.5T281-480q0 83 58 141t141 58q14 0 28.5-2t28.5-6q-39-22-74-48.5T396-396Zm57-56q51 51 114 87.5T702-308q-40 51-98 79.5T481-200q-117 0-198.5-81.5T201-480q0-65 28.5-123t79.5-98q20 72 56.5 135T453-452Zm290 72q-20-5-39.5-11T665-405q8-18 11.5-36.5T680-480q0-83-58.5-141.5T480-680q-20 0-38.5 3.5T405-665q-8-19-13.5-38T381-742q24-9 49-13.5t51-4.5q117 0 198.5 81.5T761-480q0 26-4.5 51T743-380ZM440-840v-120h80v120h-80Zm0 840v-120h80V0h-80Zm323-706-57-57 85-84 57 56-85 85ZM169-113l-57-56 85-85 57 57-85 84Zm671-327v-80h120v80H840ZM0-440v-80h120v80H0Zm791 328-85-85 57-57 84 85-56 57ZM197-706l-84-85 56-57 85 85-57 57Zm199 310Z"
diff --git a/packages/lit-dev-content/src/icons/check-icon.ts b/packages/lit-dev-content/src/icons/check-icon.ts
new file mode 100644
index 000000000..4b68dd894
--- /dev/null
+++ b/packages/lit-dev-content/src/icons/check-icon.ts
@@ -0,0 +1,18 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: BSD-3-Clause
+ */
+
+import {html, nothing} from 'lit';
+
+// Source: https://fonts.google.com/icons?selected=Material+Icons+Outlined:check_circle
+export const checkIcon = (slot = '') => html`<svg
+  height="24px"
+  viewBox="0 0 24 24"
+  width="24px"
+  fill="currentcolor"
+  slot=${slot || nothing}
+>
+  <path d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z" />
+</svg>`;
diff --git a/packages/lit-dev-content/src/icons/dark-mode-icon.ts b/packages/lit-dev-content/src/icons/dark-mode-icon.ts
index 9bb9a43b1..e51e7cff3 100644
--- a/packages/lit-dev-content/src/icons/dark-mode-icon.ts
+++ b/packages/lit-dev-content/src/icons/dark-mode-icon.ts
@@ -4,16 +4,17 @@
  * SPDX-License-Identifier: BSD-3-Clause
  */
 
-import {html} from 'lit';
+import {html, nothing} from 'lit';
 
 // Source: https://fonts.google.com/icons?selected=Material+Symbols+Outlined:dark_mode:FILL@0;wght@400;GRAD@0;opsz@24&icon.query=dark+mode
-export const darkModeIcon = html`
+export const darkModeIcon = (slot = '') => html`
   <svg
     fill="currentColor"
     width="24"
     height="24"
     aria-hidden="true"
     viewBox="0 -960 960 960"
+    slot=${slot || nothing}
   >
     <path
       d="M480-120q-150 0-255-105T120-480q0-150 105-255t255-105q14 0 27.5 1t26.5 3q-41 29-65.5 75.5T444-660q0 90 63 153t153 63q55 0 101-24.5t75-65.5q2 13 3 26.5t1 27.5q0 150-105 255T480-120Zm0-80q88 0 158-48.5T740-375q-20 5-40 8t-40 3q-123 0-209.5-86.5T364-660q0-20 3-40t8-40q-78 32-126.5 102T200-480q0 116 82 198t198 82Zm-10-270Z"
diff --git a/packages/lit-dev-content/src/icons/light-mode-icon.ts b/packages/lit-dev-content/src/icons/light-mode-icon.ts
index 571c13c2a..274ea4b03 100644
--- a/packages/lit-dev-content/src/icons/light-mode-icon.ts
+++ b/packages/lit-dev-content/src/icons/light-mode-icon.ts
@@ -4,16 +4,17 @@
  * SPDX-License-Identifier: BSD-3-Clause
  */
 
-import {html} from 'lit';
+import {html, nothing} from 'lit';
 
 // Source: https://fonts.google.com/icons?selected=Material+Symbols+Outlined:light_mode:FILL@0;wght@400;GRAD@0;opsz@24&icon.query=light+mode
-export const lightModeIcon = html`
+export const lightModeIcon = (slot = '') => html`
   <svg
     width="24"
     height="24"
     viewBox="0 -960 960 960"
     fill="currentcolor"
     aria-hidden="true"
+    slot=${slot || nothing}
   >
     <path
       d="M480-360q50 0 85-35t35-85q0-50-35-85t-85-35q-50 0-85 35t-35 85q0 50 35 85t85 35Zm0 80q-83 0-141.5-58.5T280-480q0-83 58.5-141.5T480-680q83 0 141.5 58.5T680-480q0 83-58.5 141.5T480-280ZM200-440H40v-80h160v80Zm720 0H760v-80h160v80ZM440-760v-160h80v160h-80Zm0 720v-160h80v160h-80ZM256-650l-101-97 57-59 96 100-52 56Zm492 496-97-101 53-55 101 97-57 59Zm-98-550 97-101 59 57-100 96-56-52ZM154-212l101-97 55 53-97 101-59-57Zm326-268Z"