diff --git a/src/declarations/stencil-private.ts b/src/declarations/stencil-private.ts index 0dfb7b9995d..6ff17da01e9 100644 --- a/src/declarations/stencil-private.ts +++ b/src/declarations/stencil-private.ts @@ -1440,7 +1440,7 @@ export interface RenderNode extends HostElement { /** * Node reference: - * This is a reference for a original location node + * This is a reference from an original location node * back to the node that's been moved around. */ ['s-nr']?: PatchedSlotNode | RenderNode; diff --git a/src/runtime/client-hydrate.ts b/src/runtime/client-hydrate.ts index 0bc1360be66..603a84770d5 100644 --- a/src/runtime/client-hydrate.ts +++ b/src/runtime/client-hydrate.ts @@ -16,7 +16,7 @@ import { TEXT_NODE_ID, VNODE_FLAGS, } from './runtime-constants'; -import { addSlotRelocateNode } from './slot-polyfill-utils'; +import { addSlotRelocateNode, patchSlotNode } from './slot-polyfill-utils'; import { newVNode } from './vdom/h'; /** @@ -615,6 +615,7 @@ function addSlot( // attempt to find any mock slotted nodes which we'll move later addSlottedNodes(slottedNodes, slotId, slotName, node, shouldMove ? parentNodeId : childVNode.$hostId$); + patchSlotNode(node); if (shouldMove) { // Move slot comment node (to after any other comment nodes) diff --git a/src/runtime/dom-extras.ts b/src/runtime/dom-extras.ts index 57c010fd561..8120553b653 100644 --- a/src/runtime/dom-extras.ts +++ b/src/runtime/dom-extras.ts @@ -4,8 +4,10 @@ import { supportsShadow } from '@platform'; import type * as d from '../declarations'; import { addSlotRelocateNode, - getHostSlotChildNodes, + dispatchSlotChangeEvent, + findSlotFromSlottedNode, getHostSlotNodes, + getSlotChildSiblings, getSlotName, getSlottedChildNodes, updateFallbackSlotVisibility, @@ -90,22 +92,18 @@ export const patchCloneNode = (HostElementPrototype: HTMLElement) => { */ export const patchSlotAppendChild = (HostElementPrototype: any) => { HostElementPrototype.__appendChild = HostElementPrototype.appendChild; + HostElementPrototype.appendChild = function (this: d.RenderNode, newChild: d.RenderNode) { - const slotName = (newChild['s-sn'] = getSlotName(newChild)); - const slotNode = getHostSlotNodes((this as any).__childNodes || this.childNodes, this.tagName, slotName)[0]; + const { slotName, slotNode } = findSlotFromSlottedNode(newChild, this); if (slotNode) { addSlotRelocateNode(newChild, slotNode); - const slotChildNodes = getHostSlotChildNodes(slotNode, slotName); + const slotChildNodes = getSlotChildSiblings(slotNode, slotName); const appendAfter = slotChildNodes[slotChildNodes.length - 1]; - const parent = intrnlCall(appendAfter, 'parentNode') as d.RenderNode; - let insertedNode: d.RenderNode; - if (parent.__insertBefore) { - insertedNode = parent.__insertBefore(newChild, appendAfter.nextSibling); - } else { - insertedNode = parent.insertBefore(newChild, appendAfter.nextSibling); - } + const parent = internalCall(appendAfter, 'parentNode') as d.RenderNode; + const insertedNode: d.RenderNode = internalCall(parent, 'insertBefore')(newChild, appendAfter.nextSibling); + dispatchSlotChangeEvent(slotNode); // Check if there is fallback content that should be hidden updateFallbackSlotVisibility(this); @@ -155,20 +153,18 @@ export const patchSlotPrepend = (HostElementPrototype: HTMLElement) => { if (typeof newChild === 'string') { newChild = this.ownerDocument.createTextNode(newChild) as unknown as d.RenderNode; } - const slotName = (newChild['s-sn'] = getSlotName(newChild)); - const childNodes = (this as any).__childNodes || this.childNodes; + const slotName = (newChild['s-sn'] = getSlotName(newChild)) || ''; + const childNodes = internalCall(this, 'childNodes'); const slotNode = getHostSlotNodes(childNodes, this.tagName, slotName)[0]; if (slotNode) { addSlotRelocateNode(newChild, slotNode, true); - const slotChildNodes = getHostSlotChildNodes(slotNode, slotName); + const slotChildNodes = getSlotChildSiblings(slotNode, slotName); const appendAfter = slotChildNodes[0]; - const parent = intrnlCall(appendAfter, 'parentNode') as d.RenderNode; - if (parent.__insertBefore) { - return parent.__insertBefore(newChild, intrnlCall(appendAfter, 'nextSibling')); - } else { - return parent.insertBefore(newChild, intrnlCall(appendAfter, 'nextSibling')); - } + const parent = internalCall(appendAfter, 'parentNode') as d.RenderNode; + const toReturn = internalCall(parent, 'insertBefore')(newChild, internalCall(appendAfter, 'nextSibling')); + dispatchSlotChangeEvent(slotNode); + return toReturn; } if (newChild.nodeType === 1 && !!newChild.getAttribute('slot')) { @@ -263,8 +259,7 @@ const patchInsertBefore = (HostElementPrototype: HTMLElement) => { newChild: T, currentChild: d.RenderNode | null, ) { - const slotName = (newChild['s-sn'] = getSlotName(newChild)); - const slotNode = getHostSlotNodes(this.__childNodes, this.tagName, slotName)[0]; + const { slotName, slotNode } = findSlotFromSlottedNode(newChild, this); const slottedNodes = this.__childNodes ? this.childNodes : getSlottedChildNodes(this.childNodes); if (slotNode) { @@ -286,13 +281,10 @@ const patchInsertBefore = (HostElementPrototype: HTMLElement) => { // current child ('slot before' node) is 'in' the same slot addSlotRelocateNode(newChild, slotNode); - const parent = intrnlCall(currentChild, 'parentNode') as d.RenderNode; - if (parent.__insertBefore) { - // the parent is a patched component, so we need to use the internal method - parent.__insertBefore(newChild, currentChild); - } else { - parent.insertBefore(newChild, currentChild); - } + const parent = internalCall(currentChild, 'parentNode') as d.RenderNode; + internalCall(parent, 'insertBefore')(newChild, currentChild); + + dispatchSlotChangeEvent(slotNode); } return; } @@ -432,7 +424,7 @@ export const patchChildSlotNodes = (elm: HTMLElement) => { * @param node the slotted node to be patched */ export const patchSlottedNode = (node: Node) => { - if (!node || (node as any).__nextSibling || !globalThis.Node) return; + if (!node || (node as any).__nextSibling !== undefined || !globalThis.Node) return; patchNextSibling(node); patchPreviousSibling(node); @@ -595,10 +587,13 @@ function patchHostOriginalAccessor( * * @returns the original accessor or method of the node */ -function intrnlCall(node: T, method: P): T[P] { +export function internalCall(node: T, method: P): T[P] { if ('__' + method in node) { - return node[('__' + method) as keyof d.RenderNode] as T[P]; + const toReturn = node[('__' + method) as keyof d.RenderNode] as T[P]; + if (typeof toReturn !== 'function') return toReturn; + return toReturn.bind(node) as T[P]; } else { - return node[method]; + if (typeof node[method] !== 'function') return node[method]; + return node[method].bind(node) as T[P]; } } diff --git a/src/runtime/slot-polyfill-utils.ts b/src/runtime/slot-polyfill-utils.ts index 275f5d92fdd..9c7264e330c 100644 --- a/src/runtime/slot-polyfill-utils.ts +++ b/src/runtime/slot-polyfill-utils.ts @@ -1,11 +1,12 @@ import { BUILD } from '@app-data'; import type * as d from '../declarations'; +import { internalCall } from './dom-extras'; import { NODE_TYPE } from './runtime-constants'; /** * Adjust the `.hidden` property as-needed on any nodes in a DOM subtree which - * are slot fallbacks nodes - `...` + * are slot fallback nodes - `...` * * A slot fallback node should be visible by default. Then, it should be * conditionally hidden if: @@ -17,7 +18,7 @@ import { NODE_TYPE } from './runtime-constants'; * @param elm the element of interest */ export const updateFallbackSlotVisibility = (elm: d.RenderNode) => { - const childNodes: d.RenderNode[] = elm.__childNodes || (elm.childNodes as any); + const childNodes = internalCall(elm, 'childNodes'); // is this is a stencil component? if (elm.tagName && elm.tagName.includes('-') && elm['s-cr'] && elm.tagName !== 'SLOT-FB') { @@ -25,7 +26,7 @@ export const updateFallbackSlotVisibility = (elm: d.RenderNode) => { getHostSlotNodes(childNodes as any, (elm as HTMLElement).tagName).forEach((slotNode) => { if (slotNode.nodeType === NODE_TYPE.ElementNode && slotNode.tagName === 'SLOT-FB') { // this is a slot fallback node - if (getHostSlotChildNodes(slotNode, slotNode['s-sn'], false)?.length) { + if (getSlotChildSiblings(slotNode, getSlotName(slotNode), false)?.length) { // has slotted nodes, hide fallback slotNode.hidden = true; } else { @@ -35,8 +36,11 @@ export const updateFallbackSlotVisibility = (elm: d.RenderNode) => { } }); } - for (const childNode of childNodes) { - if (childNode.nodeType === NODE_TYPE.ElementNode && (childNode.__childNodes || childNode.childNodes).length) { + + let i = 0; + for (i = 0; i < childNodes.length; i++) { + const childNode = childNodes[i] as d.RenderNode; + if (childNode.nodeType === NODE_TYPE.ElementNode && internalCall(childNode, 'childNodes').length) { // keep drilling down updateFallbackSlotVisibility(childNode); } @@ -54,7 +58,7 @@ export const updateFallbackSlotVisibility = (elm: d.RenderNode) => { * @returns An array of slotted reference nodes. */ export const getSlottedChildNodes = (childNodes: NodeListOf): d.PatchedSlotNode[] => { - const result = []; + const result: d.PatchedSlotNode[] = []; for (let i = 0; i < childNodes.length; i++) { const slottedNode = ((childNodes[i] as d.RenderNode)['s-nr'] as d.PatchedSlotNode) || undefined; if (slottedNode && slottedNode.isConnected) { @@ -71,7 +75,7 @@ export const getSlottedChildNodes = (childNodes: NodeListOf): d.Patch * @param slotName the name of the slot to match on. * @returns a reference to the slot node that matches the provided name, `null` otherwise */ -export function getHostSlotNodes(childNodes: NodeListOf, hostName: string, slotName?: string) { +export function getHostSlotNodes(childNodes: NodeListOf, hostName?: string, slotName?: string) { let i = 0; let slottedNodes: d.RenderNode[] = []; let childNode: d.RenderNode; @@ -80,8 +84,8 @@ export function getHostSlotNodes(childNodes: NodeListOf, hostName: st childNode = childNodes[i] as any; if ( childNode['s-sr'] && - childNode['s-hn'] === hostName && - (slotName === undefined || childNode['s-sn'] === slotName) + (!hostName || childNode['s-hn'] === hostName) && + (slotName === undefined || getSlotName(childNode) === slotName) ) { slottedNodes.push(childNode); if (typeof slotName !== 'undefined') return slottedNodes; @@ -92,18 +96,19 @@ export function getHostSlotNodes(childNodes: NodeListOf, hostName: st } /** - * Get slotted child nodes of a slot node - * @param node - the slot node to get the child nodes from + * Get all 'child' sibling nodes of a slot node + * @param slot - the slot node to get the child nodes from * @param slotName - the name of the slot to match on * @param includeSlot - whether to include the slot node in the result - * @returns slotted child nodes of the slot node + * @returns child nodes of the slot node */ -export const getHostSlotChildNodes = (node: d.RenderNode, slotName: string, includeSlot = true) => { +export const getSlotChildSiblings = (slot: d.RenderNode, slotName: string, includeSlot = true) => { const childNodes: d.RenderNode[] = []; - if ((includeSlot && node['s-sr']) || !node['s-sr']) childNodes.push(node as any); + if ((includeSlot && slot['s-sr']) || !slot['s-sr']) childNodes.push(slot as any); + let node = slot; - while ((node = node.nextSibling as any) && (node as d.RenderNode)['s-sn'] === slotName) { - childNodes.push(node as any); + while ((node = node.nextSibling as any)) { + if (getSlotName(node) === slotName) childNodes.push(node as any); } return childNodes; }; @@ -150,37 +155,34 @@ export const addSlotRelocateNode = ( prepend?: boolean, position?: number, ) => { - let slottedNodeLocation: d.RenderNode; - - // does newChild already have a slot location node? if (newChild['s-ol'] && newChild['s-ol'].isConnected) { - slottedNodeLocation = newChild['s-ol']; - } else { - slottedNodeLocation = document.createTextNode('') as any; - slottedNodeLocation['s-nr'] = newChild; + // newChild already has a slot location node + return; } + const slottedNodeLocation = document.createTextNode('') as any; + slottedNodeLocation['s-nr'] = newChild; + + // if there's no content reference node, or parentNode we can't do anything if (!slotNode['s-cr'] || !slotNode['s-cr'].parentNode) return; const parent = slotNode['s-cr'].parentNode as any; - const appendMethod = prepend ? parent.__prepend || parent.prepend : parent.__appendChild || parent.appendChild; - - if (typeof position !== 'undefined') { - if (BUILD.hydrateClientSide) { - slottedNodeLocation['s-oo'] = position; - const childNodes = (parent.__childNodes || parent.childNodes) as NodeListOf; - const slotRelocateNodes: d.RenderNode[] = [slottedNodeLocation]; - childNodes.forEach((n) => { - if (n['s-nr']) slotRelocateNodes.push(n); - }); + const appendMethod = prepend ? internalCall(parent, 'prepend') : internalCall(parent, 'appendChild'); - slotRelocateNodes.sort((a, b) => { - if (!a['s-oo'] || a['s-oo'] < b['s-oo']) return -1; - else if (!b['s-oo'] || b['s-oo'] < a['s-oo']) return 1; - return 0; - }); - slotRelocateNodes.forEach((n) => appendMethod.call(parent, n)); - } + if (BUILD.hydrateClientSide && typeof position !== 'undefined') { + slottedNodeLocation['s-oo'] = position; + const childNodes = internalCall(parent, 'childNodes') as NodeListOf; + const slotRelocateNodes: d.RenderNode[] = [slottedNodeLocation]; + childNodes.forEach((n) => { + if (n['s-nr']) slotRelocateNodes.push(n); + }); + + slotRelocateNodes.sort((a, b) => { + if (!a['s-oo'] || a['s-oo'] < (b['s-oo'] || 0)) return -1; + else if (!b['s-oo'] || b['s-oo'] < a['s-oo']) return 1; + return 0; + }); + slotRelocateNodes.forEach((n) => appendMethod.call(parent, n)); } else { appendMethod.call(parent, slottedNodeLocation); } @@ -190,4 +192,75 @@ export const addSlotRelocateNode = ( }; export const getSlotName = (node: d.PatchedSlotNode) => - node['s-sn'] || (node.nodeType === 1 && (node as Element).getAttribute('slot')) || ''; + typeof node['s-sn'] === 'string' + ? node['s-sn'] + : (node.nodeType === 1 && (node as Element).getAttribute('slot')) || undefined; + +/** + * Add `assignedElements` and `assignedNodes` methods on a fake slot node + * + * @param node - slot node to patch + */ +export function patchSlotNode(node: d.RenderNode) { + if ((node as any).assignedElements || (node as any).assignedNodes || !node['s-sr']) return; + + const assignedFactory = (elementsOnly: boolean) => + function (opts?: { flatten: boolean }) { + const toReturn: d.RenderNode[] = []; + const slotName = this['s-sn']; + + if (opts?.flatten) { + console.error(` + Flattening is not supported for Stencil non-shadow slots. + You can use \`.childNodes\` to nested slot fallback content. + If you have a particular use case, please open an issue on the Stencil repo. + `); + } + + const parent = this['s-cr'].parentElement as d.RenderNode; + // get all light dom nodes + const slottedNodes = parent.__childNodes ? parent.childNodes : getSlottedChildNodes(parent.childNodes); + + (slottedNodes as d.RenderNode[]).forEach((n) => { + // find all the nodes assigned to slots we care about + if (slotName === getSlotName(n)) { + toReturn.push(n); + } + }); + + if (elementsOnly) { + return toReturn.filter((n) => n.nodeType === NODE_TYPE.ElementNode); + } + return toReturn; + }.bind(node); + + (node as any).assignedElements = assignedFactory(true); + (node as any).assignedNodes = assignedFactory(false); +} + +/** + * Dispatches a `slotchange` event on a fake `` node. + * + * @param elm the slot node to dispatch the event from + */ +export function dispatchSlotChangeEvent(elm: d.RenderNode) { + elm.dispatchEvent(new CustomEvent('slotchange', { bubbles: false, cancelable: false, composed: false })); +} + +/** + * Find the slot node that a slotted node belongs to + * + * @param slottedNode - the slotted node to find the slot for + * @param parentHost - the parent host element of the slotted node + * @returns the slot node and slot name + */ +export function findSlotFromSlottedNode(slottedNode: d.PatchedSlotNode, parentHost?: HTMLElement) { + parentHost = parentHost || slottedNode['s-ol']?.parentElement; + + if (!parentHost) return { slotNode: null, slotName: '' }; + + const slotName = (slottedNode['s-sn'] = getSlotName(slottedNode) || ''); + const childNodes = internalCall(parentHost, 'childNodes'); + const slotNode = getHostSlotNodes(childNodes, parentHost.tagName, slotName)[0]; + return { slotNode, slotName }; +} diff --git a/src/runtime/vdom/set-accessor.ts b/src/runtime/vdom/set-accessor.ts index 1b64c805fda..53b7e755ee0 100644 --- a/src/runtime/vdom/set-accessor.ts +++ b/src/runtime/vdom/set-accessor.ts @@ -12,7 +12,7 @@ import { isMemberInElement, plt, win } from '@platform'; import { isComplexType } from '@utils'; import type * as d from '../../declarations'; -import { VNODE_FLAGS, XLINK_NS } from '../runtime-constants'; +import { NODE_TYPE, VNODE_FLAGS, XLINK_NS } from '../runtime-constants'; /** * When running a VDom render set properties present on a VDom node onto the @@ -189,7 +189,11 @@ export const setAccessor = ( elm.removeAttribute(memberName); } } - } else if ((!isProp || flags & VNODE_FLAGS.isHost || isSvg) && !isComplex) { + } else if ( + (!isProp || flags & VNODE_FLAGS.isHost || isSvg) && + !isComplex && + elm.nodeType === NODE_TYPE.ElementNode + ) { newValue = newValue === true ? '' : newValue; if (BUILD.vdomXlink && xlink) { elm.setAttributeNS(XLINK_NS, memberName, newValue); diff --git a/src/runtime/vdom/vdom-render.ts b/src/runtime/vdom/vdom-render.ts index 79cd7f16a2e..82c926fe761 100644 --- a/src/runtime/vdom/vdom-render.ts +++ b/src/runtime/vdom/vdom-render.ts @@ -13,7 +13,13 @@ import { CMP_FLAGS, HTML_NS, isDef, NODE_TYPES, SVG_NS } from '@utils'; import type * as d from '../../declarations'; import { patchParentNode } from '../dom-extras'; import { NODE_TYPE, PLATFORM_FLAGS, VNODE_FLAGS } from '../runtime-constants'; -import { isNodeLocatedInSlot, updateFallbackSlotVisibility } from '../slot-polyfill-utils'; +import { + dispatchSlotChangeEvent, + findSlotFromSlottedNode, + isNodeLocatedInSlot, + patchSlotNode, + updateFallbackSlotVisibility, +} from '../slot-polyfill-utils'; import { h, isHost, newVNode } from './h'; import { updateElement } from './update-element'; @@ -73,6 +79,10 @@ const createElm = (oldParentVNode: d.VNode, newParentVNode: d.VNode, childIndex: // create a slot reference node elm = newVNode.$elm$ = BUILD.isDebug || BUILD.hydrateServerSide ? slotReferenceDebugNode(newVNode) : (doc.createTextNode('') as any); + // add css classes, attrs, props, listeners, etc. + if (BUILD.vdomAttribute) { + updateElement(null, newVNode, isSvgMode); + } } else { if (BUILD.svg && !isSvgMode) { isSvgMode = newVNode.$tag$ === 'svg'; @@ -147,6 +157,9 @@ const createElm = (oldParentVNode: d.VNode, newParentVNode: d.VNode, childIndex: // remember the ref callback function elm['s-rf'] = newVNode.$attrs$?.ref; + // give this node `assignedElements` and `assignedNodes` methods + patchSlotNode(elm); + // check if we've got an old vnode for this slot oldVNode = oldParentVNode && oldParentVNode.$children$ && oldParentVNode.$children$[childIndex]; if (oldVNode && oldVNode.$tag$ === newVNode.$tag$ && oldParentVNode.$elm$) { @@ -680,12 +693,11 @@ export const patch = (oldVNode: d.VNode, newVNode: d.VNode, isInitialRender = fa newVNode.$elm$['s-sn'] = newVNode.$name$ || ''; relocateToHostRoot(newVNode.$elm$.parentElement); } - } else { - // either this is the first render of an element OR it's an update - // AND we already know it's possible it could have changed - // this updates the element's css classes, attrs, props, listeners, etc. - updateElement(oldVNode, newVNode, isSvgMode, isInitialRender); } + // either this is the first render of an element OR it's an update + // AND we already know it's possible it could have changed + // this updates the element's css classes, attrs, props, listeners, etc. + updateElement(oldVNode, newVNode, isSvgMode, isInitialRender); } if (BUILD.updatable && oldChildren !== null && newChildren !== null) { @@ -869,7 +881,13 @@ export const insertBefore = ( patchParentNode(newNode); } // potentially use the patched insertBefore method. This will correctly slot the new node - return parent.insertBefore(newNode, reference); + parent.insertBefore(newNode, reference); + + // if we find a corresponding slot node, dispatch a slotchange event now + const { slotNode } = findSlotFromSlottedNode(newNode); + if (slotNode) dispatchSlotChangeEvent(slotNode); + + return newNode; } if (BUILD.experimentalSlotFixes && (parent as d.RenderNode).__insertBefore) { @@ -1139,8 +1157,7 @@ render() { } } } - - nodeToRelocate && typeof slotRefNode['s-rf'] === 'function' && slotRefNode['s-rf'](nodeToRelocate); + nodeToRelocate && typeof slotRefNode['s-rf'] === 'function' && slotRefNode['s-rf'](slotRefNode); } else { // this node doesn't have a slot home to go to, so let's hide it if (nodeToRelocate.nodeType === NODE_TYPE.ElementNode) { diff --git a/test/wdio/scoped-slot-assigned-methods/cmp.test.tsx b/test/wdio/scoped-slot-assigned-methods/cmp.test.tsx new file mode 100644 index 00000000000..c175b36646e --- /dev/null +++ b/test/wdio/scoped-slot-assigned-methods/cmp.test.tsx @@ -0,0 +1,119 @@ +import { Fragment, h } from '@stencil/core'; +import { render } from '@wdio/browser-runner/stencil'; +import { $, expect } from '@wdio/globals'; + +describe('scoped-slot-assigned-methods', () => { + let originalConsoleError: typeof console.error; + + beforeEach(async () => { + // @ts-expect-error - no components array? + render({ + template: () => ( + + <> +

My initial slotted content.

+ Plain text +
Plain slot content.
+ +
+ ), + }); + await $('scoped-slot-assigned-methods div').waitForExist(); + }); + + before(async () => { + originalConsoleError = console.error; + }); + + after(() => { + console.error = originalConsoleError; + }); + + it('tests assignedElements method on a ``', async () => { + const errorLogs: string[] = []; + console.error = (message) => errorLogs.push(message); + + const component: any = document.querySelector('scoped-slot-assigned-methods'); + expect(component.getSlotAssignedElements).toBeDefined(); + + let nodes = await component.getSlotAssignedElements(); + expect(nodes).toBeDefined(); + expect(nodes.length).toBe(1); + expect(nodes[0].outerHTML).toBe('

My initial slotted content.

'); + component.removeChild(nodes[0]); + + expect(await component.getSlotAssignedElements()).toHaveLength(0); + nodes = await component.getSlotAssignedElements({ flatten: true }); + expect(nodes).toHaveLength(0); + expect(errorLogs.length).toEqual(1); + + const div = document.createElement('div'); + div.slot = 'nested-slot'; + div.textContent = 'Nested slotted content'; + component.appendChild(div); + expect(await component.getSlotAssignedElements()).toHaveLength(0); + + nodes = await component.getSlotAssignedElements({ flatten: true }); + expect(nodes).toHaveLength(0); + expect(errorLogs.length).toEqual(2); + }); + + it('tests assignedNodes method on a ``', async () => { + const errorLogs: string[] = []; + console.error = (message) => errorLogs.push(message); + + const component: any = document.querySelector('scoped-slot-assigned-methods'); + expect(component.getSlotAssignedNodes).toBeDefined(); + + let nodes = await component.getSlotAssignedNodes(); + expect(nodes).toBeDefined(); + expect(nodes.length).toBe(2); + expect(nodes[0].outerHTML).toBe('

My initial slotted content.

'); + component.removeChild(nodes[0]); + + nodes = await component.getSlotAssignedNodes(); + expect(nodes).toHaveLength(1); + expect(nodes[0].nodeValue).toBe('Plain text'); + component.removeChild(nodes[0]); + expect(await component.getSlotAssignedNodes()).toHaveLength(0); + + nodes = await component.getSlotAssignedNodes({ flatten: true }); + expect(nodes).toHaveLength(0); + expect(errorLogs.length).toEqual(1); + + const div = document.createElement('div'); + div.slot = 'nested-slot'; + div.textContent = 'Nested slotted content'; + component.appendChild(div); + expect(await component.getSlotAssignedNodes()).toHaveLength(0); + + nodes = await component.getSlotAssignedNodes({ flatten: true }); + expect(nodes).toHaveLength(0); + expect(errorLogs.length).toEqual(2); + }); + + it('tests assignedElements / assignedNodes method on a plain slot (a text / comment node)', async () => { + const errorLogs: string[] = []; + console.error = (message) => errorLogs.push(message); + + const component: any = document.querySelector('scoped-slot-assigned-methods'); + expect(component.getSlotAssignedElements).toBeDefined(); + + const eles = await component.getSlotAssignedElements(undefined, true); + let nodes = await component.getSlotAssignedNodes(undefined, true); + expect(eles).toBeDefined(); + expect(nodes).toBeDefined(); + expect(nodes.length).toBe(1); + expect(eles.length).toBe(1); + expect(nodes[0].outerHTML).toBe('
Plain slot content.
'); + expect(eles[0].outerHTML).toBe('
Plain slot content.
'); + component.removeChild(nodes[0]); + + expect(await component.getSlotAssignedElements(undefined, true)).toHaveLength(0); + expect(await component.getSlotAssignedNodes(undefined, true)).toHaveLength(0); + nodes = await component.getSlotAssignedElements({ flatten: true }, true); + + expect(nodes).toHaveLength(0); + expect(errorLogs.length).toEqual(1); + }); +}); diff --git a/test/wdio/scoped-slot-assigned-methods/cmp.tsx b/test/wdio/scoped-slot-assigned-methods/cmp.tsx new file mode 100644 index 00000000000..ae7b910a097 --- /dev/null +++ b/test/wdio/scoped-slot-assigned-methods/cmp.tsx @@ -0,0 +1,37 @@ +import { Component, h, Method } from '@stencil/core'; + +@Component({ + tag: 'scoped-slot-assigned-methods', + scoped: true, +}) +export class ScopedSlotAssignedMethods { + private fbSlot: HTMLSlotElement; + private plainSlot: HTMLSlotElement; + + @Method() + async getSlotAssignedElements(opts?: { flatten: boolean }, getPlainSlot = false) { + if (getPlainSlot) { + return this.plainSlot.assignedElements(opts); + } + return this.fbSlot.assignedElements(opts); + } + + @Method() + async getSlotAssignedNodes(opts?: { flatten: boolean }, getPlainSlot = false) { + if (getPlainSlot) { + return this.plainSlot.assignedNodes(opts); + } + return this.fbSlot.assignedNodes(opts); + } + + render() { + return ( +
+ (this.fbSlot = s)}> + Fallback content + + (this.plainSlot = s)} /> +
+ ); + } +} diff --git a/test/wdio/scoped-slot-slotchange/cmp-wrap.tsx b/test/wdio/scoped-slot-slotchange/cmp-wrap.tsx new file mode 100644 index 00000000000..8c0367c4913 --- /dev/null +++ b/test/wdio/scoped-slot-slotchange/cmp-wrap.tsx @@ -0,0 +1,19 @@ +import { Component, h, Prop } from '@stencil/core'; + +@Component({ + tag: 'scoped-slot-slotchange-wrap', + scoped: true, +}) +export class ScopedSlotChangeWrap { + @Prop() swapSlotContent: boolean = false; + + render() { + return ( +
+ + {this.swapSlotContent ?
Swapped slotted content
:

Initial slotted content

} +
+
+ ); + } +} diff --git a/test/wdio/scoped-slot-slotchange/cmp.test.tsx b/test/wdio/scoped-slot-slotchange/cmp.test.tsx new file mode 100644 index 00000000000..b67b992c38a --- /dev/null +++ b/test/wdio/scoped-slot-slotchange/cmp.test.tsx @@ -0,0 +1,77 @@ +/// + +import { h } from '@stencil/core'; +import { render, waitForChanges } from '@wdio/browser-runner/stencil'; +import { $, expect } from '@wdio/globals'; + +describe('scoped-slot-slotchange', () => { + it('checks that internal, stencil content changes fire slotchange events', async () => { + // @ts-expect-error - no components array? + render({ + template: () => ( + +

