+ A custom list passed in via slot="list", opened by an
+ external handler. Uses ia-icon-label for the button
+ content.
+
+
+
+
+
${this.plusIcon}
+ My Lists
+
+
+
Listen Later
+
Favorites
+
Read in 2026
+
+
+
+
+
+
+
+
+
+ Options are supplied as an array of
+ OptionInterface objects. An option with a
+ url renders as an anchor; otherwise it renders as a
+ button. Selecting one emits an optionSelected event and
+ calls the option's own selectedHandler, if it has one.
+
+
+ With openViaButton off, the main button no longer
+ toggles the menu and the caret becomes a separate button, so
+ displayCaret needs to be on for the menu to be
+ reachable.
+
+
+ Set isCustomList to replace the generated option list
+ with your own markup in slot="list". Pair it with
+ hasCustomClickHandler when the host wants to own the
+ open/close behavior, as in the second example above.
+
`;
+ const el = await fixture(
+ html` ${customList} `,
+ );
+ expect(el.isCustomList).to.be.true;
+
+ const slot = el.shadowRoot?.querySelector('slot[name=list]');
+ expect(slot).to.exist;
+ const elements = (slot as HTMLSlotElement)?.assignedElements();
+ expect(elements).to.exist;
+ expect(elements?.length).to.equal(1);
+ const li = elements?.[0] as HTMLLIElement;
+ expect(li).to.exist;
+ expect(li?.querySelector('.foo')).to.exist;
+ });
+});
diff --git a/src/elements/ia-dropdown/ia-dropdown.ts b/src/elements/ia-dropdown/ia-dropdown.ts
new file mode 100644
index 0000000..ab5228e
--- /dev/null
+++ b/src/elements/ia-dropdown/ia-dropdown.ts
@@ -0,0 +1,781 @@
+import {
+ html,
+ css,
+ LitElement,
+ TemplateResult,
+ PropertyValues,
+ CSSResultGroup,
+} from 'lit';
+import {
+ property,
+ query,
+ customElement,
+ queryAssignedElements,
+} from 'lit/decorators.js';
+import { when } from 'lit/directives/when.js';
+import { unsafeHTML } from 'lit/directives/unsafe-html.js';
+
+import themeStyles from '@src/themes/theme-styles';
+
+// Imported as raw markup and inlined rather than rendered as ``, so
+// that the carets stay stylable — they're recolored through the
+// `--dropdownCaretColor` CSS var, which cannot reach inside an ``.
+import caretUp from './assets/caret-up.svg?raw';
+import caretDown from './assets/caret-down.svg?raw';
+
+export interface OptionInterface {
+ url?: string;
+ selectedHandler?: (option: OptionInterface) => void;
+ label: string | TemplateResult;
+ id: string;
+}
+
+@customElement('ia-dropdown')
+export class IADropdown extends LitElement {
+ /**
+ * Determines whether the dropdown's option menu is currently visible.
+ */
+ @property({ type: Boolean, reflect: true }) open = false;
+
+ /**
+ * Determines whether the main button and/or caret is disabled.
+ */
+ @property({ type: Boolean, reflect: true }) isDisabled = false;
+
+ /**
+ * Specifies whether a caret should be displayed beside the main button content.
+ * Defaults to `false`.
+ */
+ @property({ type: Boolean }) displayCaret = false;
+
+ /**
+ * Specifies whether the dropdown should automatically close when an option is selected.
+ *
+ * Defaults to `false`, for backwards-compatibility.
+ */
+ @property({ type: Boolean }) closeOnSelect = false;
+
+ /**
+ * Specifies whether pressing the main button itself should open the dropdown. This does
+ * not change the behavior of clicking the caret (if shown), which _always_ opens the dropdown
+ * menu.
+ *
+ * Defaults to true, but can be disabled if only caret clicks should toggle the menu.
+ */
+ @property({ type: Boolean }) openViaButton = true;
+
+ /**
+ * Whether to use a popover element for the dropdown menu. Default false.
+ */
+ @property({ type: Boolean }) usePopover = false;
+
+ /**
+ * Specifies whether the currently-selected option should be shown in the dropdown menu.
+ * When `true`, all options are always listed.
+ * When `false`, only unselected options are listed.
+ *
+ * Defaults to `false`, for backwards-compatibility.
+ */
+ @property({ type: Boolean }) includeSelectedOption = false;
+
+ @property({ type: String }) selectedOption = '';
+
+ @property({ attribute: false }) options: OptionInterface[] = [];
+
+ /**
+ * Option group label for screen readers.
+ */
+ @property({ type: String }) optionGroup: string = 'options';
+
+ @property({ attribute: false }) optionSelected = () => {};
+
+ /**
+ * Specifies whether the dropdown option list passed in as .
+ */
+ @property({ type: Boolean, reflect: true }) isCustomList = false;
+
+ /**
+ * Indicates whether mainbutton @click event overridden by ancestor
+ * @click or custom event
+ *
+ * If true, prevents dropdown from opening, closing on main button click.
+ * Also prevents dropdown from opening, closing on caret click, if displayCaret.
+ *
+ * Custom click handling needs to handle:
+ * - enabling/disabling click events
+ * - this.isDisabled property
+ * - this.open property
+ * Suggest using the instance's open property from ancestor:
+ * @query('#custom-dropdown') customDropdown!: IADropdown;
+ * toggleDropdown = () => {
+ * this.customDropdown.open = !this.customDropdown.open
+ * }
+ * @see ia-dropdown-story.ts - the "My Lists" custom-list dropdown
+ *
+ * Allows loading options from an API on click before opening dropdown.
+ */
+ @property({ type: Boolean, reflect: true }) hasCustomClickHandler = false;
+
+ /**
+ * Specifies whether the dropdown should automatically close when the Esc key is pressed.
+ * Defaults to `false`, for backwards-compatibility.
+ */
+ @property({ type: Boolean, reflect: true }) closeOnEscape = false;
+
+ /**
+ * Specifies whether the dropdown should close on clicks outside the dropdown menu.
+ * Always closes when true, regardless of {@property hasCustomClickHandler}
+ * Defaults to `false`, for backwards-compatibility.
+ */
+ @property({ type: Boolean, reflect: true }) closeOnBackdropClick = false;
+
+ @query('.ia-dropdown-group') private container!: HTMLDivElement;
+
+ @query('#dropdown-main') private dropdownMenu!: HTMLUListElement;
+
+ @query('.click-main') private mainButton!: HTMLButtonElement;
+
+ @queryAssignedElements({ slot: 'dropdown-label' })
+ private mainButtonLabelSlotted!: HTMLElement[];
+
+ // Lifecycle methods
+
+ async firstUpdated(): Promise {
+ // Wait for the next tick to ensure that the dropdown is in the DOM
+ await new Promise((resolve) => {
+ setTimeout(resolve, 0);
+ });
+
+ this.addEventListener('closeDropdown', this.closeOptions);
+ }
+
+ protected willUpdate(changed: PropertyValues): void {
+ if (changed.has('open')) {
+ this.updatePopoverState();
+ }
+ }
+
+ disconnectedCallback(): void {
+ super.disconnectedCallback?.();
+ this.removeKeyboardListener();
+ }
+
+ // Events
+
+ // Add optional event listener to close dropdown when Esc key pressed
+ private setupKeyboardListener(): void {
+ if (this.closeOnEscape) {
+ document.addEventListener('keydown', this.boundKeyboardListener);
+ }
+ }
+
+ // Remove the Esc key listener for Esc key pressed
+ private removeKeyboardListener(): void {
+ if (this.closeOnEscape) {
+ document.removeEventListener('keydown', this.boundKeyboardListener);
+ }
+ }
+
+ // Event handlers
+
+ // Handle Esc key pressed
+ private boundKeyboardListener = (e: KeyboardEvent) => {
+ switch (e.key) {
+ case 'Escape':
+ case 'Esc':
+ this.closeOptions();
+ break;
+ default:
+ break;
+ }
+ };
+
+ get dropdownState(): string {
+ if (this.open) {
+ this.setupKeyboardListener();
+ return 'open';
+ }
+ this.removeKeyboardListener();
+ return 'closed';
+ }
+
+ private closeOptions = (e?: Event): void => {
+ if (e && e.type === 'click') {
+ e.stopPropagation();
+ }
+ this.open = false;
+ this.updatePopoverState();
+ };
+
+ toggleOptions(): void {
+ this.open = !this.open;
+ this.updatePopoverState();
+ }
+
+ private updatePopoverState(): void {
+ if (!this.usePopover) return;
+ this.dropdownMenu?.togglePopover?.(this.open);
+ if (this.open) this.positionDropdownMenu();
+ }
+
+ private positionDropdownMenu(): void {
+ if (!this.dropdownMenu) return;
+ const containerRect = this.container.getBoundingClientRect();
+ this.dropdownMenu.style.left = `${containerRect.left}px`;
+ this.dropdownMenu.style.top = `${containerRect.bottom}px`;
+ this.dropdownMenu.style.minWidth = `${containerRect.width}px`;
+ }
+
+ private mainButtonClicked(): void {
+ if (this.openViaButton) {
+ this.toggleOptions();
+ } else {
+ // Refer the click to the button's first slotted child instead
+ this.mainButtonLabelSlotted[0]?.click();
+ }
+ }
+
+ private mainButtonKeyDown(e: KeyboardEvent): void {
+ if (e.key === 'Enter' || e.key === ' ') {
+ this.mainButtonClicked();
+ e.preventDefault();
+ }
+ }
+
+ private caretKeyDown(e: KeyboardEvent): void {
+ if (e.key === 'Enter' || e.key === ' ') {
+ this.toggleOptions();
+ e.preventDefault();
+ }
+ }
+
+ // Options
+
+ /**
+ * Sets the default OptionInterface[] options for the dropdown
+ *
+ * Options with different structure and behavior can be used
+ * by passing in a custom list via
+ * and setting this.isCustomList = true
+ *
+ * @see ia-dropdown-story.ts - the "My Lists" custom-list dropdown
+ */
+
+ /**
+ * Renders a single option with click event handler
+ * @param availableOption {OptionInterface}
+ * @returns
+ */
+ renderOption(availableOption: OptionInterface): TemplateResult {
+ const { label, url = undefined, id } = availableOption;
+ let component;
+ const selected = this.selectedOption === id ? 'selected' : '';
+
+ if (url) {
+ component = html` this.optionClicked(e, availableOption)}
+ >${label}`;
+ } else {
+ component = html``;
+ }
+
+ return html`
${component}
`;
+ }
+
+ optionClicked(e: Event, option: OptionInterface): void {
+ e.stopPropagation();
+ // Don't emit an event for reselecting the same option
+ if (this.selectedOption !== option.id) {
+ this.selectedOption = option.id;
+
+ this.dispatchEvent(
+ new CustomEvent('optionSelected', {
+ detail: { option },
+ }),
+ );
+ option.selectedHandler?.(option);
+ }
+ if (this.closeOnSelect) {
+ this.closeOptions();
+ this.mainButton.focus(); // Move focus to the main button if we're closing the menu
+ }
+ }
+
+ get availableOptions(): OptionInterface[] {
+ // If we're showing the selected option in the dropdown then _all_ options are available.
+ if (this.includeSelectedOption) return this.options;
+
+ // Otherwise, exclude the selected option
+ return this.options.filter(
+ (option) => this.selectedOption !== (option as OptionInterface).id,
+ );
+ }
+
+ // Templates
+
+ /**
+ * Template for the "up" caret icon shown when the dropdown is open.
+ * Renders its contents within a named "caret-up" slot so that custom icons
+ * can be provided to override the default one.
+ */
+ private get caretUpTemplate(): TemplateResult {
+ return html`
+
+ ${unsafeHTML(caretUp)}
+
+ `;
+ }
+
+ /**
+ * Template for the "down" caret icon shown when the dropdown is closed.
+ * Renders its contents within a named "caret-down" slot so that custom icons
+ * can be provided to override the default one.
+ */
+ private get caretDownTemplate(): TemplateResult {
+ return html`
+
+ ${unsafeHTML(caretDown)}
+
+ `;
+ }
+
+ /**
+ * Renders up and down carets
+ *
+ * @event click caretClicked()
+ * @event keydown caretKeyDown()
+ *
+ * @slot caret-up - Allow replacement of default up caret.
+ * @slot caret-down - Allow replacement of default down caret.
+ */
+ get caretTemplate(): TemplateResult {
+ if (!this.displayCaret) return html``;
+
+ // When clicking the button has the same effect as the caret (opening the dropdown),
+ // we just render the caret as inert content inside the button.
+ if (this.openViaButton) {
+ return html`
+
+ ${this.caretUpTemplate} ${this.caretDownTemplate}
+
+ `;
+ }
+
+ // However, when clicking the button should _not_ open the dropdown, the caret
+ // should instead be rendered as a standalone button as it is the only means of
+ // controlling the dropdown state.
+ return html`
+
+ `;
+ }
+
+ /**
+ * Renders the dropdown menu, either as a list of options or as a custom list
+ *
+ * NOTE: tried to skip initial rendering of dropdown options with:
+ * if (!this.open) return html``;
+ * This would also remove dropdown options from DOM on close.
+ * But because options may have window events, this could create a memory leak.
+ *
+ * @slot list - Allow replacement of default {@interface OptionInterface} dropdown list.
+ */
+ get dropdownTemplate(): TemplateResult {
+ if (this.isCustomList) {
+ return html``;
+ }
+ return html`${this.availableOptions.map((o) => this.renderOption(o))}`;
+ }
+
+ /**
+ * Optional template rendering transparent backdrop to capture clicks outside the
+ * dropdown menu when open.
+ *
+ * @event click closeOptions()
+ * @event keyup closeOptions()
+ */
+ private get backdropTemplate(): TemplateResult {
+ if (!this.closeOnBackdropClick) return html``;
+ if (!this.open) return html``;
+ return html`
+
+ `;
+ }
+
+ /**
+ * Whether the caret element should be nested inside the main button (as an inert icon).
+ * If false, then the caret should be rendered as a separate button beside the main one.
+ */
+ private get shouldNestCaretInButton(): boolean {
+ return this.openViaButton;
+ }
+
+ /**
+ * Whether the main button (or caret, if main button is disabled) should have click and
+ * keydown handlers attached to it.
+ */
+ private get shouldAttachEventHandlers(): boolean {
+ return !this.isDisabled && !this.hasCustomClickHandler;
+ }
+
+ render() {
+ return html`
+