tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 5c98bd370eaa378c086d65db06ec044d71ab0743
parent d1276e3d41983f9e7da6fdf40ec49c9e2b8976f0
Author: hannajones <hjones@mozilla.com>
Date:   Tue,  2 Dec 2025 19:39:47 +0000

Bug 1998560 - add support for icons to moz-option r=mkennedy

Differential Revision: https://phabricator.services.mozilla.com/D273616

Diffstat:
Mbrowser/components/storybook/component-status/components.json | 2+-
Mtoolkit/content/widgets/moz-select/moz-select.css | 62++++++++++++++++++++++++++++++++++++--------------------------
Mtoolkit/content/widgets/moz-select/moz-select.mjs | 300++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mtoolkit/content/widgets/moz-select/moz-select.stories.mjs | 2++
4 files changed, 282 insertions(+), 84 deletions(-)

diff --git a/browser/components/storybook/component-status/components.json b/browser/components/storybook/component-status/components.json @@ -1,5 +1,5 @@ { - "generatedAt": "2025-11-18T18:36:31.823Z", + "generatedAt": "2025-11-28T17:17:14.044Z", "count": 29, "items": [ { diff --git a/toolkit/content/widgets/moz-select/moz-select.css b/toolkit/content/widgets/moz-select/moz-select.css @@ -40,39 +40,61 @@ -moz-context-properties: fill; fill: var(--select-icon-fill); - &:has(select:hover), - &:has(select:open) { + &:hover, + &:has(select:open), + &:has(+ [open]) { border-color: var(--select-border-color-hover); background-color: var(--select-background-color-hover); color: var(--select-text-color-hover); } - &:has(select:disabled) { + &:has(:disabled) { border-color: var(--select-border-color-disabled); background-color: var(--select-background-color-disabled); color: var(--select-text-color-disabled); opacity: var(--select-opacity-disabled); } - &:has(select:focus-visible) { + &:has(:focus-visible) { outline: var(--focus-outline); outline-offset: var(--focus-outline-offset); } } -select { - border: none; +.panel-trigger { + display: flex; + align-items: center; + outline: none; +} + +panel-item[icon]::part(button) { + background-image: var(--select-item-icon-url); +} + +panel-item[selected] { + background-color: var(--button-background-color-selected); + border-color: var(--button-border-color-selected); + color: var(--button-text-color-selected); +} + +select, +.panel-trigger { border-radius: var(--select-border-radius); - padding-block: var(--space-xsmall); padding-inline: var(--space-large) var(--space-xxlarge); - margin: 0; min-height: var(--select-min-height); width: 100%; font-size: var(--select-font-size); font-weight: var(--select-font-weight); - appearance: none; - color: inherit; background-color: transparent; + color: inherit; + appearance: none; + border: none; +} + +select { + border: none; + padding-block: var(--space-xsmall); + margin: 0; &:focus-visible { outline: none; @@ -110,23 +132,11 @@ select { inset-inline-end: var(--space-medium); } -.with-icon select { +.with-icon .panel-trigger { padding-inline-start: calc(var(--space-large) + var(--icon-size) + var(--space-small)); - transform: translateX(calc(-1 * (var(--icon-size) + var(--space-small)))); - transition: transform 0.3s ease; - - @media (prefers-reduced-motion) { - transition: unset; - } } -.with-icon:has(select:not(:open)) { - select { - transform: translateX(0); - } - - .select-option-icon { - opacity: 1; - transform: scale(1); - } +.with-icon:has(.panel-trigger) .select-option-icon { + opacity: 1; + transform: scale(1); } diff --git a/toolkit/content/widgets/moz-select/moz-select.mjs b/toolkit/content/widgets/moz-select/moz-select.mjs @@ -11,6 +11,17 @@ import { } from "../vendor/lit.all.mjs"; import { MozBaseInputElement, MozLitElement } from "../lit-utils.mjs"; +/** @import { TemplateResult } from "chrome://global/content/vendor/lit.all.mjs" */ + +/** + * @typedef {object} SelectOption + * @property {string} value - The value of the option. + * @property {string} label - The display label of the option. + * @property {string} [iconSrc] - The icon source URL for the option. + * @property {boolean} [disabled] - Whether the option is disabled. + * @property {boolean} [hidden] - Whether the option is hidden. + */ + /** * A select dropdown with options provided via custom `moz-option` elements. * @@ -24,21 +35,33 @@ import { MozBaseInputElement, MozLitElement } from "../lit-utils.mjs"; * @property {string} supportPage - Name of the SUMO support page to link to. * @property {string} ariaLabel - The aria-label text when there is no visible label. * @property {string} ariaDescription - The aria-description text when there is no visible description. - * @property {Array} options - The array of options, populated by <moz-option> children in the + * @property {SelectOption[]} options - The array of options, populated by <moz-option> children in the * default slot. Do not set directly, these will be overridden by <moz-option> children. + * @property {SelectOption} selectedOption - The currently selected option object. + * @property {number} selectedIndex - The index of the currently selected option. + * @property {boolean} usePanelList - Whether or not to render a panel. Depends on options using icons. */ export default class MozSelect extends MozBaseInputElement { - #optionIconSrcMap = new Map(); - static properties = { options: { type: Array, state: true }, + selectedOption: { type: Object, state: true }, + selectedIndex: { type: Number, state: true }, + usePanelList: { type: Boolean, state: true }, }; static inputLayout = "block"; + static queries = { + panelList: "panel-list", + panelTrigger: ".panel-trigger", + }; + constructor() { super(); this.value = ""; this.options = []; + this.usePanelList = false; + this.selectedOption = null; + this.selectedIndex = 0; this.slotRef = createRef(); this.optionsMutationObserver = new MutationObserver( this.populateOptions.bind(this) @@ -62,41 +85,50 @@ export default class MozSelect extends MozBaseInputElement { } } - get _selectedOptionIconSrc() { - if (!this.inputEl || !this.options.length) { - return ""; + willUpdate(changedProperties) { + super.willUpdate(changedProperties); + if (changedProperties.has("value") || changedProperties.has("options")) { + this.selectedIndex = this.options.findIndex( + opt => opt.value === this.value + ); + this.selectedOption = this.options[this.selectedIndex] ?? this.options[0]; } + } - return this.#optionIconSrcMap.get(this.value) ?? ""; + /** + * Gets the icon source for the currently selected option. + * + * @returns {string} The icon source URL or empty string. + */ + get _selectedOptionIconSrc() { + return this.selectedOption?.iconSrc ?? ""; } /** * Internal - populates the select element with options from the light DOM slot. */ populateOptions() { - this.options = []; - this.#optionIconSrcMap.clear(); + let options = []; for (const node of this.slotRef.value.assignedNodes()) { if (node.localName === "moz-option") { - const optionValue = node.getAttribute("value"); - const optionLabel = node.getAttribute("label"); - const optionIconSrc = node.getAttribute("iconsrc"); - const optionDisabled = node.getAttribute("disabled") !== null; - const optionHidden = node.getAttribute("hidden") !== null; - this.options.push({ - value: optionValue, - label: optionLabel, - iconSrc: optionIconSrc, - disabled: optionDisabled, - hidden: optionHidden, + options.push({ + value: node.getAttribute("value"), + label: node.getAttribute("label"), + iconSrc: node.getAttribute("iconsrc"), + disabled: node.getAttribute("disabled") !== null, + hidden: node.getAttribute("hidden") !== null, }); - - if (optionIconSrc) { - this.#optionIconSrcMap.set(optionValue, optionIconSrc); - } } } + + this.options = options; + this.usePanelList = options.some(opt => opt.iconSrc); + + // Default to first option if no value set to match native select behavior. + if (this.usePanelList && !this.value && this.options.length) { + this.value = this.options[0].value; + } } /** @@ -110,6 +142,88 @@ export default class MozSelect extends MozBaseInputElement { } /** + * Handles change events from the panel-list and dispatches a change event. + * + * @param {Event} event - The click event from panel-item selection. + */ + handlePanelChange(event) { + this.handleStateChange(event); + this.redispatchEvent(new Event("change", { bubbles: true })); + } + + /** + * Handles the panel being hidden and returns focus to the trigger button. + */ + handlePanelHidden() { + this.panelTrigger?.focus(); + } + + /** + * Toggles the panel-list open/closed state. + * + * @param {Event} event - The triggering event. + */ + togglePanel(event) { + this.panelList?.toggle(event); + } + + /** + * Handles keyboard events on the panel trigger button. + * Arrow keys change selection (Windows/Linux) or open the panel (Mac). + * Space opens the panel. Enter is prevented to match native select behavior. + * + * @param {KeyboardEvent} event - The keyboard event. + */ + handlePanelKeydown(event) { + if (this.panelList?.open) { + return; + } + + switch (event.key) { + case "ArrowDown": + case "ArrowUp": + event.preventDefault(); + if (navigator.platform.includes("Mac")) { + // Mac - open the menu + this.togglePanel(event); + } else { + // Windows/Linux - select the next option + this.selectNextOption(event.key === "ArrowDown" ? 1 : -1); + } + break; + case "Enter": + event.preventDefault(); + break; + case " ": + event.preventDefault(); + this.togglePanel(event); + break; + } + } + + /** + * Selects the next enabled option in the given direction. Skips disabled and + * hidden options. + * + * @param {number} direction - The direction to move (1 for next, -1 for + * previous). + */ + selectNextOption(direction) { + let currentIndex = this.selectedIndex; + let options = this.options; + + for (let i = 1; i < options.length; i++) { + let nextIndex = currentIndex + direction * i; + let nextOption = options[nextIndex]; + if (nextOption && !nextOption.disabled && !nextOption.hidden) { + this.value = nextOption.value; + this.redispatchEvent(new Event("change", { bubbles: true })); + return; + } + } + } + + /** * @type {MozBaseInputElement['inputStylesTemplate']} */ inputStylesTemplate() { @@ -119,6 +233,11 @@ export default class MozSelect extends MozBaseInputElement { />`; } + /** + * Renders the icon for the currently selected option. + * + * @returns {TemplateResult | null} + */ selectedOptionIconTemplate() { if (this._selectedOptionIconSrc) { return html`<img @@ -130,48 +249,115 @@ export default class MozSelect extends MozBaseInputElement { return null; } - inputTemplate() { - const classes = classMap({ - "select-wrapper": true, - "with-icon": !!this._selectedOptionIconSrc, - }); + /** + * Renders the native select element (used when options don't have icons). + * + * @returns {TemplateResult} + */ + selectTemplate() { + return html`<select + id="input" + name=${this.name} + .value=${this.value} + accesskey=${this.accessKey} + @input=${this.handleStateChange} + @change=${this.redispatchEvent} + ?disabled=${this.disabled || this.parentDisabled} + aria-label=${ifDefined(this.ariaLabel ?? undefined)} + aria-describedby="description" + aria-description=${ifDefined( + this.hasDescription ? undefined : this.ariaDescription + )} + > + ${this.options.map( + option => html` + <option + value=${option.value} + .selected=${option.value == this.value} + ?disabled=${option.disabled} + ?hidden=${option.hidden} + > + ${option.label} + </option> + ` + )} + </select>`; + } + + /** + * Renders the button trigger for the panel-list (used when options have + * icons). + * + * @returns {TemplateResult} + */ + panelTargetTemplate() { + return html`<button + class="panel-trigger" + aria-haspopup="menu" + aria-expanded=${this.panelList?.open ? "true" : "false"} + @click=${this.togglePanel} + @keydown=${this.handlePanelKeydown} + ?disabled=${this.disabled || this.parentDisabled} + > + ${this.selectedOption?.label} + </button>`; + } + /** + * Renders the panel-list dropdown menu (used when options have icons). + * + * @returns {TemplateResult} + */ + panelListTemplate() { + return html`<panel-list + .value=${this.value} + min-width-from-anchor + id="input" + @click=${this.handlePanelChange} + @hidden=${this.handlePanelHidden} + > + ${this.options.map( + option => + html`<panel-item + .value=${option.value} + ?selected=${option.value == this.value} + ?disabled=${option.disabled} + ?hidden=${option.hidden} + icon=${ifDefined(option.iconSrc)} + style=${option.iconSrc + ? `--select-item-icon-url: url(${option.iconSrc})` + : ""} + > + ${option.label} + </panel-item>` + )} + </panel-list>`; + } + + /** + * Renders the main input template with either a native select or panel-list. + * + * @returns {TemplateResult} + */ + inputTemplate() { return html` - <div class=${ifDefined(classes)}> + <div + class=${classMap({ + "select-wrapper": true, + "with-icon": !!this._selectedOptionIconSrc, + })} + > ${this.selectedOptionIconTemplate()} - <select - id="input" - name=${this.name} - .value=${this.value} - accesskey=${this.accessKey} - @input=${this.handleStateChange} - @change=${this.redispatchEvent} - ?disabled=${this.disabled || this.parentDisabled} - aria-label=${ifDefined(this.ariaLabel ?? undefined)} - aria-describedby="description" - aria-description=${ifDefined( - this.hasDescription ? undefined : this.ariaDescription - )} - > - ${this.options.map( - option => html` - <option - value=${option.value} - ?selected=${option.value == this.value} - ?disabled=${option.disabled} - ?hidden=${option.hidden} - > - ${option.label} - </option> - ` - )} - </select> + ${!this.usePanelList + ? this.selectTemplate() + : this.panelTargetTemplate()} <img src="chrome://global/skin/icons/arrow-down.svg" role="presentation" class="select-chevron-icon" /> </div> + ${this.usePanelList ? this.panelListTemplate() : ""} <slot @slotchange=${this.populateOptions} hidden diff --git a/toolkit/content/widgets/moz-select/moz-select.stories.mjs b/toolkit/content/widgets/moz-select/moz-select.stories.mjs @@ -157,6 +157,8 @@ Default.args = { useOtherOptions: false, hasSlottedSupportLink: false, ellipsized: false, + disabledOption: false, + hiddenOption: false, }; export const WithIcon = Template.bind({});