diff --git a/docs/resources/changelog.md b/docs/resources/changelog.md index abf8a252..aa16dca5 100644 --- a/docs/resources/changelog.md +++ b/docs/resources/changelog.md @@ -19,6 +19,7 @@ _During the beta period, these restrictions may be relaxed in the event of a mis - Improved `` track color when used on various backgrounds - Improved a11y in `` so VoiceOver announces radios properly in a radio group - Improved the API for the experimental `` component by making `position` accept a percentage and adding the `position-in-pixels` attribute +- Refactored ``, ``, ``, ``, ``, ``, ``, ``, and `` to use a Reactive Controller for slot detection - Refactored internal id usage in ``, ``, ``, and `` - Removed `position: relative` from the common component stylesheet diff --git a/src/components/breadcrumb-item/breadcrumb-item.ts b/src/components/breadcrumb-item/breadcrumb-item.ts index 55a911a6..1e58c544 100644 --- a/src/components/breadcrumb-item/breadcrumb-item.ts +++ b/src/components/breadcrumb-item/breadcrumb-item.ts @@ -1,8 +1,8 @@ import { LitElement, html } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; -import { hasSlot } from '../../internal/slot'; +import { HasSlotController } from '../../internal/slot'; import styles from './breadcrumb-item.styles'; /** @@ -25,8 +25,7 @@ import styles from './breadcrumb-item.styles'; export default class SlBreadcrumbItem extends LitElement { static styles = styles; - @state() hasPrefix = false; - @state() hasSuffix = false; + private hasSlotController = new HasSlotController(this, ['prefix', 'suffix']); /** * Optional URL to direct the user to when the breadcrumb item is activated. When set, a link will be rendered @@ -40,11 +39,6 @@ export default class SlBreadcrumbItem extends LitElement { /** The `rel` attribute to use on the link. Only used when `href` is set. */ @property() rel: string = 'noreferrer noopener'; - handleSlotChange() { - this.hasPrefix = hasSlot(this, 'prefix'); - this.hasSuffix = hasSlot(this, 'suffix'); - } - render() { const isLink = this.href ? true : false; @@ -53,12 +47,12 @@ export default class SlBreadcrumbItem extends LitElement { part="base" class=${classMap({ 'breadcrumb-item': true, - 'breadcrumb-item--has-prefix': this.hasPrefix, - 'breadcrumb-item--has-suffix': this.hasSuffix + 'breadcrumb-item--has-prefix': this.hasSlotController.test('prefix'), + 'breadcrumb-item--has-suffix': this.hasSlotController.test('suffix') })} > - + ${isLink @@ -80,7 +74,7 @@ export default class SlBreadcrumbItem extends LitElement { `} - + ` diff --git a/src/components/textarea/textarea.ts b/src/components/textarea/textarea.ts index 6fe83255..6b0564d2 100644 --- a/src/components/textarea/textarea.ts +++ b/src/components/textarea/textarea.ts @@ -6,7 +6,7 @@ import { emit } from '../../internal/event'; import { live } from 'lit/directives/live.js'; import { watch } from '../../internal/watch'; import { getLabelledBy, renderFormControl } from '../../internal/form-control'; -import { hasSlot } from '../../internal/slot'; +import { HasSlotController } from '../../internal/slot'; import styles from './textarea.styles'; let id = 0; @@ -35,14 +35,13 @@ export default class SlTextarea extends LitElement { @query('.textarea__control') input: HTMLTextAreaElement; + private hasSlotController = new HasSlotController(this, ['help-text', 'label']); private inputId = `textarea-${++id}`; private helpTextId = `textarea-help-text-${id}`; private labelId = `textarea-label-${id}`; private resizeObserver: ResizeObserver; @state() private hasFocus = false; - @state() private hasHelpTextSlot = false; - @state() private hasLabelSlot = false; /** The textarea's size. */ @property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium'; @@ -115,10 +114,7 @@ export default class SlTextarea extends LitElement { connectedCallback() { super.connectedCallback(); - this.handleSlotChange = this.handleSlotChange.bind(this); this.resizeObserver = new ResizeObserver(() => this.setTextareaHeight()); - this.shadowRoot!.addEventListener('slotchange', this.handleSlotChange); - this.handleSlotChange(); this.updateComplete.then(() => { this.setTextareaHeight(); @@ -133,7 +129,6 @@ export default class SlTextarea extends LitElement { disconnectedCallback() { super.disconnectedCallback(); this.resizeObserver.unobserve(this.input); - this.shadowRoot!.removeEventListener('slotchange', this.handleSlotChange); } /** Sets focus on the textarea. */ @@ -243,13 +238,6 @@ export default class SlTextarea extends LitElement { this.setTextareaHeight(); } - @watch('helpText') - @watch('label') - handleSlotChange() { - this.hasHelpTextSlot = hasSlot(this, 'help-text'); - this.hasLabelSlot = hasSlot(this, 'label'); - } - @watch('value') handleValueChange() { if (this.input) { @@ -269,15 +257,18 @@ export default class SlTextarea extends LitElement { } render() { + const hasLabelSlot = this.hasSlotController.test('label'); + const hasHelpTextSlot = this.hasSlotController.test('help-text'); + return renderFormControl( { inputId: this.inputId, label: this.label, labelId: this.labelId, - hasLabelSlot: this.hasLabelSlot, + hasLabelSlot, helpTextId: this.helpTextId, helpText: this.helpText, - hasHelpTextSlot: this.hasHelpTextSlot, + hasHelpTextSlot, size: this.size }, html` @@ -321,10 +312,10 @@ export default class SlTextarea extends LitElement { getLabelledBy({ label: this.label, labelId: this.labelId, - hasLabelSlot: this.hasLabelSlot, + hasLabelSlot, helpText: this.helpText, helpTextId: this.helpTextId, - hasHelpTextSlot: this.hasHelpTextSlot + hasHelpTextSlot }) )} @change=${this.handleChange} diff --git a/src/internal/slot.ts b/src/internal/slot.ts index c30a77a7..b5ab3dd1 100644 --- a/src/internal/slot.ts +++ b/src/internal/slot.ts @@ -1,3 +1,57 @@ +import { ReactiveController, ReactiveControllerHost } from 'lit'; + +export class HasSlotController implements ReactiveController { + host: ReactiveControllerHost & Element; + slotNames: string[] = []; + + constructor(host: ReactiveControllerHost & Element, slotNames: string[] = []) { + (this.host = host).addController(this); + this.slotNames = slotNames; + this.handleSlotChange = this.handleSlotChange.bind(this); + } + + private hasDefaultSlot() { + return [...this.host.childNodes].some(node => { + if (node.nodeType === node.TEXT_NODE && node.textContent!.trim() !== '') { + return true; + } + + if (node.nodeType === node.ELEMENT_NODE) { + const el = node as HTMLElement; + if (!el.hasAttribute('slot')) { + return true; + } + } + + return false; + }); + } + + private hasNamedSlot(name: string) { + return this.host.querySelector(`:scope > [slot="${name}"]`) !== null; + } + + test(slotName: string) { + return slotName === '[default]' ? this.hasDefaultSlot() : this.hasNamedSlot(slotName); + } + + hostConnected() { + this.host.shadowRoot!.addEventListener('slotchange', this.handleSlotChange); + } + + hostDisconnected() { + this.host.shadowRoot!.removeEventListener('slotchange', this.handleSlotChange); + } + + handleSlotChange(event: Event) { + const slot = event.target as HTMLSlotElement; + + if ((this.slotNames.includes('[default]') && !slot.name) || (slot.name && this.slotNames?.includes(slot.name))) { + this.host.requestUpdate(); + } + } +} + // // Given a slot, this function iterates over all of its assigned element and text nodes and returns the concatenated // HTML as a string. This is useful because we can't use slot.innerHTML as an alternative. @@ -35,30 +89,3 @@ export function getTextContent(slot: HTMLSlotElement): string { return text; } - -// -// Determines whether an element has a slot. If name is specified, the function will look for a corresponding named -// slot, otherwise it will look for a "default" slot (e.g. a non-empty text node or an element with no slot attribute). -// -export function hasSlot(el: HTMLElement, name?: string) { - // Look for a named slot - if (name) { - return el.querySelector(`:scope > [slot="${name}"]`) !== null; - } - - // Look for a default slot - return [...el.childNodes].some(node => { - if (node.nodeType === node.TEXT_NODE && node.textContent!.trim() !== '') { - return true; - } - - if (node.nodeType === node.ELEMENT_NODE) { - const el = node as HTMLElement; - if (!el.hasAttribute('slot')) { - return true; - } - } - - return false; - }); -}