Skip to content

Commit 265ddf1

Browse files
committed
Merge branch 'issue-7-fix-nested-slot-issues'
2 parents 6a87444 + 64bacf6 commit 265ddf1

File tree

3 files changed

+339
-83
lines changed

3 files changed

+339
-83
lines changed

index.js

Lines changed: 113 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,14 @@ function createSvelteSlots(slots) {
4848
* @param {string[]?} opts.attributes Optional array of attributes that should be reactively forwarded to the component when modified.
4949
* @param {boolean?} opts.shadow Indicates if we should build the component in the shadow root instead of in the regular ("light") DOM.
5050
* @param {string?} opts.href URL to the CSS stylesheet to incorporate into the shadow DOM (if enabled).
51+
* @param {boolean?} opts.debugMode Hidden option to enable debugging for package development purposes.
5152
*/
5253
export default function(opts) {
5354
class Wrapper extends HTMLElement {
5455
constructor() {
5556
super();
57+
58+
this.debug('constructor()');
5659
this.slotCount = 0;
5760
let root = opts.shadow ? this.attachShadow({ mode: 'open' }) : this;
5861

@@ -77,6 +80,8 @@ export default function(opts) {
7780
}
7881

7982
connectedCallback() {
83+
this.debug('connectedCallback()');
84+
8085
// Props passed to Svelte component constructor.
8186
let props = {
8287
$$scope: {}
@@ -86,37 +91,52 @@ export default function(opts) {
8691

8792
// Populate custom element attributes into the props object.
8893
// TODO: Inspect component and normalize to lowercase for Lit-style props (https://github.com/crisward/svelte-tag/issues/16)
89-
let slots;
9094
Array.from(this.attributes).forEach(attr => props[attr.name] = attr.value);
9195

96+
// Setup slot elements, making sure to retain a reference to the original elements prior to processing, so they
97+
// can be restored later on disconnectedCallback().
98+
this.slotEls = {};
9299
if (opts.shadow) {
93-
slots = this.getShadowSlots();
100+
this.slotEls = this.getShadowSlots();
94101
this.observer = new MutationObserver(this.processMutations.bind(this, { root: this._root, props }));
95102
this.observer.observe(this, { childList: true, subtree: true, attributes: false });
96103
} else {
97-
slots = this.getSlots();
104+
this.slotEls = this.getSlots();
98105
}
99-
this.slotCount = Object.keys(slots).length;
100-
props.$$slots = createSvelteSlots(slots);
106+
this.slotCount = Object.keys(this.slotEls).length; // TODO: Refactor to getter
107+
props.$$slots = createSvelteSlots(this.slotEls);
101108

102109
this.elem = new opts.component({ target: this._root, props });
103110
}
104111

105112
disconnectedCallback() {
113+
this.debug('disconnectedCallback()');
114+
106115
if (this.observer) {
107116
this.observer.disconnect();
108117
}
109118

110-
// Double check that element has been initialized already. This could happen in case connectedCallback (which
111-
// is async) hasn't fully completed yet.
119+
// Double check that element has been initialized already. This could happen in case connectedCallback() hasn't
120+
// fully completed yet (e.g. if initialization is async) TODO: May happen later if MutationObserver is setup for light DOM
112121
if (this.elem) {
113122
try {
114-
// destroy svelte element when removed from domn
123+
// Clean up: Destroy Svelte component when removed from DOM.
115124
this.elem.$destroy();
116125
} catch(err) {
117126
console.error(`Error destroying Svelte component in '${this.tagName}'s disconnectedCallback(): ${err}`);
118127
}
119128
}
129+
130+
if (!opts.shadow) {
131+
// Go through originally removed slots and restore back to the custom element. This is necessary in case
132+
// we're just being appended elsewhere in the DOM (likely if we're nested under another custom element
133+
// that initializes after this custom element, thus causing *another* round of construct/connectedCallback
134+
// on this one).
135+
for(let slotName in this.slotEls) {
136+
let slotEl = this.slotEls[slotName];
137+
this.appendChild(slotEl);
138+
}
139+
}
120140
}
121141

122142
/**
@@ -135,17 +155,64 @@ export default function(opts) {
135155
return node;
136156
}
137157

158+
/**
159+
* Traverses DOM to find the first custom element that the provided <slot> element happens to belong to.
160+
*
161+
* @param {Element} slot
162+
* @returns {HTMLElement|null}
163+
*/
164+
findSlotParent(slot) {
165+
let parentEl = slot.parentElement;
166+
while(parentEl) {
167+
if (parentEl.tagName.indexOf('-') !== -1) return parentEl;
168+
parentEl = parentEl.parentElement;
169+
}
170+
return null;
171+
}
172+
173+
/**
174+
* Indicates if the provided <slot> element instance belongs to this custom element or not.
175+
*
176+
* @param {Element} slot
177+
* @returns {boolean}
178+
*/
179+
isOwnSlot(slot) {
180+
let slotParent = this.findSlotParent(slot);
181+
if (slotParent === null) return false;
182+
return (slotParent === this);
183+
}
184+
138185
getSlots() {
139-
const namedSlots = this.querySelectorAll('[slot]');
140186
let slots = {};
141-
namedSlots.forEach(n => {
142-
slots[n.slot] = n;
143-
this.removeChild(n);
144-
});
145-
if (this.innerHTML.length) {
146-
slots.default = this.unwrap(this);
187+
188+
// Look for named slots below this element. IMPORTANT: This may return slots nested deeper (see check in forEach below).
189+
const queryNamedSlots = this.querySelectorAll('[slot]');
190+
for(let candidate of queryNamedSlots) {
191+
// Skip this slot if it doesn't happen to belong to THIS custom element.
192+
if (!this.isOwnSlot(candidate)) continue;
193+
194+
slots[candidate.slot] = candidate;
195+
// TODO: Potentially problematic in edge cases where the browser may *oddly* return slots from query selector
196+
// above, yet their not actually a child of the current element. This seems to only happen if another
197+
// constructor() + connectedCallback() are BOTH called for this particular element again BEFORE a
198+
// disconnectedCallback() gets called (out of sync). Only experienced in Chrome when manually editing the HTML
199+
// when there were multiple other custom elements present inside the slot of another element (very edge case?)
200+
this.removeChild(candidate);
201+
}
202+
203+
// Default slots are indeed allowed alongside named slots, as long as the named slots are elided *first*. We
204+
// should also make sure we trim out whitespace in case all slots and elements are already removed. We don't want
205+
// to accidentally pass content (whitespace) to a component that isn't setup with a default slot.
206+
if (this.innerHTML.trim().length !== 0) {
207+
if (slots.default) {
208+
// Edge case: User has a named "default" as well as remaining HTML left over. Use same error as Svelte.
209+
console.error(`svelteRetag: '${this.tagName}': Found elements without slot attribute when using slot="default"`);
210+
} else {
211+
slots.default = this.unwrap(this);
212+
}
147213
this.innerHTML = '';
148214
}
215+
149216
return slots;
150217
}
151218

@@ -168,12 +235,22 @@ export default function(opts) {
168235
// light DOM, since that is not deferred and technically slots will be added after the wrapping tag's connectedCallback()
169236
// during initial browser parsing and before the closing tag is encountered.
170237
processMutations({ root, props }, mutations) {
238+
this.debug('processMutations()');
239+
171240
for(let mutation of mutations) {
172241
if (mutation.type === 'childList') {
173242
let slots = this.getShadowSlots();
243+
244+
// TODO: Should it re-render if the count changes at all? e.g. what if slots were REMOVED (reducing it to zero)?
245+
// We'd have latent content left over that's not getting updated, then. Consider rewrite...
174246
if (Object.keys(slots).length) {
247+
175248
props.$$slots = createSvelteSlots(slots);
249+
250+
// TODO: Why is this set here but not re-rendered unless the slot count changes?
251+
// TODO: Also, why is props.$$slots set above but not just passed here? Calling createSvelteSlots(slots) 2x...
176252
this.elem.$set({ '$$slots': createSvelteSlots(slots) });
253+
177254
// do full re-render on slot count change - needed for tabs component
178255
if (this.slotCount !== Object.keys(slots).length) {
179256
Array.from(this.attributes).forEach(attr => props[attr.name] = attr.value); // TODO: Redundant, repeated on connectedCallback().
@@ -186,11 +263,32 @@ export default function(opts) {
186263
}
187264
}
188265

266+
/**
267+
* Forward modifications to element attributes to the corresponding Svelte prop.
268+
*
269+
* @param {string} name
270+
* @param {string} oldValue
271+
* @param {string} newValue
272+
*/
189273
attributeChangedCallback(name, oldValue, newValue) {
274+
this.debug('attributes changed', { name, oldValue, newValue });
275+
190276
if (this.elem && newValue !== oldValue) {
191277
this.elem.$set({ [name]: newValue });
192278
}
193279
}
280+
281+
/**
282+
* Pass through to console.log() but includes a reference to the custom element in the log for easier targeting for
283+
* debugging purposes.
284+
*
285+
* @param {...*}
286+
*/
287+
debug() {
288+
if (opts.debugMode) {
289+
console.log.apply(null, [this, ...arguments]);
290+
}
291+
}
194292
}
195293

196294
window.customElements.define(opts.tagname, Wrapper);

0 commit comments

Comments
 (0)