My initial slotted content.

+
+ ), + }); + await $('scoped-slot-slotchange-wrap div').waitForExist(); + + const slotChangeEle: any = document.querySelector('scoped-slot-slotchange'); + await expect(slotChangeEle).toBeDefined(); + await expect(slotChangeEle.slotEventCatch).toBeDefined(); + await expect(slotChangeEle.slotEventCatch).toHaveLength(1); + await expect(slotChangeEle.slotEventCatch[0]).toMatchObject({ event: { type: 'slotchange' } }); + await expect(slotChangeEle.slotEventCatch[0].assignedNodes[0].outerHTML).toMatch( + `

Initial slotted content

`, + ); + + document.querySelector('scoped-slot-slotchange-wrap').setAttribute('swap-slot-content', 'true'); + await waitForChanges(); + await expect(slotChangeEle.slotEventCatch).toHaveLength(2); + await expect(slotChangeEle.slotEventCatch[1]).toMatchObject({ event: { type: 'slotchange' } }); + await expect(slotChangeEle.slotEventCatch[1].assignedNodes[0].outerHTML).toMatch( + '
Swapped slotted content
', + ); + }); + + it('checks that external, browser content changes fire slotchange events', async () => { + // @ts-expect-error - no components array? + render({ + template: () => , + }); + await $('scoped-slot-slotchange div').waitForExist(); + const slotChangeEle: any = document.querySelector('scoped-slot-slotchange'); + + await expect(slotChangeEle).toBeDefined(); + await expect(slotChangeEle.slotEventCatch).toHaveLength(0); + + const p = document.createElement('p'); + p.innerHTML = 'Append child content'; + slotChangeEle.appendChild(p); + await waitForChanges(); + + await expect(slotChangeEle.slotEventCatch).toHaveLength(1); + await expect(slotChangeEle.slotEventCatch[0]).toMatchObject({ event: { type: 'slotchange' } }); + await expect(slotChangeEle.slotEventCatch[0].event.target.name).toBeFalsy(); + await expect(slotChangeEle.slotEventCatch[0].assignedNodes[0].outerHTML).toMatch(`

Append child content

`); + + const p2 = document.createElement('p'); + p2.innerHTML = 'Fallback content'; + p2.slot = 'fallback-slot'; + slotChangeEle.appendChild(p2); + await waitForChanges(); + + await expect(slotChangeEle.slotEventCatch).toHaveLength(2); + await expect(slotChangeEle.slotEventCatch[1]).toMatchObject({ event: { type: 'slotchange' } }); + await expect(slotChangeEle.slotEventCatch[1].event.target.getAttribute('name')).toBe('fallback-slot'); + + const div = document.createElement('div'); + div.innerHTML = 'InsertBefore content'; + slotChangeEle.insertBefore(div, null); + await waitForChanges(); + + await expect(slotChangeEle.slotEventCatch).toHaveLength(4); + await expect(slotChangeEle.slotEventCatch[2]).toMatchObject({ event: { type: 'slotchange' } }); + await expect(slotChangeEle.slotEventCatch[2].assignedNodes[1].outerHTML).toMatch(`
InsertBefore content
`); + }); +}); diff --git a/test/wdio/scoped-slot-slotchange/cmp.tsx b/test/wdio/scoped-slot-slotchange/cmp.tsx new file mode 100644 index 00000000000..c014b280191 --- /dev/null +++ b/test/wdio/scoped-slot-slotchange/cmp.tsx @@ -0,0 +1,24 @@ +import { Component, h, Prop } from '@stencil/core'; + +@Component({ + tag: 'scoped-slot-slotchange', + scoped: true, +}) +export class ScopedSlotChange { + @Prop({ mutable: true }) slotEventCatch: { event: Event; assignedNodes: Node[] }[] = []; + + private handleSlotchange = (e) => { + this.slotEventCatch.push({ event: e, assignedNodes: e.target.assignedNodes() }); + }; + + render() { + return ( +
+ + + Slot with fallback + +
+ ); + } +} diff --git a/test/wdio/slot-ref/cmp.test.tsx b/test/wdio/slot-ref/cmp.test.tsx deleted file mode 100644 index 58580c093fd..00000000000 --- a/test/wdio/slot-ref/cmp.test.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { h } from '@stencil/core'; -import { render } from '@wdio/browser-runner/stencil'; - -describe('slot-ref', () => { - beforeEach(async () => { - render({ - template: () => ( - - - Hello World! - - - ), - }); - - await $('slot-ref').waitForExist(); - }); - - it('ref callback of slot is called', async () => { - await expect($('slot-ref')).toHaveAttribute('data-ref-id', 'slotted-element-id'); - await expect($('slot-ref')).toHaveAttribute('data-ref-tagname', 'SPAN'); - }); -}); diff --git a/test/wdio/slot-ref/cmp.tsx b/test/wdio/slot-ref/cmp.tsx deleted file mode 100644 index 4a1f61aabc5..00000000000 --- a/test/wdio/slot-ref/cmp.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Component, Element, h, Host } from '@stencil/core'; - -@Component({ - tag: 'slot-ref', - shadow: false, - scoped: true, -}) -export class SlotRef { - @Element() hostElement: HTMLElement; - - render() { - return ( - - { - this.hostElement.setAttribute('data-ref-id', el!.id); - this.hostElement.setAttribute('data-ref-tagname', el!.tagName); - }} - /> - - ); - } -}