@wippy-fe/webcomponent-core 0.0.25 → 0.0.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/base-element.host.test.ts +174 -0
- package/src/base-element.ts +97 -29
- package/src/host-bus.ts +44 -0
- package/src/host-scope.test.ts +19 -0
- package/src/host-scope.ts +28 -0
- package/src/index.ts +9 -6
- package/src/prop-parser.ts +12 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wippy-fe/webcomponent-core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.27",
|
|
4
4
|
"description": "Framework-agnostic base class for Wippy web components — shadow DOM, CSS loading, schema-driven prop parsing, and lifecycle management.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
],
|
|
12
12
|
"peerDependencies": {
|
|
13
13
|
"@iconify/vue": "^5.0.0",
|
|
14
|
-
"@wippy-fe/proxy": "0.0.
|
|
14
|
+
"@wippy-fe/proxy": "0.0.27",
|
|
15
|
+
"@wippy-fe/shared": "0.0.27"
|
|
15
16
|
},
|
|
16
17
|
"license": "UNLICENSED"
|
|
17
18
|
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*
|
|
4
|
+
* Covers `WippyElement.host` getter — reads `__wippyHost` attached by
|
|
5
|
+
* the managed-layout shell's content resolvers. And the cleanup path
|
|
6
|
+
* in `disconnectedCallback` that drops stale wires on detach.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from 'vitest'
|
|
9
|
+
import { WippyElement } from './base-element.ts'
|
|
10
|
+
|
|
11
|
+
// `ElementInternals.states` shim lives in `vitest.setup.ts` (gen-2-chat
|
|
12
|
+
// repo root) so any test that transitively imports `WippyElement` gets
|
|
13
|
+
// the patch. This file just exercises the resulting behavior.
|
|
14
|
+
|
|
15
|
+
class TestEl extends WippyElement {
|
|
16
|
+
static get wippyConfig() {
|
|
17
|
+
return { propsSchema: { properties: {} }, hostCssKeys: [] }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
protected onMount(): void {}
|
|
21
|
+
protected onUnmount(): void {}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const TAG = `wippy-host-test-${Math.random().toString(36).slice(2)}`
|
|
25
|
+
customElements.define(TAG, TestEl)
|
|
26
|
+
|
|
27
|
+
describe('wippyElement.host getter', () => {
|
|
28
|
+
it('returns null on a fresh element with no wires', () => {
|
|
29
|
+
const el = document.createElement(TAG) as TestEl
|
|
30
|
+
expect(el.host).toBeNull()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('returns the wrapper attached by the resolver as __wippyHost', () => {
|
|
34
|
+
const el = document.createElement(TAG) as TestEl
|
|
35
|
+
const fakeWrapper = { layout: { broadcast: () => {} } }
|
|
36
|
+
;(el as unknown as { __wippyHost: unknown }).__wippyHost = fakeWrapper
|
|
37
|
+
expect(el.host).toBe(fakeWrapper)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('connectedCallback can read this.host on first tick', () => {
|
|
41
|
+
let observedHost: unknown = 'sentinel'
|
|
42
|
+
class Probe extends WippyElement {
|
|
43
|
+
static get wippyConfig() {
|
|
44
|
+
return { propsSchema: { properties: {} }, hostCssKeys: [] }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
protected onMount(): void {
|
|
48
|
+
observedHost = this.host
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
protected onUnmount(): void {}
|
|
52
|
+
}
|
|
53
|
+
const probeTag = `wippy-probe-${Math.random().toString(36).slice(2)}`
|
|
54
|
+
customElements.define(probeTag, Probe)
|
|
55
|
+
const el = document.createElement(probeTag)
|
|
56
|
+
const fakeWrapper = { layout: {} }
|
|
57
|
+
;(el as unknown as { __wippyHost: unknown }).__wippyHost = fakeWrapper
|
|
58
|
+
document.body.appendChild(el)
|
|
59
|
+
expect(observedHost).toBe(fakeWrapper)
|
|
60
|
+
el.remove()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('wippyElement.disconnectedCallback wire cleanup', () => {
|
|
65
|
+
it('drops __wippyHost and __wippyHostBus on disconnect', () => {
|
|
66
|
+
const el = document.createElement(TAG) as TestEl
|
|
67
|
+
;(el as unknown as { __wippyHost: unknown }).__wippyHost = { layout: {} }
|
|
68
|
+
;(el as unknown as { __wippyHostBus: unknown }).__wippyHostBus = {
|
|
69
|
+
broadcast: () => {},
|
|
70
|
+
send: () => {},
|
|
71
|
+
subscribe: () => () => {},
|
|
72
|
+
}
|
|
73
|
+
document.body.appendChild(el)
|
|
74
|
+
el.remove()
|
|
75
|
+
expect((el as unknown as { __wippyHost?: unknown }).__wippyHost).toBeUndefined()
|
|
76
|
+
expect((el as unknown as { __wippyHostBus?: unknown }).__wippyHostBus).toBeUndefined()
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('wippyElement reconnect — no shadow-child accumulation', () => {
|
|
81
|
+
// R3 regression: previously `connectedCallback` re-ran the full mount
|
|
82
|
+
// path (CSS injection + container append) on every reconnect. The
|
|
83
|
+
// `disconnectedCallback` did NOT clear the persistent shadow root, so
|
|
84
|
+
// each detach+reattach cycle leaked another `<style>` and another
|
|
85
|
+
// container `<div>`. On a move-style DOM op (e.g. `appendChild` of an
|
|
86
|
+
// already-connected element, list reorder) the browser fires
|
|
87
|
+
// disconnect → connect synchronously, so this leak fires on every
|
|
88
|
+
// reorder.
|
|
89
|
+
//
|
|
90
|
+
// Fix: gate CSS / container / icon setup on a `_initialized` flag and
|
|
91
|
+
// reuse the stored container on reconnect.
|
|
92
|
+
|
|
93
|
+
class CssEl extends WippyElement {
|
|
94
|
+
static get wippyConfig() {
|
|
95
|
+
return {
|
|
96
|
+
propsSchema: { properties: {} },
|
|
97
|
+
hostCssKeys: [],
|
|
98
|
+
inlineCss: ':host { display: block; }',
|
|
99
|
+
containerClasses: ['ct'],
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
public mountCount = 0
|
|
104
|
+
public lastIsRemount: boolean | undefined = undefined
|
|
105
|
+
public lastContainer: HTMLElement | null = null
|
|
106
|
+
|
|
107
|
+
protected onMount(
|
|
108
|
+
_shadow: ShadowRoot,
|
|
109
|
+
container: HTMLElement,
|
|
110
|
+
_props: Record<string, unknown>,
|
|
111
|
+
_errors: string[],
|
|
112
|
+
_content?: string | null,
|
|
113
|
+
isRemount?: boolean,
|
|
114
|
+
): void {
|
|
115
|
+
this.mountCount += 1
|
|
116
|
+
this.lastIsRemount = isRemount
|
|
117
|
+
this.lastContainer = container
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
protected onUnmount(): void {}
|
|
121
|
+
}
|
|
122
|
+
const cssTag = `wippy-reconnect-${Math.random().toString(36).slice(2)}`
|
|
123
|
+
customElements.define(cssTag, CssEl)
|
|
124
|
+
|
|
125
|
+
it('does not duplicate <style> or container <div> across detach/reattach cycles', () => {
|
|
126
|
+
const el = document.createElement(cssTag) as CssEl
|
|
127
|
+
document.body.appendChild(el)
|
|
128
|
+
|
|
129
|
+
const shadow = el.shadowRoot!
|
|
130
|
+
const initialChildCount = shadow.childElementCount
|
|
131
|
+
const initialStyles = shadow.querySelectorAll('style').length
|
|
132
|
+
const initialContainers = shadow.querySelectorAll('div.ct').length
|
|
133
|
+
expect(initialContainers).toBe(1)
|
|
134
|
+
expect(initialStyles).toBeGreaterThan(0)
|
|
135
|
+
expect(el.mountCount).toBe(1)
|
|
136
|
+
expect(el.lastIsRemount).toBe(false)
|
|
137
|
+
|
|
138
|
+
// Detach + reattach (one cycle) — pre-fix this would add another
|
|
139
|
+
// <style> and another container <div>.
|
|
140
|
+
el.remove()
|
|
141
|
+
document.body.appendChild(el)
|
|
142
|
+
|
|
143
|
+
expect(shadow.childElementCount).toBe(initialChildCount)
|
|
144
|
+
expect(shadow.querySelectorAll('style').length).toBe(initialStyles)
|
|
145
|
+
expect(shadow.querySelectorAll('div.ct').length).toBe(initialContainers)
|
|
146
|
+
expect(el.mountCount).toBe(2)
|
|
147
|
+
expect(el.lastIsRemount).toBe(true)
|
|
148
|
+
|
|
149
|
+
// Second detach + reattach cycle — counts still stable.
|
|
150
|
+
el.remove()
|
|
151
|
+
document.body.appendChild(el)
|
|
152
|
+
|
|
153
|
+
expect(shadow.childElementCount).toBe(initialChildCount)
|
|
154
|
+
expect(shadow.querySelectorAll('style').length).toBe(initialStyles)
|
|
155
|
+
expect(shadow.querySelectorAll('div.ct').length).toBe(initialContainers)
|
|
156
|
+
expect(el.mountCount).toBe(3)
|
|
157
|
+
expect(el.lastIsRemount).toBe(true)
|
|
158
|
+
|
|
159
|
+
el.remove()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('reuses the same container instance on reconnect', () => {
|
|
163
|
+
const el = document.createElement(cssTag) as CssEl
|
|
164
|
+
document.body.appendChild(el)
|
|
165
|
+
const firstContainer = el.lastContainer
|
|
166
|
+
expect(firstContainer).not.toBeNull()
|
|
167
|
+
|
|
168
|
+
el.remove()
|
|
169
|
+
document.body.appendChild(el)
|
|
170
|
+
|
|
171
|
+
expect(el.lastContainer).toBe(firstContainer)
|
|
172
|
+
el.remove()
|
|
173
|
+
})
|
|
174
|
+
})
|
package/src/base-element.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { WippyElementConfig } from './types.ts'
|
|
2
2
|
import { addCollection } from '@iconify/vue'
|
|
3
|
-
import {
|
|
3
|
+
import { addIcons, define } from '@wippy-fe/proxy'
|
|
4
|
+
import { injectInlineCss, loadHostCss } from './css-loader.ts'
|
|
5
|
+
import { getWippyHost } from './host-scope.ts'
|
|
4
6
|
import { parseProps } from './prop-parser.ts'
|
|
5
|
-
import type { WippyElementConfig } from './types.ts'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Abstract base class for Wippy web components.
|
|
@@ -10,7 +11,7 @@ import type { WippyElementConfig } from './types.ts'
|
|
|
10
11
|
* Generic over `Props` — the parsed prop object type. This types `onMount`,
|
|
11
12
|
* `onPropsChanged`, and `validateProps`. Defaults to `Record<string, unknown>`.
|
|
12
13
|
*
|
|
13
|
-
* Lifecycle order:
|
|
14
|
+
* Lifecycle order (first connect):
|
|
14
15
|
* 1. shadow DOM attached
|
|
15
16
|
* 2. `onInit(shadow)` — hook for early shadow DOM customization
|
|
16
17
|
* 3. inline CSS injected
|
|
@@ -23,12 +24,25 @@ import type { WippyElementConfig } from './types.ts'
|
|
|
23
24
|
* 10. `onReady()` — post-mount hook
|
|
24
25
|
* 11. `load` event emitted
|
|
25
26
|
*
|
|
27
|
+
* On reconnect (move-style DOM op: detach → reattach), the shadow root
|
|
28
|
+
* persists across the cycle, so steps 1, 3, 4, 5 (CSS injection +
|
|
29
|
+
* container creation) are skipped to avoid duplicating `<style>` and
|
|
30
|
+
* container `<div>` siblings inside the persistent shadow. The stored
|
|
31
|
+
* container is reused and `onMount` is re-invoked with `isRemount = true`
|
|
32
|
+
* so framework adapters can decide between fresh-mount and remount paths.
|
|
33
|
+
*
|
|
26
34
|
* On error: `onError(error)` → internals state → error → `error` event
|
|
27
|
-
* On disconnect: `onUnmount()` → `unload` event → internals cleared
|
|
35
|
+
* On disconnect: `onUnmount()` → `unload` event → internals cleared.
|
|
36
|
+
* Note: `disconnectedCallback` does NOT clear the shadow root or the
|
|
37
|
+
* stored container — they're reused on reconnect.
|
|
28
38
|
*/
|
|
29
39
|
export abstract class WippyElement<Props = Record<string, unknown>> extends HTMLElement {
|
|
30
40
|
private _internals!: ElementInternals
|
|
31
41
|
private _contentObserver: MutationObserver | null = null
|
|
42
|
+
/** True after the first successful connectedCallback completes. */
|
|
43
|
+
private _initialized: boolean = false
|
|
44
|
+
/** Container div created on first connect; reused on subsequent connects. */
|
|
45
|
+
private _container: HTMLElement | null = null
|
|
32
46
|
|
|
33
47
|
/**
|
|
34
48
|
* Override to provide the component's configuration.
|
|
@@ -58,6 +72,24 @@ export abstract class WippyElement<Props = Record<string, unknown>> extends HTML
|
|
|
58
72
|
this._internals = this.attachInternals()
|
|
59
73
|
}
|
|
60
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Panel-scoped `host` wrapper attached by the managed-layout shell's
|
|
77
|
+
* content resolvers (`ComponentResolver` / `WebComponentPackageLoader`).
|
|
78
|
+
*
|
|
79
|
+
* Inside a managed-layout panel, this is a wrapper around the universal
|
|
80
|
+
* `host` API where context-aware calls (`layout.broadcast / send / on`)
|
|
81
|
+
* are routed through the panel-bound bus — so `sourcePanelId` is
|
|
82
|
+
* attributed correctly without postMessage indirection. Layout
|
|
83
|
+
* mutations and other host methods pass through unchanged.
|
|
84
|
+
*
|
|
85
|
+
* `null` outside a managed-layout context (compat shell, chat sidebar,
|
|
86
|
+
* standalone playground). Subclass code that needs a host in those
|
|
87
|
+
* cases can fall back to `import { host } from '@wippy-fe/proxy'`.
|
|
88
|
+
*/
|
|
89
|
+
get host(): ReturnType<typeof getWippyHost> {
|
|
90
|
+
return getWippyHost(this)
|
|
91
|
+
}
|
|
92
|
+
|
|
61
93
|
/**
|
|
62
94
|
* Emit a CustomEvent that bubbles and crosses shadow DOM boundaries.
|
|
63
95
|
*/
|
|
@@ -75,33 +107,46 @@ export abstract class WippyElement<Props = Record<string, unknown>> extends HTML
|
|
|
75
107
|
this._internals.states.add('loading')
|
|
76
108
|
try {
|
|
77
109
|
const config = (this.constructor as typeof WippyElement).wippyConfig
|
|
110
|
+
const isRemount = this._initialized
|
|
78
111
|
|
|
79
112
|
// 1. Shadow DOM (guard against reconnect — attachShadow throws if called twice)
|
|
80
113
|
const shadow = this.shadowRoot ?? this.attachShadow({ mode: config.shadowMode ?? 'open' })
|
|
81
114
|
|
|
82
|
-
|
|
83
|
-
|
|
115
|
+
let container: HTMLElement
|
|
116
|
+
if (!isRemount) {
|
|
117
|
+
// 2. Early hook — customize shadow before CSS/container
|
|
118
|
+
this.onInit(shadow)
|
|
84
119
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
120
|
+
// 3. Inline CSS
|
|
121
|
+
if (config.inlineCss) {
|
|
122
|
+
injectInlineCss(shadow, config.inlineCss)
|
|
123
|
+
}
|
|
89
124
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
125
|
+
// 4. Host CSS (async, non-blocking)
|
|
126
|
+
if (config.hostCssKeys === undefined || config.hostCssKeys.length > 0) {
|
|
127
|
+
loadHostCss(shadow, config.hostCssKeys)
|
|
128
|
+
}
|
|
94
129
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
130
|
+
// 5. Container
|
|
131
|
+
container = document.createElement('div')
|
|
132
|
+
const classes = config.containerClasses ?? []
|
|
133
|
+
if (classes.length > 0) {
|
|
134
|
+
container.classList.add(...classes)
|
|
135
|
+
}
|
|
136
|
+
shadow.appendChild(container)
|
|
137
|
+
this._container = container
|
|
102
138
|
|
|
103
|
-
|
|
104
|
-
|
|
139
|
+
// 6. Icons
|
|
140
|
+
addIcons(addCollection)
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
// Reconnect path — reuse the existing shadow children + container.
|
|
144
|
+
// Skip CSS/icon/container setup so we don't accumulate duplicate
|
|
145
|
+
// `<style>` and container `<div>` siblings on every detach/reattach.
|
|
146
|
+
// The framework adapter decides whether to remount on the existing
|
|
147
|
+
// container or recreate its app instance.
|
|
148
|
+
container = this._container!
|
|
149
|
+
}
|
|
105
150
|
|
|
106
151
|
// 7. Parse initial props + custom validation
|
|
107
152
|
const { props, errors } = parseProps(this, config.propsSchema)
|
|
@@ -125,19 +170,25 @@ export abstract class WippyElement<Props = Record<string, unknown>> extends HTML
|
|
|
125
170
|
})
|
|
126
171
|
}
|
|
127
172
|
|
|
128
|
-
// 8. Framework mount
|
|
129
|
-
this.onMount(shadow, container, typedProps, errors, initialContent)
|
|
173
|
+
// 8. Framework mount (first time) or remount (reconnect)
|
|
174
|
+
this.onMount(shadow, container, typedProps, errors, initialContent, isRemount)
|
|
130
175
|
|
|
131
176
|
// 9. Ready
|
|
132
177
|
this._internals.states.delete('loading')
|
|
133
178
|
this._internals.states.add('ready')
|
|
134
179
|
|
|
180
|
+
// First-connect bookkeeping after a clean mount.
|
|
181
|
+
if (!isRemount) {
|
|
182
|
+
this._initialized = true
|
|
183
|
+
}
|
|
184
|
+
|
|
135
185
|
// 10. Post-mount hook
|
|
136
186
|
this.onReady()
|
|
137
187
|
|
|
138
188
|
// 11. Emit load
|
|
139
189
|
this.emitEvent('load')
|
|
140
|
-
}
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
141
192
|
this.onError(error)
|
|
142
193
|
this._internals.states.delete('loading')
|
|
143
194
|
this._internals.states.add('error')
|
|
@@ -156,10 +207,17 @@ export abstract class WippyElement<Props = Record<string, unknown>> extends HTML
|
|
|
156
207
|
this.onUnmount()
|
|
157
208
|
this.emitEvent('unload')
|
|
158
209
|
this._internals.states.clear()
|
|
210
|
+
// Drop panel-context wires so a detached element held by a
|
|
211
|
+
// consumer (or re-inserted into a different panel) doesn't carry
|
|
212
|
+
// stale wires that would silently misattribute. The resolver
|
|
213
|
+
// re-installs them on the next mount.
|
|
214
|
+
delete (this as unknown as { __wippyHost?: unknown }).__wippyHost
|
|
215
|
+
delete (this as unknown as { __wippyHostBus?: unknown }).__wippyHostBus
|
|
159
216
|
}
|
|
160
217
|
|
|
161
218
|
attributeChangedCallback(_name: string, oldVal: string | null, newVal: string | null): void {
|
|
162
|
-
if (oldVal === newVal)
|
|
219
|
+
if (oldVal === newVal)
|
|
220
|
+
return
|
|
163
221
|
const config = (this.constructor as typeof WippyElement).wippyConfig
|
|
164
222
|
const { props, errors } = parseProps(this, config.propsSchema)
|
|
165
223
|
if (config.validateProps) {
|
|
@@ -173,13 +231,23 @@ export abstract class WippyElement<Props = Record<string, unknown>> extends HTML
|
|
|
173
231
|
/** Called right after shadow DOM is attached, before CSS or container. */
|
|
174
232
|
protected onInit(_shadow: ShadowRoot): void {}
|
|
175
233
|
|
|
176
|
-
/**
|
|
234
|
+
/**
|
|
235
|
+
* Called after shadow DOM, CSS, and container are ready. Mount your
|
|
236
|
+
* framework here.
|
|
237
|
+
*
|
|
238
|
+
* Invoked on first connect AND on every subsequent reconnect (move-style
|
|
239
|
+
* DOM op: detach → reattach). On reconnect the same `container` instance
|
|
240
|
+
* is reused. Adapters that hold a framework app handle should either
|
|
241
|
+
* detect `isRemount === true` and re-mount on the existing container, or
|
|
242
|
+
* leave the previous app intact across the detach/reattach cycle.
|
|
243
|
+
*/
|
|
177
244
|
protected abstract onMount(
|
|
178
245
|
shadow: ShadowRoot,
|
|
179
246
|
container: HTMLElement,
|
|
180
247
|
initialProps: Props,
|
|
181
248
|
initialErrors: string[],
|
|
182
249
|
initialContent?: string | null,
|
|
250
|
+
isRemount?: boolean,
|
|
183
251
|
): void
|
|
184
252
|
|
|
185
253
|
/** Called after internals state is set to ready, before the `load` event. */
|
package/src/host-bus.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout bus types shared between `@wippy-fe/webcomponent-core` and
|
|
3
|
+
* `@wippy-fe/webcomponent-vue`. Mirror of the host-side
|
|
4
|
+
* `LayoutBusBound` / `BroadcastEnvelope` shapes; structurally
|
|
5
|
+
* compatible so a host-mounted web component can read its
|
|
6
|
+
* `__wippyHostBus` property without importing host-private types.
|
|
7
|
+
*
|
|
8
|
+
* The host (gen-2-chat managed-layout shell) sets:
|
|
9
|
+
* - `el.dataset.wippyPanelId` (string)
|
|
10
|
+
* - `(el as { __wippyHostBus?: LayoutBusBound }).__wippyHostBus`
|
|
11
|
+
* BEFORE the element is appended to the DOM. Web components see them
|
|
12
|
+
* on first `connectedCallback` tick.
|
|
13
|
+
*
|
|
14
|
+
* Outside a managed-layout host, both are absent — read code MUST
|
|
15
|
+
* tolerate `undefined`.
|
|
16
|
+
*
|
|
17
|
+
* The interface bodies live in `@wippy-fe/shared` so the host can
|
|
18
|
+
* import them without dragging in this package's `@wippy-fe/proxy`
|
|
19
|
+
* runtime dependency. Consumers continue to import from
|
|
20
|
+
* `@wippy-fe/webcomponent-core` (this re-export) for backward compat.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { LayoutBusBound } from '@wippy-fe/shared'
|
|
24
|
+
|
|
25
|
+
export type { BroadcastEnvelope, LayoutBusBound } from '@wippy-fe/shared'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read the host bus attached to an element by `ComponentResolver` in
|
|
29
|
+
* the managed-layout host. Returns `null` outside a managed-layout
|
|
30
|
+
* host (or before the element is connected). Stable across element
|
|
31
|
+
* remounts of the same panel — the resolver re-attaches a fresh bus
|
|
32
|
+
* each time.
|
|
33
|
+
*/
|
|
34
|
+
export function getWippyHostBus(el: HTMLElement): LayoutBusBound | null {
|
|
35
|
+
return (el as { __wippyHostBus?: LayoutBusBound }).__wippyHostBus ?? null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read the panelId attribute set by `ComponentResolver`. `null`
|
|
40
|
+
* outside a managed-layout host.
|
|
41
|
+
*/
|
|
42
|
+
export function getWippyPanelId(el: HTMLElement): string | null {
|
|
43
|
+
return el.dataset.wippyPanelId ?? null
|
|
44
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
import { getWippyHost } from './host-scope.ts'
|
|
6
|
+
|
|
7
|
+
describe('getWippyHost', () => {
|
|
8
|
+
it('returns the wrapper attached as __wippyHost', () => {
|
|
9
|
+
const el = document.createElement('div')
|
|
10
|
+
const fake = { layout: { broadcast: () => {} } }
|
|
11
|
+
;(el as unknown as { __wippyHost: unknown }).__wippyHost = fake
|
|
12
|
+
expect(getWippyHost(el)).toBe(fake)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('returns null when no wrapper attached', () => {
|
|
16
|
+
const el = document.createElement('div')
|
|
17
|
+
expect(getWippyHost(el)).toBeNull()
|
|
18
|
+
})
|
|
19
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Element-attached host wrapper reader for web components mounted as
|
|
3
|
+
* `kind: 'component'` panels in the gen-2-chat managed-layout shell.
|
|
4
|
+
*
|
|
5
|
+
* The host stamps a panel-scoped wrapper as `__wippyHost` on each
|
|
6
|
+
* element BEFORE it is appended to the DOM (see the host's
|
|
7
|
+
* `panel-context-wires.ts` and `host-scope.ts`). Consumer web
|
|
8
|
+
* components read it back via `getWippyHost(this)` in
|
|
9
|
+
* `connectedCallback` and onwards.
|
|
10
|
+
*
|
|
11
|
+
* The wrapper is **constructed by the host** — this package only
|
|
12
|
+
* exposes the reader. Outside a managed-layout host (raw mounts,
|
|
13
|
+
* compat shells, tests without the connector) the property is absent
|
|
14
|
+
* and `getWippyHost` returns `null`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Read the panel-scoped host wrapper attached to an element by the
|
|
19
|
+
* managed-layout shell's content resolvers. Returns `null` outside a
|
|
20
|
+
* managed-layout host (or before the element is connected).
|
|
21
|
+
*
|
|
22
|
+
* Subclass authors of `WippyElement` / `WippyVueElement` access this
|
|
23
|
+
* via `this.host` / `useHost()` and rarely need to call this directly.
|
|
24
|
+
* The helper is exposed for raw `HTMLElement` consumers.
|
|
25
|
+
*/
|
|
26
|
+
export function getWippyHost<H = unknown>(el: HTMLElement): H | null {
|
|
27
|
+
return ((el as unknown) as { __wippyHost?: H }).__wippyHost ?? null
|
|
28
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export {
|
|
3
|
-
export {
|
|
1
|
+
export { define, WippyElement } from './base-element.ts'
|
|
2
|
+
export { injectInlineCss, loadHostCss } from './css-loader.ts'
|
|
3
|
+
export { getWippyHostBus, getWippyPanelId } from './host-bus.ts'
|
|
4
|
+
export type { BroadcastEnvelope, LayoutBusBound } from './host-bus.ts'
|
|
5
|
+
export { getWippyHost } from './host-scope.ts'
|
|
6
|
+
export { attrToCamel, parseProps } from './prop-parser.ts'
|
|
7
|
+
export type { ParseResult } from './prop-parser.ts'
|
|
4
8
|
export type {
|
|
9
|
+
HostCssKey,
|
|
5
10
|
WippyElementConfig,
|
|
6
|
-
WippyPropsSchema,
|
|
7
11
|
WippyPropDefinition,
|
|
8
|
-
|
|
12
|
+
WippyPropsSchema,
|
|
9
13
|
} from './types.ts'
|
|
10
|
-
export type { ParseResult } from './prop-parser.ts'
|
package/src/prop-parser.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { WippyPropDefinition, WippyPropsSchema } from './types.ts'
|
|
2
2
|
|
|
3
3
|
export interface ParseResult {
|
|
4
4
|
props: Record<string, unknown>
|
|
@@ -16,20 +16,22 @@ export function attrToCamel(attr: string): string {
|
|
|
16
16
|
/**
|
|
17
17
|
* Parses a single attribute value according to its schema definition.
|
|
18
18
|
*/
|
|
19
|
-
function parseValue(attr: string, raw: string, def: WippyPropDefinition): { value: unknown
|
|
19
|
+
function parseValue(attr: string, raw: string, def: WippyPropDefinition): { value: unknown, error?: string } {
|
|
20
20
|
switch (def.type) {
|
|
21
21
|
case 'string':
|
|
22
22
|
return { value: raw }
|
|
23
23
|
|
|
24
24
|
case 'number': {
|
|
25
|
-
const n = parseFloat(raw)
|
|
26
|
-
if (isNaN(n))
|
|
25
|
+
const n = Number.parseFloat(raw)
|
|
26
|
+
if (Number.isNaN(n))
|
|
27
|
+
return { value: undefined, error: `Invalid ${attr}: expected a number` }
|
|
27
28
|
return { value: n }
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
case 'integer': {
|
|
31
|
-
const n = parseInt(raw, 10)
|
|
32
|
-
if (isNaN(n))
|
|
32
|
+
const n = Number.parseInt(raw, 10)
|
|
33
|
+
if (Number.isNaN(n))
|
|
34
|
+
return { value: undefined, error: `Invalid ${attr}: expected an integer` }
|
|
33
35
|
return { value: n }
|
|
34
36
|
}
|
|
35
37
|
|
|
@@ -45,7 +47,8 @@ function parseValue(attr: string, raw: string, def: WippyPropDefinition): { valu
|
|
|
45
47
|
return { value: undefined, error: `Invalid ${attr}: expected a JSON array` }
|
|
46
48
|
}
|
|
47
49
|
return { value: parsed }
|
|
48
|
-
}
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
49
52
|
return { value: undefined, error: `Invalid ${attr}: must be valid JSON` }
|
|
50
53
|
}
|
|
51
54
|
}
|
|
@@ -78,7 +81,8 @@ export function parseProps(element: HTMLElement, schema: WippyPropsSchema): Pars
|
|
|
78
81
|
const result = parseValue(attr, raw, def)
|
|
79
82
|
if (result.error) {
|
|
80
83
|
errors.push(result.error)
|
|
81
|
-
}
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
82
86
|
props[camel] = result.value
|
|
83
87
|
}
|
|
84
88
|
}
|