diff --git a/docs/resources/changelog.md b/docs/resources/changelog.md index 93992c39..2e94405c 100644 --- a/docs/resources/changelog.md +++ b/docs/resources/changelog.md @@ -6,6 +6,12 @@ Components with the Experimental bad _During the beta period, these restrictions may be relaxed in the event of a mission-critical bug._ 🐛 +## Next + +- Added `sl-label-change` event to `` +- Fixed a bug where updating a menu item's label wouldn't update the display label in `` [#729](https://github.com/shoelace-style/shoelace/issues/729) +- Improved performance of `` by caching menu items instead of traversing for them each time + ## 2.0.0-beta.73 - Added `button` part to `` diff --git a/src/components/menu-item/menu-item.ts b/src/components/menu-item/menu-item.ts index 8ae68dbd..2f100459 100644 --- a/src/components/menu-item/menu-item.ts +++ b/src/components/menu-item/menu-item.ts @@ -2,6 +2,8 @@ import { html, LitElement } from 'lit'; import { customElement, property, query } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import '../../components/icon/icon'; +import { emit } from '../../internal/event'; +import { getTextContent } from '../../internal/slot'; import { watch } from '../../internal/watch'; import styles from './menu-item.styles'; @@ -11,6 +13,9 @@ import styles from './menu-item.styles'; * * @dependency sl-icon * + * @event sl-label-change - Emitted when the menu item's text label changes. For performance reasons, this event is only + * emitted if the default slot's `slotchange` event is triggered. It will not fire when the label is first set. + * * @slot - The menu item's label. * @slot prefix - Used to prepend an icon or similar element to the menu item. * @slot suffix - Used to append an icon or similar element to the menu item. @@ -24,6 +29,9 @@ import styles from './menu-item.styles'; export default class SlMenuItem extends LitElement { static styles = styles; + private cachedTextLabel: string; + + @query('slot:not([name])') defaultSlot: HTMLSlotElement; @query('.menu-item') menuItem: HTMLElement; /** Draws the item in a checked state. */ @@ -39,6 +47,11 @@ export default class SlMenuItem extends LitElement { this.setAttribute('role', 'menuitem'); } + /** Returns a text label based on the contents of the menu item's default slot. */ + getTextLabel() { + return getTextContent(this.defaultSlot); + } + @watch('checked') handleCheckedChange() { this.setAttribute('aria-checked', this.checked ? 'true' : 'false'); @@ -49,6 +62,21 @@ export default class SlMenuItem extends LitElement { this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false'); } + handleDefaultSlotChange() { + const textLabel = this.getTextLabel(); + + // Ignore the first time the label is set + if (typeof this.cachedTextLabel === 'undefined') { + this.cachedTextLabel = textLabel; + return; + } + + if (textLabel !== this.cachedTextLabel) { + this.cachedTextLabel = textLabel; + emit(this, 'sl-label-change'); + } + } + render() { return html`
- + diff --git a/src/components/select/select.test.ts b/src/components/select/select.test.ts index 7db7956b..8a8f26c2 100644 --- a/src/components/select/select.test.ts +++ b/src/components/select/select.test.ts @@ -1,4 +1,4 @@ -import { expect, fixture, html, waitUntil } from '@open-wc/testing'; +import { expect, fixture, html, oneEvent, waitUntil } from '@open-wc/testing'; import { sendKeys } from '@web/test-runner-commands'; import sinon from 'sinon'; import type SlSelect from './select'; @@ -110,4 +110,24 @@ describe('', () => { expect(select.shadowRoot!.activeElement).to.equal(select.control); }); + + it('should update the display label when a menu item changes', async () => { + const el = await fixture(html` + + Option 1 + Option 2 + Option 3 + + `); + const displayLabel = el.shadowRoot!.querySelector('[part="display-label"]')!; + const menuItem = el.querySelector('sl-menu-item')!; + + expect(displayLabel.textContent?.trim()).to.equal('Option 1'); + menuItem.textContent = 'updated'; + + await oneEvent(el, 'sl-label-change'); + await el.updateComplete; + + expect(displayLabel.textContent?.trim()).to.equal('updated'); + }); }); diff --git a/src/components/select/select.ts b/src/components/select/select.ts index 56d7736e..114c6e31 100644 --- a/src/components/select/select.ts +++ b/src/components/select/select.ts @@ -8,7 +8,7 @@ import '../../components/menu/menu'; import '../../components/tag/tag'; import { emit } from '../../internal/event'; import { FormSubmitController } from '../../internal/form'; -import { getTextContent, HasSlotController } from '../../internal/slot'; +import { HasSlotController } from '../../internal/slot'; import { watch } from '../../internal/watch'; import { LocalizeController } from '../../utilities/localize'; import styles from './select.styles'; @@ -72,6 +72,7 @@ export default class SlSelect extends LitElement { private readonly formSubmitController = new FormSubmitController(this); private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label'); private readonly localize = new LocalizeController(this); + private menuItems: SlMenuItem[] = []; private resizeObserver: ResizeObserver; @state() private hasFocus = false; @@ -166,15 +167,6 @@ export default class SlSelect extends LitElement { this.invalid = !this.input.checkValidity(); } - getItemLabel(item: SlMenuItem) { - const slot = item.shadowRoot!.querySelector('slot:not([name])'); - return getTextContent(slot); - } - - getItems() { - return [...this.querySelectorAll('sl-menu-item')]; - } - getValueAsArray() { // Single selects use '' as an empty selection value, so convert this to [] for an empty multi select if (this.multiple && this.value === '') { @@ -229,9 +221,8 @@ export default class SlSelect extends LitElement { handleKeyDown(event: KeyboardEvent) { const target = event.target as HTMLElement; - const items = this.getItems(); - const firstItem = items[0]; - const lastItem = items[items.length - 1]; + const firstItem = this.menuItems[0]; + const lastItem = this.menuItems[this.menuItems.length - 1]; // Ignore key presses on tags if (target.tagName.toLowerCase() === 'sl-tag') { @@ -313,6 +304,14 @@ export default class SlSelect extends LitElement { this.control.focus(); } + handleMenuItemLabelChange() { + // Update the display label when checked menu item's label changes + if (!this.multiple) { + const checkedItem = this.menuItems.find(item => item.value === this.value); + this.displayLabel = checkedItem ? checkedItem.getTextLabel() : ''; + } + } + @watch('multiple') handleMultipleChange() { // Cast to array | string based on `this.multiple` @@ -323,11 +322,11 @@ export default class SlSelect extends LitElement { async handleMenuSlotChange() { // Wait for items to render before gathering labels otherwise the slot won't exist - const items = this.getItems(); + this.menuItems = [...this.querySelectorAll('sl-menu-item')]; // Check for duplicate values in menu items const values: string[] = []; - items.forEach(item => { + this.menuItems.forEach(item => { if (values.includes(item.value)) { console.error(`Duplicate value found in menu item: '${item.value}'`, item); } @@ -335,7 +334,8 @@ export default class SlSelect extends LitElement { values.push(item.value); }); - await Promise.all(items.map(item => item.render)).then(() => this.syncItemsFromValue()); + await Promise.all(this.menuItems.map(item => item.render)); + this.syncItemsFromValue(); } handleTagInteraction(event: KeyboardEvent | MouseEvent) { @@ -364,22 +364,20 @@ export default class SlSelect extends LitElement { resizeMenu() { this.menu.style.width = `${this.control.clientWidth}px`; - this.dropdown.reposition(); } syncItemsFromValue() { - const items = this.getItems(); const value = this.getValueAsArray(); // Sync checked states - items.map(item => (item.checked = value.includes(item.value))); + this.menuItems.map(item => (item.checked = value.includes(item.value))); // Sync display label and tags if (this.multiple) { - const checkedItems = items.filter(item => value.includes(item.value)); + const checkedItems = this.menuItems.filter(item => value.includes(item.value)); - this.displayLabel = checkedItems.length > 0 ? this.getItemLabel(checkedItems[0]) : ''; + this.displayLabel = checkedItems.length > 0 ? checkedItems[0].getTextLabel() : ''; this.displayTags = checkedItems.map((item: SlMenuItem) => { return html` - ${this.getItemLabel(item)} + ${item.getTextLabel()} `; }); @@ -428,16 +426,15 @@ export default class SlSelect extends LitElement { `); } } else { - const checkedItem = items.find(item => item.value === value[0]); + const checkedItem = this.menuItems.find(item => item.value === value[0]); - this.displayLabel = checkedItem ? this.getItemLabel(checkedItem) : ''; + this.displayLabel = checkedItem ? checkedItem.getTextLabel() : ''; this.displayTags = []; } } syncValueFromItems() { - const items = this.getItems(); - const checkedItems = items.filter(item => item.checked); + const checkedItems = this.menuItems.filter(item => item.checked); const checkedValues = checkedItems.map(item => item.value); if (this.multiple) { @@ -571,7 +568,7 @@ export default class SlSelect extends LitElement {
- +