diff --git a/docs/docs/components/select.md b/docs/docs/components/select.md index 5f8420c3b..14cd0c5a6 100644 --- a/docs/docs/components/select.md +++ b/docs/docs/components/select.md @@ -130,6 +130,15 @@ Note that multi-select options may wrap, causing the control to expand verticall Use the `value` attribute to set the initial selection. +```html {.example} + + Option 1 + Option 2 + Option 3 + Option 4 + +``` + When using `multiple`, the `value` _attribute_ uses space-delimited values to select more than one option. Because of this, `` values cannot contain spaces. If you're accessing the `value` _property_ through Javascript, it will be an array. ```html {.example} @@ -294,7 +303,7 @@ Remember that custom tags are rendered in a shadow root. To style them, you can return ` - ${option.getTextLabel()} + ${option.label} `; }; diff --git a/src/components/menu-item/menu-item.test.ts b/src/components/menu-item/menu-item.test.ts index e6bf5226e..e28ef916f 100644 --- a/src/components/menu-item/menu-item.test.ts +++ b/src/components/menu-item/menu-item.test.ts @@ -60,9 +60,16 @@ describe('', () => { }); }); - it('should return a text label when calling getTextLabel()', async () => { + it('defaultLabel should return a text label', async () => { const el = await fixture(html` Test `); - expect(el.getTextLabel()).to.equal('Test'); + expect(el.defaultLabel).to.equal('Test'); + expect(el.label).to.equal('Test'); + }); + + it('label attribute should override default label', async () => { + const el = await fixture(html` Text content `); + expect(el.defaultLabel).to.equal('Text content'); + expect(el.label).to.equal('Manual label'); }); it('should emit the slotchange event when the label changes', async () => { @@ -107,7 +114,7 @@ describe('', () => { expect(submenuSlot.hidden).to.be.true; }); - it('should render an wa-popup if the slot="submenu" attribute is present', async () => { + it('should render a wa-popup if the slot="submenu" attribute is present', async () => { const menu = await fixture(html` diff --git a/src/components/menu-item/menu-item.ts b/src/components/menu-item/menu-item.ts index c9ea44bb8..3356c1f63 100644 --- a/src/components/menu-item/menu-item.ts +++ b/src/components/menu-item/menu-item.ts @@ -1,9 +1,8 @@ import type { PropertyValues } from 'lit'; import { html } from 'lit'; -import { customElement, property, query } from 'lit/decorators.js'; +import { customElement, property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; -import { getTextContent } from '../../internal/slot.js'; -import { watch } from '../../internal/watch.js'; +import getText from '../../internal/get-text.js'; import WebAwesomeElement from '../../internal/webawesome-element.js'; import { LocalizeController } from '../../utilities/localize.js'; import '../icon/icon.js'; @@ -43,7 +42,6 @@ import { SubmenuController } from './submenu-controller.js'; export default class WaMenuItem extends WebAwesomeElement { static shadowStyle = styles; - private cachedTextLabel: string; private readonly localize = new LocalizeController(this); @query('slot:not([name])') defaultSlot: HTMLSlotElement; @@ -64,6 +62,36 @@ export default class WaMenuItem extends WebAwesomeElement { /** Draws the menu item in a disabled state, preventing selection. */ @property({ type: Boolean, reflect: true }) disabled = false; + _label: string = ''; + /** + * The option’s plain text label. + * Usually automatically generated, but can be useful to provide manually for cases involving complex content. + */ + @property() + set label(value) { + const oldValue = this._label; + this._label = value || ''; + + if (this._label !== oldValue) { + this.requestUpdate('label', oldValue); + } + } + + get label(): string { + if (this._label) { + return this._label; + } + + if (!this.defaultLabel) { + this.updateDefaultLabel(); + } + + return this.defaultLabel; + } + + /** The default label, generated from the element contents. Will be equal to `label` in most cases. */ + @state() defaultLabel = ''; + /** * Used for SSR purposes. If true, will render a ">" caret icon for showing that it has a submenu, but will be non-interactive. */ @@ -75,6 +103,7 @@ export default class WaMenuItem extends WebAwesomeElement { super.connectedCallback(); this.addEventListener('click', this.handleHostClick); this.addEventListener('mouseover', this.handleMouseOver); + this.updateDefaultLabel(); } disconnectedCallback() { @@ -93,17 +122,10 @@ export default class WaMenuItem extends WebAwesomeElement { } private handleDefaultSlotChange() { - const textLabel = this.getTextLabel(); - - // Ignore the first time the label is set - if (typeof this.cachedTextLabel === 'undefined') { - this.cachedTextLabel = textLabel; - return; - } + let labelChanged = this.updateDefaultLabel(); // When the label changes, emit a slotchange event so parent controls see it - if (textLabel !== this.cachedTextLabel) { - this.cachedTextLabel = textLabel; + if (labelChanged) { /** @internal - prevent the CEM from recording this event */ this.dispatchEvent(new Event('slotchange', { bubbles: true, composed: false, cancelable: false })); } @@ -122,41 +144,48 @@ export default class WaMenuItem extends WebAwesomeElement { event.stopPropagation(); }; - @watch('checked') - handleCheckedChange() { - // For proper accessibility, users have to use type="checkbox" to use the checked attribute - if (this.checked && this.type !== 'checkbox') { - this.checked = false; - return; + updated(changedProperties: PropertyValues) { + if (changedProperties.has('checked')) { + // For proper accessibility, users have to use type="checkbox" to use the checked attribute + if (this.checked && this.type !== 'checkbox') { + this.checked = false; + return; + } + + // Only checkbox types can receive the aria-checked attribute + if (this.type === 'checkbox') { + this.setAttribute('aria-checked', this.checked ? 'true' : 'false'); + } else { + this.removeAttribute('aria-checked'); + } } - // Only checkbox types can receive the aria-checked attribute - if (this.type === 'checkbox') { - this.setAttribute('aria-checked', this.checked ? 'true' : 'false'); - } else { - this.removeAttribute('aria-checked'); + if (changedProperties.has('disabled')) { + this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false'); + } + + if (changedProperties.has('type')) { + if (this.type === 'checkbox') { + this.setAttribute('role', 'menuitemcheckbox'); + this.setAttribute('aria-checked', this.checked ? 'true' : 'false'); + } else { + this.setAttribute('role', 'menuitem'); + this.removeAttribute('aria-checked'); + } } } - @watch('disabled') - handleDisabledChange() { - this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false'); - } + private updateDefaultLabel() { + let oldValue = this.defaultLabel; + this.defaultLabel = getText(this).trim(); + let changed = this.defaultLabel !== oldValue; - @watch('type') - handleTypeChange() { - if (this.type === 'checkbox') { - this.setAttribute('role', 'menuitemcheckbox'); - this.setAttribute('aria-checked', this.checked ? 'true' : 'false'); - } else { - this.setAttribute('role', 'menuitem'); - this.removeAttribute('aria-checked'); + if (!this._label && changed) { + // Uses default label, and it has changed + this.requestUpdate('label', oldValue); } - } - /** Returns a text label based on the contents of the menu item's default slot. */ - getTextLabel() { - return getTextContent(this.defaultSlot); + return changed; } isSubmenu() { diff --git a/src/components/option/option.test.ts b/src/components/option/option.test.ts index 307eca961..4f1494337 100644 --- a/src/components/option/option.test.ts +++ b/src/components/option/option.test.ts @@ -23,6 +23,7 @@ describe('', () => { expect(el.value).to.equal(''); expect(el.disabled).to.be.false; + expect(el.label).to.equal('Test'); expect(el.getAttribute('aria-disabled')).to.equal('false'); }); @@ -44,9 +45,16 @@ describe('', () => { expect(el.value).to.equal('10'); }); - it('should escape HTML when calling getTextLabel()', async () => { + it('defaultLabel should escape HTML', async () => { const el = await fixture(html` Option `); - expect(el.getTextLabel()).to.equal('Option'); + expect(el.defaultLabel).to.equal('Option'); + expect(el.label).to.equal('Option'); + }); + + it('label attribute should override default label', async () => { + const el = await fixture(html` Text content `); + expect(el.defaultLabel).to.equal('Text content'); + expect(el.label).to.equal('Manual label'); }); }); } diff --git a/src/components/option/option.ts b/src/components/option/option.ts index 268f0113b..cc67a0831 100644 --- a/src/components/option/option.ts +++ b/src/components/option/option.ts @@ -1,6 +1,7 @@ import type { PropertyValues } from 'lit'; import { html } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; +import getText from '../../internal/get-text.js'; import WebAwesomeElement from '../../internal/webawesome-element.js'; import { LocalizeController } from '../../utilities/localize.js'; import '../icon/icon.js'; @@ -42,7 +43,9 @@ export default class WaOption extends WebAwesomeElement { @query('.label') defaultSlot: HTMLSlotElement; + // Set via the parent select @state() current = false; + @state() selected = false; /** @@ -55,6 +58,36 @@ export default class WaOption extends WebAwesomeElement { /** Draws the option in a disabled state, preventing selection. */ @property({ type: Boolean, reflect: true }) disabled = false; + _label: string = ''; + /** + * The option’s plain text label. + * Usually automatically generated, but can be useful to provide manually for cases involving complex content. + */ + @property() + set label(value) { + const oldValue = this._label; + this._label = value || ''; + + if (this._label !== oldValue) { + this.requestUpdate('label', oldValue); + } + } + + get label(): string { + if (this._label) { + return this._label; + } + + if (!this.defaultLabel) { + this.updateDefaultLabel(); + } + + return this.defaultLabel; + } + + /** The default label, generated from the element contents. Will be equal to `label` in most cases. */ + @state() defaultLabel = ''; + connectedCallback() { super.connectedCallback(); this.setAttribute('role', 'option'); @@ -62,6 +95,7 @@ export default class WaOption extends WebAwesomeElement { this.addEventListener('mouseenter', this.handleHover); this.addEventListener('mouseleave', this.handleHover); + this.updateDefaultLabel(); } disconnectedCallback(): void { @@ -72,6 +106,8 @@ export default class WaOption extends WebAwesomeElement { } private handleDefaultSlotChange() { + this.updateDefaultLabel(); + if (this.isInitialized) { // When the label changes, tell the controller to update customElements.whenDefined('wa-select').then(() => { @@ -126,24 +162,17 @@ export default class WaOption extends WebAwesomeElement { } } - /** Returns a plain text label based on the option's content. */ - getTextLabel() { - const nodes = this.childNodes; - let label = ''; + private updateDefaultLabel() { + let oldValue = this.defaultLabel; + this.defaultLabel = getText(this).trim(); + let changed = this.defaultLabel !== oldValue; - [...nodes].forEach(node => { - if (node.nodeType === Node.ELEMENT_NODE) { - if (!(node as HTMLElement).hasAttribute('slot')) { - label += (node as HTMLElement).textContent; - } - } + if (!this._label && changed) { + // Uses default label, and it has changed + this.requestUpdate('label', oldValue); + } - if (node.nodeType === Node.TEXT_NODE) { - label += node.textContent; - } - }); - - return label.trim(); + return changed; } render() { diff --git a/src/components/select/select.ts b/src/components/select/select.ts index 95f32af9a..bcc287563 100644 --- a/src/components/select/select.ts +++ b/src/components/select/select.ts @@ -252,7 +252,7 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement { removable @wa-remove=${(event: WaRemoveEvent) => this.handleTagRemove(event, option)} > - ${option.getTextLabel()} + ${option.label} `; }; @@ -437,7 +437,7 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement { } for (const option of allOptions) { - const label = option.getTextLabel().toLowerCase(); + const label = option.label.toLowerCase(); if (label.startsWith(this.typeToSelectString)) { this.setCurrentOption(option); @@ -642,7 +642,7 @@ export default class WaSelect extends WebAwesomeFormAssociatedElement { } else { const selectedOption = this.selectedOptions[0]; this.value = selectedOption?.value ?? ''; - this.displayLabel = selectedOption?.getTextLabel?.() ?? ''; + this.displayLabel = selectedOption?.label ?? ''; } // Update validity diff --git a/src/internal/get-text.ts b/src/internal/get-text.ts new file mode 100644 index 000000000..63abf3fb6 --- /dev/null +++ b/src/internal/get-text.ts @@ -0,0 +1,46 @@ +/** + * Like textContent, but better: + * - Uses assignedNodes to get text content from slots (and falls back to content if nothing is slotted) + * - Ignores script and style elements + * @param root - One or more nodes to get text content from. + * @param depth - By default, will just return element.textContent for any child elements instead of calling the function recursively. + * Set to a positive integer to recurse that many levels. Generally a tradeoff between performance and accuracy. + * @returns + */ +export default function getText(root: Node | Iterable, depth = 0): string { + if (!root || !globalThis.Node) { + return ''; + } + + if (typeof (root as any)[Symbol.iterator] === 'function') { + let nodes = Array.isArray(root) ? root : [...(root as Iterable)]; + return nodes.map(node => getText(node, --depth)).join(''); + } + + let node = root as Node; + + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent ?? ''; + } + + if (node.nodeType === Node.ELEMENT_NODE) { + let element = node as HTMLElement; + + if (element.hasAttribute('slot') || element.matches('style, script')) { + return ''; + } + + if (element instanceof HTMLSlotElement) { + let assignedNodes = element.assignedNodes({ flatten: true }); + + if (assignedNodes.length > 0) { + // If no assigned nodes, we still want the slot contents + return getText(assignedNodes, --depth); + } + } + + return depth > -1 ? getText(element, --depth) : (element.textContent ?? ''); + } + + return node.hasChildNodes() ? getText(node.childNodes, --depth) : ''; +} diff --git a/src/internal/slot.ts b/src/internal/slot.ts index a52ce5b40..a11fdcec0 100644 --- a/src/internal/slot.ts +++ b/src/internal/slot.ts @@ -12,11 +12,11 @@ export class HasSlotController implements ReactiveController { private hasDefaultSlot() { return [...this.host.childNodes].some(node => { - if (node.nodeType === node.TEXT_NODE && node.textContent!.trim() !== '') { + if (node.nodeType === Node.TEXT_NODE && node.textContent!.trim() !== '') { return true; } - if (node.nodeType === node.ELEMENT_NODE) { + if (node.nodeType === Node.ELEMENT_NODE) { const el = node as HTMLElement; const tagName = el.tagName.toLowerCase(); @@ -90,23 +90,3 @@ export function getInnerHTML(nodes: Iterable, callback?: (node: Node) => s return html; } - -/** - * Given a slot, this function iterates over all of its assigned text nodes and returns the concatenated text as a - * string. This is useful because we can't use slot.textContent as an alternative. - */ -export function getTextContent(slot: HTMLSlotElement | undefined | null): string { - if (!slot) { - return ''; - } - const nodes = slot.assignedNodes({ flatten: true }); - let text = ''; - - [...nodes].forEach(node => { - if (node.nodeType === Node.TEXT_NODE) { - text += node.textContent; - } - }); - - return text; -}