From 7e520d3e7a4de9a584796fc3c99d3cb0ec184336 Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Tue, 28 Jul 2026 17:06:11 -0700 Subject: [PATCH 1/2] WEBDEV-8812: Migrate ia-dropdown into elements Moves @internetarchive/ia-dropdown v2.2.0 into src/elements/ia-dropdown/, so ia-dropdown-search-bar can use the local component and elements can drop the external dep. Renames to match the conventions here: IaDropdown -> IADropdown, IaIconLabel -> IAIconLabel, and the lowercase optionInterface -> OptionInterface. selectedHandler was typed as bare Function, now it's (option: OptionInterface) => void, which is what it always was at runtime. Tests ported from web-test-runner + @open-wc/testing + sinon to vitest; the sinon sandbox was dead scaffolding so it's gone. The carets stay as inline Lit svg templates rather than becoming .svg file imports like other elements icons. They're recolored through --dropdownCaretColor, and you can't style the inside of an , so converting them would quietly break that var and the caret-up/caret-down override slots. 186 tests pass, including the 14 search-bar tests now running against the local dropdown. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EnQGMFUXNYTcTL2mvkJ3Dp --- package-lock.json | 50 -- package.json | 1 - .../ia-dropdown-search-bar.ts | 11 +- src/elements/ia-dropdown/assets/caret-down.ts | 13 + src/elements/ia-dropdown/assets/caret-up.ts | 13 + src/elements/ia-dropdown/ia-dropdown-story.ts | 386 +++++++++ src/elements/ia-dropdown/ia-dropdown.test.ts | 425 ++++++++++ src/elements/ia-dropdown/ia-dropdown.ts | 777 ++++++++++++++++++ .../ia-dropdown/ia-icon-label.test.ts | 36 + src/elements/ia-dropdown/ia-icon-label.ts | 101 +++ src/elements/index.ts | 2 + 11 files changed, 1760 insertions(+), 55 deletions(-) create mode 100644 src/elements/ia-dropdown/assets/caret-down.ts create mode 100644 src/elements/ia-dropdown/assets/caret-up.ts create mode 100644 src/elements/ia-dropdown/ia-dropdown-story.ts create mode 100644 src/elements/ia-dropdown/ia-dropdown.test.ts create mode 100644 src/elements/ia-dropdown/ia-dropdown.ts create mode 100644 src/elements/ia-dropdown/ia-icon-label.test.ts create mode 100644 src/elements/ia-dropdown/ia-icon-label.ts diff --git a/package-lock.json b/package-lock.json index 734d64e..384f90d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "AGPL-3.0-only", "dependencies": { "@internetarchive/ia-clearable-text-input": "^1.1.1", - "@internetarchive/ia-dropdown": "^2.2.0", "@lit/localize": "^0.12.2", "lit": "^2.8.0 || ^3.3.2", "magic-snowflakes": "^7.0.2", @@ -814,55 +813,6 @@ "@types/trusted-types": "^2.0.2" } }, - "node_modules/@internetarchive/ia-dropdown": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@internetarchive/ia-dropdown/-/ia-dropdown-2.2.0.tgz", - "integrity": "sha512-h1vF1qLmjbPcRVkYz3kv1HzNHhucaM+IorVzvUejgYhIYRxrBjCswpOpMXeoR16UE4pyDVc/mtkKq/QFaVinRw==", - "license": "AGPL-3.0-only", - "dependencies": { - "lit": "^2.8.0" - } - }, - "node_modules/@internetarchive/ia-dropdown/node_modules/@lit/reactive-element": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.3.tgz", - "integrity": "sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==", - "license": "BSD-3-Clause", - "dependencies": { - "@lit-labs/ssr-dom-shim": "^1.0.0" - } - }, - "node_modules/@internetarchive/ia-dropdown/node_modules/lit": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/lit/-/lit-2.8.0.tgz", - "integrity": "sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==", - "license": "BSD-3-Clause", - "dependencies": { - "@lit/reactive-element": "^1.6.0", - "lit-element": "^3.3.0", - "lit-html": "^2.8.0" - } - }, - "node_modules/@internetarchive/ia-dropdown/node_modules/lit-element": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.3.3.tgz", - "integrity": "sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==", - "license": "BSD-3-Clause", - "dependencies": { - "@lit-labs/ssr-dom-shim": "^1.1.0", - "@lit/reactive-element": "^1.3.0", - "lit-html": "^2.8.0" - } - }, - "node_modules/@internetarchive/ia-dropdown/node_modules/lit-html": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.8.0.tgz", - "integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==", - "license": "BSD-3-Clause", - "dependencies": { - "@types/trusted-types": "^2.0.2" - } - }, "node_modules/@internetarchive/icon-close": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@internetarchive/icon-close/-/icon-close-1.4.1.tgz", diff --git a/package.json b/package.json index ad2672b..446abf1 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,6 @@ }, "dependencies": { "@internetarchive/ia-clearable-text-input": "^1.1.1", - "@internetarchive/ia-dropdown": "^2.2.0", "@lit/localize": "^0.12.2", "lit": "^2.8.0 || ^3.3.2", "magic-snowflakes": "^7.0.2", diff --git a/src/elements/ia-dropdown-search-bar/ia-dropdown-search-bar.ts b/src/elements/ia-dropdown-search-bar/ia-dropdown-search-bar.ts index 426d800..64e9e03 100644 --- a/src/elements/ia-dropdown-search-bar/ia-dropdown-search-bar.ts +++ b/src/elements/ia-dropdown-search-bar/ia-dropdown-search-bar.ts @@ -9,14 +9,17 @@ import { } from 'lit'; import { customElement, property, query } from 'lit/decorators.js'; import type { IaClearableTextInput } from '@internetarchive/ia-clearable-text-input'; -import type { IaDropdown, optionInterface } from '@internetarchive/ia-dropdown'; +import type { + IADropdown, + OptionInterface, +} from '@src/elements/ia-dropdown/ia-dropdown'; import type { SearchCategory, SearchRequestedDetail } from './models'; import themeStyles from '@src/themes/theme-styles'; import searchIcon from './search.svg'; import '@internetarchive/ia-clearable-text-input'; -import '@internetarchive/ia-dropdown'; +import '@src/elements/ia-dropdown/ia-dropdown'; import '@src/elements/ia-status-indicator/ia-status-indicator'; /** @@ -54,7 +57,7 @@ export class IADropdownSearchBar extends LitElement { private searchInput!: IaClearableTextInput; @query('#category-dropdown') - private categoryDropdown?: IaDropdown; + private categoryDropdown?: IADropdown; /** The effective selected category, falling back to the first in the list. */ private get resolvedCategory(): string { @@ -197,7 +200,7 @@ export class IADropdownSearchBar extends LitElement { * pattern). The parent also binds this property and may re-set it on re-render. */ private handleCategorySelected( - e: CustomEvent<{ option: optionInterface }>, + e: CustomEvent<{ option: OptionInterface }>, ): void { const newCategoryId = e.detail.option.id; if (newCategoryId === this.resolvedCategory) return; diff --git a/src/elements/ia-dropdown/assets/caret-down.ts b/src/elements/ia-dropdown/assets/caret-down.ts new file mode 100644 index 0000000..e7a3f5e --- /dev/null +++ b/src/elements/ia-dropdown/assets/caret-down.ts @@ -0,0 +1,13 @@ +/** + * @file Caret Down Icon + * + * Kept as an inline Lit `svg` template rather than an imported `.svg` file so + * that `ia-dropdown` can recolor it via the `--dropdownCaretColor` CSS var, + * which is not possible on the contents of an ``. + */ +import { svg } from 'lit'; + +export default svg` + +`; diff --git a/src/elements/ia-dropdown/assets/caret-up.ts b/src/elements/ia-dropdown/assets/caret-up.ts new file mode 100644 index 0000000..7fd3975 --- /dev/null +++ b/src/elements/ia-dropdown/assets/caret-up.ts @@ -0,0 +1,13 @@ +/** + * @file Caret Up Icon + * + * Kept as an inline Lit `svg` template rather than an imported `.svg` file so + * that `ia-dropdown` can recolor it via the `--dropdownCaretColor` CSS var, + * which is not possible on the contents of an ``. + */ +import { svg } from 'lit'; + +export default svg` + +`; diff --git a/src/elements/ia-dropdown/ia-dropdown-story.ts b/src/elements/ia-dropdown/ia-dropdown-story.ts new file mode 100644 index 0000000..436dbb0 --- /dev/null +++ b/src/elements/ia-dropdown/ia-dropdown-story.ts @@ -0,0 +1,386 @@ +import { css, html, LitElement, type CSSResultGroup } from 'lit'; +import { customElement, query, state } from 'lit/decorators.js'; +import { StyleInputSettings } from '@demo/story-components/story-styles-settings'; + +import type { IADropdown, OptionInterface } from './ia-dropdown'; + +import '@demo/story-template'; +import './ia-dropdown'; +import './ia-icon-label'; + +// Styles + +const styleInputSettings: StyleInputSettings[] = [ + { + label: 'Menu background', + cssVariable: '--dropdownBgColor', + defaultValue: '#333333', + inputType: 'color', + }, + { + label: 'Text color', + cssVariable: '--dropdownTextColor', + defaultValue: '#ffffff', + inputType: 'color', + }, + { + label: 'Caret color', + cssVariable: '--dropdownCaretColor', + defaultValue: '#ffffff', + inputType: 'color', + }, + { + label: 'Border color', + cssVariable: '--dropdownBorderColor', + defaultValue: '#ffffff', + inputType: 'color', + }, + { + label: 'Selected option background', + cssVariable: '--dropdownSelectedBgColor', + defaultValue: '#ffffff', + inputType: 'color', + }, + { + label: 'Selected option text', + cssVariable: '--dropdownSelectedTextColor', + defaultValue: '#2c2c2c', + inputType: 'color', + }, + { + label: 'Border radius', + cssVariable: '--dropdownBorderRadius', + defaultValue: 4, + inputType: 'range', + min: 0, + max: 20, + step: 1, + unit: 'px', + }, + { + label: 'Menu offset from button', + cssVariable: '--dropdownOffsetTop', + defaultValue: 5, + inputType: 'range', + min: 0, + max: 40, + step: 1, + unit: 'px', + }, +]; + +// Option sets + +const MEDIA_OPTIONS: OptionInterface[] = [ + { id: 'all', label: 'All media types' }, + { id: 'texts', label: 'Books & Documents' }, + { id: 'movies', label: 'Video' }, + { id: 'audio', label: 'Audio' }, + { id: 'software', label: 'Software' }, +]; + +// These use in-page fragment URLs so that trying them out doesn't navigate +// away from the demo. Any `url` renders the option as an anchor just the same. +const LINK_OPTIONS: OptionInterface[] = [ + { + id: 'inlibrary', + url: '#elem-ia-dropdown', + label: 'Books to Borrow', + }, + { + id: 'texts', + url: '#elem-ia-dropdown', + label: 'Texts Collection', + }, + { + id: 'web', + url: '#elem-ia-dropdown', + label: 'Wayback Machine', + }, +]; + +@customElement('ia-dropdown-story') +export class IADropdownStory extends LitElement { + // Story component state + + @state() private displayCaret = true; + + @state() private isDisabled = false; + + @state() private openViaButton = true; + + @state() private closeOnSelect = false; + + @state() private includeSelectedOption = false; + + @state() private closeOnEscape = true; + + @state() private closeOnBackdropClick = true; + + @state() private useLinkOptions = false; + + @state() private selectedOption = 'all'; + + @state() private lastSelectedLabel = '(none yet)'; + + // Shadow DOM queries + + @query('#custom-list-dropdown') private customDropdown?: IADropdown; + + private get options(): OptionInterface[] { + return this.useLinkOptions ? LINK_OPTIONS : MEDIA_OPTIONS; + } + + private get selectedLabel(): string { + const match = this.options.find((o) => o.id === this.selectedOption); + return (match?.label as string) ?? 'Select one'; + } + + render() { + return html` + +
+
+ + ${this.selectedLabel} + +
+ +

+ Last selected: ${this.lastSelectedLabel} +

+ +
+ +

+ 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
  • +
+
+
+
+ +
+ + + + + + + + + ${this.checkboxRow( + 'display-caret', + 'Display caret', + 'displayCaret', + )} + ${this.checkboxRow('disabled', 'Disabled', 'isDisabled')} + ${this.checkboxRow( + 'open-via-button', + 'Open via button', + 'openViaButton', + )} + ${this.checkboxRow( + 'close-on-select', + 'Close on select', + 'closeOnSelect', + )} + ${this.checkboxRow( + 'include-selected', + 'Include selected option in menu', + 'includeSelectedOption', + )} + ${this.checkboxRow( + 'close-on-escape', + 'Close on Escape', + 'closeOnEscape', + )} + ${this.checkboxRow( + 'close-on-backdrop', + 'Close on backdrop click', + 'closeOnBackdropClick', + )} +
+ +

+
+ +
+

+ 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. +

+
+
+ `; + } + + // Templates + + private get plusIcon() { + return html` + + `; + } + + private checkboxRow( + id: string, + label: string, + prop: + | 'displayCaret' + | 'isDisabled' + | 'openViaButton' + | 'closeOnSelect' + | 'includeSelectedOption' + | 'closeOnEscape' + | 'closeOnBackdropClick', + ) { + return html` + + + + { + this[prop] = (e.target as HTMLInputElement).checked; + }} + /> + + + `; + } + + private get exampleUsage(): string { + return ` + \${this.selectedLabel} +`; + } + + // Event handlers + + private handleOptionSelected(e: CustomEvent<{ option: OptionInterface }>) { + this.selectedOption = e.detail.option.id; + this.lastSelectedLabel = e.detail.option.label as string; + } + + private handleOptionSetChanged(e: Event) { + this.useLinkOptions = (e.target as HTMLSelectElement).value === 'links'; + this.selectedOption = this.options[0].id; + this.lastSelectedLabel = '(none yet)'; + } + + private toggleCustomDropdown() { + if (this.customDropdown) { + this.customDropdown.open = !this.customDropdown.open; + } + } + + static get styles(): CSSResultGroup { + return css` + .demo-row { + display: flex; + align-items: center; + gap: 20px; + min-height: 40px; + padding: 10px; + background: #2c2c2c; + border-radius: 4px; + } + + .demo-readout { + font-size: 1.4rem; + } + + .demo-caption { + font-size: 1.4rem; + } + + .custom-list { + margin: 0; + padding: 0; + list-style: none; + } + + .custom-list li { + padding: 5px 10px; + white-space: nowrap; + cursor: pointer; + } + + .custom-list li:hover { + background-color: rgba(255, 255, 255, 0.3); + } + `; + } +} diff --git a/src/elements/ia-dropdown/ia-dropdown.test.ts b/src/elements/ia-dropdown/ia-dropdown.test.ts new file mode 100644 index 0000000..def8c49 --- /dev/null +++ b/src/elements/ia-dropdown/ia-dropdown.test.ts @@ -0,0 +1,425 @@ +import { describe, expect, test } from 'vitest'; +import { aTimeout, fixture } from '@open-wc/testing-helpers'; +import { html } from 'lit'; + +import type { IADropdown, OptionInterface } from './ia-dropdown'; +import './ia-dropdown'; + +describe('IADropdown', () => { + test('is closed at default', async () => { + const el = await fixture(html``); + expect(el.open).to.be.false; + }); + + test('can display caret as option', async () => { + const el = await fixture(html``); + expect(el.displayCaret).to.be.false; + + el.displayCaret = true; + await el.updateComplete; + + expect(el.displayCaret).to.be.true; + + const caret = el.shadowRoot?.querySelector('span.caret'); + expect(caret).to.exist; + }); + + test('can be disabled', async () => { + const el = await fixture(html``); + expect(el.isDisabled).to.be.false; + + el.isDisabled = true; + await el.updateComplete; + + expect(el.isDisabled).to.be.true; + + const mainButton = el.shadowRoot?.querySelector( + 'button.click-main', + ) as HTMLButtonElement; + expect(mainButton.disabled).to.be.true; + }); + + test('does not set hasCustomClickHandler at default', async () => { + const el = await fixture(html``); + expect(el.hasCustomClickHandler).to.be.false; + }); + + test('when `hasCustomClickHandler` true, default toggles disabled', async () => { + const el = await fixture( + html``, + ); + + const mainButton = el.shadowRoot?.querySelector( + 'button.click-main', + ) as HTMLButtonElement; + mainButton?.click(); + await el.updateComplete; + // Should remain closed from clicking main button + expect(el.open).to.be.false; + + (el.shadowRoot?.querySelector('.caret') as HTMLElement)?.click(); + await el.updateComplete; + // Should open from clicking caret + expect(el.open).to.be.false; + }); + + describe('Slotted caret', () => { + test('can display slotted caret', async () => { + const svgCaret = html` + + `; + + const el = await fixture( + html` ${svgCaret} `, + ); + + expect(el.displayCaret).to.be.true; + + const caretDown = el.querySelector('.slotted') as HTMLElement; + expect(caretDown).to.exist; + }); + }); + + describe('Toggling', () => { + test('has `toggleOptions` function to toggle open dropdown options', async () => { + const el = await fixture( + html``, + ); + + expect(el.open).to.be.false; + el.toggleOptions(); + await el.updateComplete; + + expect(el.open).to.be.true; + }); + + test('caret, when displayed, changes to point up when dropdown is opened', async () => { + const el = await fixture( + html``, + ); + + expect(el.displayCaret).to.be.true; + const caret = el.shadowRoot?.querySelector('span.caret') as HTMLElement; + const caretDown = caret?.querySelector('.caret-down') as HTMLElement; + const caretUp = caret?.querySelector('.caret-up') as HTMLElement; + + expect(caretDown.hidden).to.be.false; + expect(caretUp.hidden).to.be.true; + + const trigger = el.shadowRoot?.querySelector('button.click-main'); + expect(trigger).to.exist; + + (trigger as HTMLElement)?.click(); + + await el.updateComplete; + + expect(caretDown.hidden).to.be.true; + expect(caretUp.hidden).to.be.false; + expect(el.open).to.be.true; + }); + + test('can optionally disable toggling via main button', async () => { + const el = await fixture( + html``, + ); + + expect(el.open).to.be.false; + const mainButton = el.shadowRoot?.querySelector( + 'button.click-main', + ) as HTMLButtonElement; + mainButton?.click(); + await el.updateComplete; + + // Should remain closed from clicking main button + expect(el.open).to.be.false; + + // But clicking on the caret should open it + (el.shadowRoot?.querySelector('.caret') as HTMLElement)?.click(); + await el.updateComplete; + expect(el.open).to.be.true; + }); + + test('can be toggled via keyboard interaction on the main button when active', async () => { + const el = await fixture( + html``, + ); + + const enterKeyEvent = new KeyboardEvent('keydown', { key: 'Enter' }); + const spaceKeyEvent = new KeyboardEvent('keydown', { key: ' ' }); + + const mainButton = el.shadowRoot?.querySelector( + '.click-main', + ) as HTMLElement; + expect(mainButton).to.exist; + expect(el.open).to.be.false; + + mainButton?.dispatchEvent(spaceKeyEvent); + await el.updateComplete; + expect(el.open).to.be.true; + + mainButton?.dispatchEvent(enterKeyEvent); + await el.updateComplete; + expect(el.open).to.be.false; + }); + + test('can be toggled via keyboard interaction on the caret when main button is not active', async () => { + const el = await fixture( + html``, + ); + + const enterKeyEvent = new KeyboardEvent('keydown', { key: 'Enter' }); + const spaceKeyEvent = new KeyboardEvent('keydown', { key: ' ' }); + + const caret = el.shadowRoot?.querySelector('.caret') as HTMLElement; + expect(caret).to.exist; + expect(el.open).to.be.false; + + caret?.dispatchEvent(enterKeyEvent); + await el.updateComplete; + expect(el.open).to.be.true; + + caret?.dispatchEvent(spaceKeyEvent); + await el.updateComplete; + expect(el.open).to.be.false; + }); + }); + + describe('Options', () => { + test('receives a list of options', async () => { + const options: OptionInterface[] = [ + { + url: 'archive.org', + label: html`

foo

`, + id: 'link-example', + }, + ]; + const el = await fixture( + html``, + ); + + expect(el.options.length).to.equal(1); + const list = el.shadowRoot?.querySelector('ul'); + + expect(list).to.exist; + expect(list?.querySelectorAll('li').length).to.equal(1); + }); + + test('On Option Selected: fires custom event `optionSelected` & option callback `selectedHandler` if available', async () => { + let optionCallbackReceived = false; + const el = await fixture( + html` { + optionCallbackReceived = true; + }, + label: 'beep', + id: 'callback-example', + }, + ] as OptionInterface[]} + >`, + ); + + let thisOptionSelected; + + const eventListener = (e: CustomEvent) => { + thisOptionSelected = e.detail.option as OptionInterface; + }; + el.addEventListener('optionSelected', eventListener as EventListener); + + expect(thisOptionSelected).to.be.undefined; + expect(optionCallbackReceived).to.be.false; + + ( + el.shadowRoot?.querySelector('button.click-main') as HTMLButtonElement + )?.click(); + await el.updateComplete; + expect(el.open).to.be.true; + + const list = el.shadowRoot?.querySelector('ul'); + const firstOption = list?.querySelector('li'); + const firstOptionTarget = firstOption?.querySelector('button'); + expect(firstOptionTarget).to.exist; + + firstOptionTarget?.click(); + + await el.updateComplete; + expect(optionCallbackReceived).to.be.true; + expect(el.selectedOption).to.equal('callback-example'); + expect(thisOptionSelected).to.exist; + expect(thisOptionSelected && thisOptionSelected['selectedHandler']).to + .exist; + expect(thisOptionSelected && thisOptionSelected['id']).to.equal( + 'callback-example', + ); + expect(thisOptionSelected && thisOptionSelected['label']).to.equal( + 'beep', + ); + }); + + test('Can optionally close dropdown when an option is selected', async () => { + const options: OptionInterface[] = [ + { + label: html`

