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:
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({});