-
Notifications
You must be signed in to change notification settings - Fork 26
/
Copy pathsvelte-web.ts
331 lines (287 loc) · 10.7 KB
/
svelte-web.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
import type { SvelteComponent } from 'svelte'
interface Options {
mode: 'open' | 'closed'
name: string
}
// Regex for testing if a prop is an event
const eventRegex = /^on[A-Z]/
// Properties with these types should be reflected to attributes.
const reflectToAttributes = new Set(['string', 'number', 'boolean'])
/**
* This function creates a faux Svelte component which forwards WebComponent
* slots into a Svelte slot.
* @param name The name of the slot
* @returns A Svelte "component" representing the slot.
*/
const createSlot = (name?: string) => {
let slot: HTMLElement
return {
// Create
c() {
slot = document.createElement('slot')
if (name) {
slot.setAttribute('name', name)
}
},
// Mount
m(target, anchor) {
target.insertBefore(slot, anchor || null)
},
// Props changed
p() {},
// Detach
d(detaching) {
if (detaching && slot.parentNode) {
slot.parentNode.removeChild(slot)
}
}
}
}
/**
* Generate a selector for an element - note: This is pretty limited at the
* moment and will only work if the element has a unique id/class. However, for
* now this works well enough, and we can improve it easily.
* @param el The element to generate the selector for
* @returns A selector for the element: Note: This relies on the element having a unique Id or class
*/
const generateSelector = (el: Element) => {
if (!el) return null
if (el.id) return `#${el.id}`
return el.className
.split(' ')
.filter((c) => c)
.map((c) => `.${c}`)
.join('')
}
export default function registerWebComponent(
component: any,
{ name, mode }: Options
) {
if (!globalThis.customElements) {
console.log(
`Component ${name} not registered as there is no customElements in this environment. Perhaps this is an SSR compile, which is not supported for Leo components yet.`
)
return
}
if (customElements.get(name)) {
console.log(`Attempted to register ${name} component multiple times.`)
return
}
// Create & mount a dummy component. We use this to work out what props are
// available and generate a list of available properties.
const c = new component({ target: document.createElement('div') })
// The names of all properties on our Svelte component.
const props = Object.keys(c.$$.props)
// All the event names in our Svelte component. Maps the HTMLEventType string
// to the Svelte prop (i.e. click: onClick).
const events = props
.filter((c) => eventRegex.test(c))
.reduce(
(prev, next) => ({ ...prev, [next.substring(2).toLowerCase()]: next }),
{}
)
// A mapping of 'attributename' to 'propertyName', as attributes are
// lowercase, while Svelte components are generally 'camelCase'.
const attributePropMap = props.reduce((prev, next) => {
prev.set(next.toLowerCase(), next)
return prev
}, new Map<string, string>())
// Note attribute keys, so changes cause us to update our Svelte Component.
const attributes = Array.from(attributePropMap.keys())
// We need to handle boolean attributes specially, as the presence/absence of the attribute indicates the value.
const boolProperties = new Set(
props.filter((p) => typeof c.$$.ctx[c.$$.props[p]] === 'boolean')
)
type Callback = (...args: any[]) => void
class SvelteWrapper extends HTMLElement {
#component: SvelteComponent
get component() {
return this.#component
}
set component(value) {
this.#component = value
}
static get observedAttributes() {
return attributes
}
static get events() {
return Object.keys(events)
}
#propsCache = {}
#lastSlots = new Set()
updateSlots = () => {
const slotsNames = Array.from(this.children).map((c) =>
c.getAttribute('slot')
)
// Add default slot if there are non-empty nodes without a slot name.
const nonEmptyNodes = Array.from(this.childNodes).filter(
(c) => c.nodeName !== '#text' || c.textContent.trim().length
)
if (nonEmptyNodes.length > slotsNames.length) slotsNames.push(null)
const distinctSlots = new Set(this.#lastSlots)
// Slots didn't change, so nothing to do here.
// The component needs to get created, at least once
if (
this.component &&
// If the size is the same, and every one of our last slots
// is present, then nothing has changed, and we don't need
// to do anything here.
this.#lastSlots.size === distinctSlots.size &&
slotsNames.every((s) => this.#lastSlots.has(s))
) {
return
}
// Update the last slots we have, so if they change we know to update them.
this.#lastSlots = distinctSlots
// Create a dictionary of the slotName: <slot name={slotName}/>
const slots = slotsNames.reduce(
(prev, next) => ({
...prev,
[next ?? 'default']: [() => createSlot(next)]
}),
{}
)
// If we've already created the component, we might have some
// existing props. We need to create a snapshot of the component
// so we can recreate it as faithfully as possible.
// Note: We might be able to do some additional hackery here
// to copy over even more information from $$.ctx and exactly
// maintain the component state!
const existingProps = props
.map((k) => [k, this.#propsCache[k]])
.reduce((prev, [key, value]) => ({ ...prev, [key]: value }), {})
// If there's focus within the element, get a selector to the
// activeElement - we'll restore it after creating/destroying the
// element.
const restoreFocus = generateSelector(this.shadowRoot?.activeElement)
// If the component already exists, destroy it. This is,
// unfortunately, necessary as there is no way to update slotted
// content in the output Svelte compiles to. This is a problem
// even when not doing crazy things:
// https://github.com/sveltejs/svelte/issues/5312
this.component?.$destroy()
// Finally, we actually create the component
this.component = new component({
// Target this shadowDOM, so we get nicely encapsulated
// styles
target: this.shadowRoot,
props: {
// Copy over existing props (there might be none, if
// this is our first render).
...existingProps,
// Create WebComponent slots for each Svelte slot we
// have content for. This has to be done at render or
// Svelte won't support fallback content.
$$slots: slots,
// Not sure what this is needed for but Svelte crashes
// without it. I think this might be related to slot
// props:
// https://svelte.dev/tutorial/slot-props
$$scope: { ctx: [] }
}
})
if (restoreFocus) {
const restoreTo = this.shadowRoot.querySelector(restoreFocus)
;(restoreTo as HTMLElement)?.focus?.()
}
}
constructor() {
super()
// Mount shadow - this is where we're going to render our Component.
// Note: In some rare cases, the shadow root might already exist,
// especially when being rendered inside a Polymer dom-if. In this case,
// we need to also clear the contents of the node, to ensure we don't
// duplicate content.
const shadow =
this.shadowRoot ?? this.attachShadow({ mode, delegatesFocus: true })
shadow.replaceChildren()
// Unfortunately we need a DOMMutationObserver to let us know when
// slotted content changes because we dynamically create & remove
// slots. This is for two reasons:
// 1) At runtime, we don't know what slots our Svelte component has
// 2) Even if we did, if we generated all of the slots at mount time
// then Svelte would never render any of the fallback content,
// event if the slot was empty.
new MutationObserver(this.updateSlots).observe(this, {
childList: true,
attributes: false,
attributeOldValue: false,
subtree: false,
characterData: false,
characterDataOldValue: false
})
// For some reason setting this on |SvelteWrapper| doesn't work properly.
for (const prop of props) {
Object.defineProperty(this, prop, {
enumerable: true,
get() {
// $$.props is { [propertyName: string]: number } where the number
// is the array index into $$.ctx that the value is stored in.
const contextIndex = this.component?.$$.props[prop]
return this.component?.$$.ctx[contextIndex]
},
set(value) {
if (reflectToAttributes.has(typeof value)) {
// Boolean attributes are special - presence/absence indicates
// value, rather than actual value.
if (boolProperties.has(prop)) {
if (value) this.setAttribute(prop, '')
else this.removeAttribute(prop)
} else this.setAttribute(prop, value)
}
// Cache the prop, so when we recreate the component we restore it
// with the right props.
this.#propsCache[prop] = value
// |.$set| updates the value of a prop. Note: This only works for
// props, not slotted content.
this.component?.$set({ [prop]: value })
}
})
}
}
// Update slots on connect.
connectedCallback() {
this.updateSlots()
}
disconnectedCallback() {
this.#lastSlots = new Set()
this.component?.$destroy()
this.#component = null
}
attributeChangedCallback(name, oldValue, newValue) {
const prop = attributePropMap.get(name)
if (!prop) return
if (oldValue === newValue) return
this[prop] = boolProperties.has(prop) ? newValue !== null : newValue
}
addEventListener(
event: string,
eventHandler: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions
) {
const svelteEvent = events[event]
if (svelteEvent) {
const callback =
'handleEvent' in eventHandler
? eventHandler.handleEvent.bind(eventHandler)
: eventHandler
this[svelteEvent] = callback
return
}
super.addEventListener(event, eventHandler, options)
}
removeEventListener(
event: string,
callback: Callback,
options?: boolean | EventListenerOptions
) {
const svelteEvent = events[event]
if (svelteEvent && this[svelteEvent] === callback) {
this[svelteEvent] = undefined
return
}
super.removeEventListener(event, callback, options)
}
}
customElements.define(name, SvelteWrapper)
}