@@ -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 */
5253export 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