foo

`, + id: 'foo', + }, + ]; + const el = await fixture( + html``, + ); + + expect(el.open).to.be.false; + + const list = el.shadowRoot?.querySelector('ul'); + expect(list).to.exist; + expect(list?.querySelectorAll('li').length).to.equal(1); + + const mainButton = el.shadowRoot?.querySelector( + 'button.click-main', + ) as HTMLButtonElement; + mainButton?.click(); + await el.updateComplete; + expect(el.open).to.be.true; + + (list?.querySelector('li > button') as HTMLButtonElement)?.click(); + await el.updateComplete; + expect(el.open).to.be.false; + }); + + test('can set `optionGroup` namespace', async () => { + const el = await fixture( + html``, + ); + + expect(el.optionGroup).to.equal('foobarz'); + + const srOnly = el.shadowRoot?.querySelector( + 'button.click-main span.sr-only', + ); + expect(srOnly?.innerHTML).to.contain('foobarz'); + }); + + test('Only show available options in dropdown', async () => { + const el = await fixture( + html` {}, + label: 'beep', + id: 'selected-example', + }, + { + selectedHandler: () => {}, + label: 'bloop', + id: 'not-selected-example', + }, + ] as OptionInterface[]} + .selectedOption=${'selected-example'} + >`, + ); + + expect(el.availableOptions.length).to.equal(1); + + el.selectedOption = ''; + await el.updateComplete; + expect(el.availableOptions.length).to.equal(2); + }); + + test('Can optionally show all options in dropdown, including selected', async () => { + const el = await fixture( + html` {}, + label: 'beep', + id: 'selected-example', + }, + { + selectedHandler: () => {}, + label: 'bloop', + id: 'not-selected-example', + }, + ] as OptionInterface[]} + .selectedOption=${'selected-example'} + >`, + ); + + expect(el.availableOptions.length).to.equal(2); + + el.selectedOption = ''; + await el.updateComplete; + expect(el.availableOptions.length).to.equal(2); + }); + }); + + describe('Keyboard', () => { + test('ignores Escape key by default', async () => { + const el = await fixture( + html``, + ); + + expect(el.open).to.be.false; + + const mainButton = el.shadowRoot?.querySelector( + 'button.click-main', + ) as HTMLButtonElement; + mainButton?.click(); + await el.updateComplete; + expect(el.open).to.be.true; + + const escapeKeyEvent = new KeyboardEvent('keydown', { key: 'Escape' }); + document.dispatchEvent(escapeKeyEvent); + await el.updateComplete; + expect(el.open).to.be.true; + }); + + test('closes on Escape key', async () => { + const el = await fixture( + html` + `, + ); + + expect(el.open).to.be.false; + + const mainButton = el.shadowRoot?.querySelector( + 'button.click-main', + ) as HTMLButtonElement; + mainButton?.click(); + await el.updateComplete; + expect(el.open).to.be.true; + + const escapeKeyEvent = new KeyboardEvent('keydown', { key: 'Escape' }); + document.dispatchEvent(escapeKeyEvent); + await aTimeout(50); + expect(el.open).to.be.false; + }); + }); + + test('can display slotted options as custom list', async () => { + const customList = html`
  • +
    foo
    +
  • `; + 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..1c9a3a6 --- /dev/null +++ b/src/elements/ia-dropdown/ia-dropdown.ts @@ -0,0 +1,777 @@ +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 themeStyles from '@src/themes/theme-styles'; + +import caretUp from './assets/caret-up'; +import caretDown from './assets/caret-down'; + +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``; + } + + 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` + + ${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` + + ${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` + + `; + } + + // 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` +
    +
    + + ${when(!this.shouldNestCaretInButton, () => this.caretTemplate)} +
    + + + + ${this.backdropTemplate} +
    + `; + } + + static get styles(): CSSResultGroup { + const dropdownBorderWidth = css`var(--dropdownBorderWidth, 1px)`; + const dropdownBorderRadius = css`var(--dropdownBorderRadius, 4px)`; + const dropdownBorderColor = css`var(--dropdownBorderColor, #fff)`; + const dropdownBgColor = css`var(--dropdownBgColor, #333)`; + const dropdownTextColor = css`var(--dropdownTextColor, #fff)`; + const dropdownHoverBgColor = css`var(--dropdownHoverBgColor, rgba(255, 255, 255, 0.3))`; + const dropdownSelectedBgColor = css`var(--dropdownSelectedBgColor, #fff)`; + const dropdownMainButtonBgColor = css`var(--dropdownMainButtonBgColor, transparent)`; + const dropdownTextAlign = css`var(--dropdownTextAlign, inherit)`; + const dropdownBackdropZIndex = css`var(--dropdownBackdropZIndex, 1)`; + const dropdownListZIndex = css`var(--dropdownListZIndex, 2)`; + + return [ + themeStyles, + css` + :host { + display: inline; + color: ${dropdownTextColor}; + } + + svg.caret-up-svg, + svg.caret-down-svg, + ::slotted(svg.caret-up-svg), + ::slotted(svg.caret-down-svg) { + fill: var(--dropdownCaretColor, #fff); + vertical-align: middle; + } + + .button-row { + display: flex; + height: 100%; + } + + button.click-main { + background: ${dropdownMainButtonBgColor}; + color: inherit; + padding: var(--dropdownMainButtonPadding, 0px); + border: var(--dropdownMainButtonBorder, none); + border-radius: var(--dropdownMainButtonBorderRadius, none); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + align-content: center; + flex-wrap: nowrap; + flex-direction: var(--dropdownMainButtonFlexDirection, row); + } + + .open button.click-main { + /* When the dropdown is open, give the buttom the same z-index + as the dropdown menu, so that it remains clickable despite + the backdrop. */ + z-index: ${dropdownListZIndex}; + } + + button.click-main:disabled { + pointer-events: none; + cursor: not-allowed; + opacity: 0.5; + /* Disable text selection on disabled button */ + -webkit-user-select: none; /* Safari */ + -ms-user-select: none; /* IE 10 and IE 11 */ + user-select: none; /* Standard syntax */ + } + + button.click-main:hover { + background-color: var( + --dropdownMainButtonHoverBgColor, + ${dropdownMainButtonBgColor} + ); + } + + button.click-main:focus, + button.click-main:focus-visible { + background-color: var( + --dropdownMainButtonFocusBgColor, + ${dropdownMainButtonBgColor} + ); + } + + button.click-main:active { + background-color: var( + --dropdownMainButtonActiveBgColor, + ${dropdownMainButtonBgColor} + ); + } + + button slot[name='dropdown-label'] { + /* Set var to 0px for column layout */ + padding-right: var(--buttonSlotPaddingRight, 5px); + display: inline-block; + } + + .ia-dropdown-group { + width: inherit; + height: inherit; + position: relative; + } + + .sr-only { + border: 0 !important; + clip: rect(1px, 1px, 1px, 1px) !important; + -webkit-clip-path: inset(50%) !important; + clip-path: inset(50%) !important; + height: 1px !important; + margin: -1px !important; + overflow: hidden !important; + padding: 0 !important; + position: absolute !important; + width: 1px !important; + white-space: nowrap !important; + -webkit-user-select: none !important; + user-select: none !important; + } + + .caret { + /* Maintain centered caret position but with a full-height clickable region */ + display: flex; + align-self: stretch; + align-items: center; + padding: var(--caretPadding, 0px); + } + + button.caret { + appearance: none; + background: none; + border: none; + cursor: pointer; + } + + .caret svg { + height: var(--caretHeight, 10px); + width: var(--caretWidth, 20px); + } + + #dropdown-backdrop { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: transparent; + z-index: ${dropdownBackdropZIndex}; + } + + ul { + z-index: ${dropdownListZIndex}; + } + + #dropdown-main.closed { + visibility: hidden; + height: 1px; + width: 1px; + } + + #dropdown-main { + position: var(--dropdownListPosition, absolute); + list-style: none; + margin: var(--dropdownOffsetTop, 5px) 0 0 0; + padding: var(--dropdownPadding, 0); + color: ${dropdownTextColor}; + background: ${dropdownBgColor}; + + font-size: var(--dropdownFontSize, inherit); + + border-top: var(--dropdownBorderTopWidth, ${dropdownBorderWidth}); + border-right: var(--dropdownBorderRightWidth, ${dropdownBorderWidth}); + border-bottom: var( + --dropdownBorderBottomWidth, + ${dropdownBorderWidth} + ); + border-left: var(--dropdownBorderLeftWidth, ${dropdownBorderWidth}); + /* Must be after border-width settings for specificity */ + border-style: solid; + border-color: ${dropdownBorderColor}; + + border-radius: var( + --dropdownBorderTopLeftRadius, + ${dropdownBorderRadius} + ) + var(--dropdownBorderTopRightRadius, ${dropdownBorderRadius}) + var(--dropdownBorderBottomRightRadius, ${dropdownBorderRadius}) + var(--dropdownBorderBottomLeftRadius, ${dropdownBorderRadius}); + + white-space: var(--dropdownWhiteSpace, normal); + + /* Prevent top/bottom inner li from overlapping inner border */ + overflow: hidden; + } + + #dropdown-main li:hover { + background-color: ${dropdownHoverBgColor}; + color: var(--dropdownHoverTextColor, #fff); + list-style: none; + cursor: pointer; + } + + #dropdown-main li:hover:first-child { + border-top-color: ${dropdownHoverBgColor}; + } + + ul#dropdown-main li:hover:last-child { + border-bottom-color: ${dropdownHoverBgColor}; + } + + #dropdown-main li:hover:not(:first-child) { + border-top: 0.5px solid var(--dropdownHoverTopBottomBorderColor, #333); + } + #dropdown-main li:hover:not(:last-child) { + border-bottom: 0.5px solid + var(--dropdownHoverTopBottomBorderColor, #333); + } + + #dropdown-main li.selected:last-child { + border-bottom-color: ${dropdownSelectedBgColor}; + } + + #dropdown-main li.selected:first-child { + border-top-color: ${dropdownSelectedBgColor}; + } + + #dropdown-main li:hover > *, + #dropdown-main li:focus-within > * { + background-color: ${dropdownHoverBgColor}; + color: var(--dropdownHoverTextColor, #fff); + } + + #dropdown-main li.selected > * { + background-color: ${dropdownSelectedBgColor}; + color: var(--dropdownSelectedTextColor, #2c2c2c); + } + + #dropdown-main li { + background: ${dropdownBgColor}; + list-style: none; + height: 30px; + cursor: pointer; + border-bottom: 0.5px solid ${dropdownBgColor}; + border-top: 0.5px solid ${dropdownBgColor}; + } + + #dropdown-main li button { + background: none; + color: inherit; + border: none; + font: inherit; + cursor: pointer; + outline: inherit; + } + + #dropdown-main li a { + text-decoration: none; + display: block; + box-sizing: border-box; + } + + #dropdown-main li:first-child { + border-top-left-radius: var(--dropdownBorderTopLeftRadius, 4px); + border-top-right-radius: var(--dropdownBorderTopRightRadius, 4px); + } + + #dropdown-main li:last-child { + border-bottom-right-radius: var( + --dropdownBorderBottomRightRadius, + 4px + ); + border-bottom-left-radius: var(--dropdownBorderBottomLeftRadius, 4px); + } + + /* cover the list with the label */ + #dropdown-main li > * > :first-child { + margin: 0; + display: flex; + align-items: center; + justify-content: flex-start; + align-content: center; + flex-wrap: nowrap; + height: 100%; + padding: var(--dropdownItemPaddingTop, 5px) + var(--dropdownItemPaddingRight, 10px) + var(--dropdownItemPaddingBottom, 5px) + var(--dropdownItemPaddingLeft, 10px); + box-sizing: border-box; + } + + #dropdown-main li > * { + width: 100%; + height: inherit; + color: ${dropdownTextColor}; + background: var(--dropdownItemButtonBgColor, transparent); + padding: var(--dropdownItemButtonPadding, 0); + text-align: ${dropdownTextAlign}; + } + `, + ]; + } +} diff --git a/src/elements/ia-dropdown/ia-icon-label.test.ts b/src/elements/ia-dropdown/ia-icon-label.test.ts new file mode 100644 index 0000000..53968be --- /dev/null +++ b/src/elements/ia-dropdown/ia-icon-label.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'vitest'; +import { fixture } from '@open-wc/testing-helpers'; +import { html } from 'lit'; + +import type { IAIconLabel } from './ia-icon-label'; +import './ia-icon-label'; + +describe('IAIconLabel', () => { + test('displays the provided icon in the `slot[name="icon"`', async () => { + const fooLabel = html`

    foo

    `; + const el = await fixture( + html` ${fooLabel} `, + ); + + const iconSlot = el?.shadowRoot?.querySelector('slot[name="icon"]'); + const iconSlotEls = (iconSlot as HTMLSlotElement)?.assignedElements(); + expect(iconSlotEls.length).to.equal(1); + expect(iconSlotEls[0].getAttribute('id')).to.equal('bunnyhop'); + }); + + test('displays generic slot', async () => { + const barLabel = html`

    bar

    `; + const el = await fixture( + html` ${barLabel} `, + ); + + const allSlots = el?.shadowRoot?.querySelectorAll('slot') || []; + const iconSlotEls = allSlots[0]?.assignedElements(); + const labelSlotEls = allSlots[1]?.assignedElements(); + + expect(allSlots?.length).to.equal(2); + expect(iconSlotEls?.length).to.equal(0); + expect(labelSlotEls?.length).to.equal(1); + expect(labelSlotEls[0].getAttribute('id')).to.equal('carrotcake'); + }); +}); diff --git a/src/elements/ia-dropdown/ia-icon-label.ts b/src/elements/ia-dropdown/ia-icon-label.ts new file mode 100644 index 0000000..d18c1ed --- /dev/null +++ b/src/elements/ia-dropdown/ia-icon-label.ts @@ -0,0 +1,101 @@ +import { html, css, LitElement, TemplateResult, CSSResultGroup } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +import themeStyles from '@src/themes/theme-styles'; + +/** + * Lays out an icon beside a label, for use as the content of an + * `ia-dropdown` option or main button. + */ +@customElement('ia-icon-label') +export class IAIconLabel extends LitElement { + render(): TemplateResult { + return html` +
    + + +
    + `; + } + + static styles: CSSResultGroup = [ + themeStyles, + css` + :host { + display: block; + width: 100%; + height: 100%; + position: relative; + } + + :host(.invert-icon-at-hover:hover) slot[name='icon'] { + filter: invert(1); + } + + :host(.selected) { + background-color: var(--selectedBgColor, #fff); + color: var(--selectedTextColor, #2c2c2c); + } + + :host(.invert-icon-at-selected.selected) slot[name='icon'] { + filter: invert(1); + } + + div.icon-label-container { + display: flex; + width: 100%; + align-items: center; + justify-content: flex-start; + align-content: center; + flex-wrap: nowrap; + flex-direction: var(--iconLabelFlexDirection, row); + height: 100%; + } + + slot[name='icon'] { + width: var(--iconWidth, 20px); + margin-right: var(--iconLabelGutterWidth, 10px); + display: flex; + align-items: center; + justify-content: flex-start; + align-content: center; + flex-wrap: nowrap; + white-space: nowrap; + height: 100%; + } + + /* https://css-tricks.com/flexbox-truncated-text/ */ + ::slotted(div.truncate) { + display: flex; + width: var(--labelWidth, 100%); + text-align: left; + word-wrap: break-word; /* Important for long words! */ + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + } + + @supports not (-webkit-line-clamp: 2) { + ::slotted(div.truncate) { + min-width: 0; + } + } + @supports (-webkit-line-clamp: 2) { + ::slotted(div.truncate) { + min-width: 0; + display: -webkit-box; + overflow-wrap: break-word; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + /* Fixed line-height needed to fit unicode and emojis + https://stackoverflow.com/a/67807146 + */ + line-height: 1.2em; + /* max-height needed for Safari browser */ + max-height: var(--labelTruncateHeight, 30px); + max-width: var(--labelWidth, 100%); + } + } + `, + ]; +} diff --git a/src/elements/index.ts b/src/elements/index.ts index 4847a5e..a3b8f82 100644 --- a/src/elements/index.ts +++ b/src/elements/index.ts @@ -1,3 +1,5 @@ export * from './ia-button/ia-button'; export * from './ia-combo-box/ia-combo-box'; +export * from './ia-dropdown/ia-dropdown'; +export * from './ia-dropdown/ia-icon-label'; export * from './ia-dropdown-search-bar/ia-dropdown-search-bar'; From 07d87db8e9fced35d8c0c6ce3c22eb16d5b9914c Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Tue, 28 Jul 2026 17:11:09 -0700 Subject: [PATCH 2/2] WEBDEV-8812: Move the caret icons to real .svg files The carets were Lit svg templates in .ts files. They're now plain caret-up.svg / caret-down.svg like the other icons in the repo. They're imported with ?raw and inlined with unsafeHTML rather than rendered as , because the caret color is driven by the --dropdownCaretColor CSS var and that can't reach inside an . Four places rely on it: this repo's own ia-dropdown-search-bar, offshoot's loan-filter-bar, and collection-browser's sort-filter-bar and smart-facet-dropdown. Verified in the browser that the carets are still real inline elements with their caret-up-svg / caret-down-svg classes, that the var still drives the fill, and that the search bar's caret resolves to its dark #2c2c2c rather than the default white. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EnQGMFUXNYTcTL2mvkJ3Dp --- src/elements/ia-dropdown/assets/caret-down.svg | 4 ++++ src/elements/ia-dropdown/assets/caret-down.ts | 13 ------------- src/elements/ia-dropdown/assets/caret-up.svg | 4 ++++ src/elements/ia-dropdown/assets/caret-up.ts | 13 ------------- src/elements/ia-dropdown/ia-dropdown.ts | 12 ++++++++---- 5 files changed, 16 insertions(+), 30 deletions(-) create mode 100644 src/elements/ia-dropdown/assets/caret-down.svg delete mode 100644 src/elements/ia-dropdown/assets/caret-down.ts create mode 100644 src/elements/ia-dropdown/assets/caret-up.svg delete mode 100644 src/elements/ia-dropdown/assets/caret-up.ts diff --git a/src/elements/ia-dropdown/assets/caret-down.svg b/src/elements/ia-dropdown/assets/caret-down.svg new file mode 100644 index 0000000..ff99db0 --- /dev/null +++ b/src/elements/ia-dropdown/assets/caret-down.svg @@ -0,0 +1,4 @@ + + + diff --git a/src/elements/ia-dropdown/assets/caret-down.ts b/src/elements/ia-dropdown/assets/caret-down.ts deleted file mode 100644 index e7a3f5e..0000000 --- a/src/elements/ia-dropdown/assets/caret-down.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * @file Caret Down Icon - * - * Kept as an inline Lit `svg` template rather than an imported `.svg` file so - * that `ia-dropdown` can recolor it via the `--dropdownCaretColor` CSS var, - * which is not possible on the contents of an ``. - */ -import { svg } from 'lit'; - -export default svg` - -`; diff --git a/src/elements/ia-dropdown/assets/caret-up.svg b/src/elements/ia-dropdown/assets/caret-up.svg new file mode 100644 index 0000000..c057770 --- /dev/null +++ b/src/elements/ia-dropdown/assets/caret-up.svg @@ -0,0 +1,4 @@ + + + diff --git a/src/elements/ia-dropdown/assets/caret-up.ts b/src/elements/ia-dropdown/assets/caret-up.ts deleted file mode 100644 index 7fd3975..0000000 --- a/src/elements/ia-dropdown/assets/caret-up.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * @file Caret Up Icon - * - * Kept as an inline Lit `svg` template rather than an imported `.svg` file so - * that `ia-dropdown` can recolor it via the `--dropdownCaretColor` CSS var, - * which is not possible on the contents of an ``. - */ -import { svg } from 'lit'; - -export default svg` - -`; diff --git a/src/elements/ia-dropdown/ia-dropdown.ts b/src/elements/ia-dropdown/ia-dropdown.ts index 1c9a3a6..ab5228e 100644 --- a/src/elements/ia-dropdown/ia-dropdown.ts +++ b/src/elements/ia-dropdown/ia-dropdown.ts @@ -13,11 +13,15 @@ import { 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'; -import caretUp from './assets/caret-up'; -import caretDown from './assets/caret-down'; +// 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; @@ -323,7 +327,7 @@ export class IADropdown extends LitElement { private get caretUpTemplate(): TemplateResult { return html` - ${caretUp} + ${unsafeHTML(caretUp)} `; } @@ -336,7 +340,7 @@ export class IADropdown extends LitElement { private get caretDownTemplate(): TemplateResult { return html` - ${caretDown} + ${unsafeHTML(caretDown)} `; }