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;
-}