@wippy-fe/webcomponent-core 0.0.6
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/README.md +202 -0
- package/package.json +17 -0
- package/src/base-element.ts +212 -0
- package/src/css-loader.ts +39 -0
- package/src/index.ts +10 -0
- package/src/prop-parser.ts +87 -0
- package/src/types.ts +131 -0
package/README.md
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# @wippy-fe/webcomponent-core
|
|
2
|
+
|
|
3
|
+
Framework-agnostic base class for building Wippy web components. Handles the boilerplate every component needs — shadow DOM, CSS loading, schema-driven prop parsing, ElementInternals state, and lifecycle events — so your subclass only deals with framework-specific mounting.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
- **Shadow DOM setup** with configurable mode (`open`/`closed`) and container `<div>`
|
|
8
|
+
- **Host CSS inheritance** — loads Wippy platform CSS into the shadow root so the component matches the host theme
|
|
9
|
+
- **Inline CSS injection** — injects component-specific styles from `?inline` imports
|
|
10
|
+
- **Schema-driven prop parsing** — reads `wippy.props` from package.json and auto-converts attributes to typed JS values
|
|
11
|
+
- **ElementInternals** — manages `loading` → `ready` / `error` states
|
|
12
|
+
- **Lifecycle hooks** — `onInit`, `onMount`, `onReady`, `onError`, `onUnmount`, `onPropsChanged`
|
|
13
|
+
- **Lifecycle events** — emits `load`, `unload`, `error` as CustomEvents that cross shadow boundaries
|
|
14
|
+
- **Icon registration** — calls `addIcons(addCollection)` from `@wippy-fe/proxy`
|
|
15
|
+
|
|
16
|
+
## What it does NOT do
|
|
17
|
+
|
|
18
|
+
- Any framework setup (Vue, React, Svelte, etc.) — that's for framework-specific packages like `@wippy-fe/webcomponent-vue`
|
|
19
|
+
- Component registration — use `define(import.meta.url, YourElement)` from this package
|
|
20
|
+
- State management — the base class is stateless; your framework layer manages state
|
|
21
|
+
|
|
22
|
+
## CSS: How Styling Works in Shadow DOM
|
|
23
|
+
|
|
24
|
+
Shadow DOM blocks style inheritance from the host page. A web component must explicitly bring in any styles it needs. There are two mechanisms:
|
|
25
|
+
|
|
26
|
+
### Inline CSS (`inlineCss`)
|
|
27
|
+
|
|
28
|
+
Your component's **own** styles — Tailwind utilities, custom classes, layout rules. Bundled at build time via Vite's `?inline` import and injected **synchronously** into the shadow root before mount.
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import stylesText from './styles.css?inline'
|
|
32
|
+
|
|
33
|
+
static get wippyConfig() {
|
|
34
|
+
return {
|
|
35
|
+
propsSchema: pkg.wippy.props,
|
|
36
|
+
inlineCss: stylesText, // your component's CSS, injected immediately
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Every component with its own stylesheet needs this.
|
|
42
|
+
|
|
43
|
+
### Host CSS Inheritance (`hostCssKeys`)
|
|
44
|
+
|
|
45
|
+
Shared **platform CSS** loaded at runtime from the Wippy host into the shadow root. This is how your component inherits the host app's look-and-feel. Loaded **asynchronously** (non-blocking — the component becomes interactive before CSS finishes loading).
|
|
46
|
+
|
|
47
|
+
| Key | What it provides | When to include |
|
|
48
|
+
|-----|-----------------|-----------------|
|
|
49
|
+
| `fontCssUrl` | Platform font definitions | Almost always — skip only if using fully custom fonts |
|
|
50
|
+
| `themeConfigUrl` | CSS custom properties (color scales, spacing, radii, etc.) matching the host theme | **Recommended for all components.** This is what makes your component look consistent with the host. At dev time, a local `theme-config.css` provides fallback values; at runtime the host injects the real theme. |
|
|
51
|
+
| `primeVueCssUrl` | PrimeVue component classes (`p-button`, `p-input`, etc.) in unstyled mode, styled to match the host | **Include if using any PrimeVue components** (buttons, forms, tables, etc.). Skip only for fully custom UI with zero PrimeVue usage. |
|
|
52
|
+
| `markdownCssUrl` | Styles for rendered markdown blocks | **Include only if rendering markdown.** |
|
|
53
|
+
| `iframeCssUrl` | Scrollbar styling and iframe-related styles | **Recommended for all components** so scrollbars match the host. |
|
|
54
|
+
|
|
55
|
+
**Choose what your component actually uses:**
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
// Standard component using PrimeVue UI (most common)
|
|
59
|
+
hostCssKeys: ['fontCssUrl', 'themeConfigUrl', 'primeVueCssUrl', 'iframeCssUrl']
|
|
60
|
+
|
|
61
|
+
// Also renders markdown content
|
|
62
|
+
hostCssKeys: ['fontCssUrl', 'themeConfigUrl', 'primeVueCssUrl', 'markdownCssUrl', 'iframeCssUrl']
|
|
63
|
+
|
|
64
|
+
// Minimal: just theme variables, no PrimeVue
|
|
65
|
+
hostCssKeys: ['fontCssUrl', 'themeConfigUrl', 'iframeCssUrl']
|
|
66
|
+
|
|
67
|
+
// Fully self-styled, no host inheritance
|
|
68
|
+
hostCssKeys: []
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Default (when omitted): all five keys.
|
|
72
|
+
|
|
73
|
+
> **Note on dev-time duplication:** Your component's `styles.css` may import `theme-config.css` for local dev (so Tailwind/PostCSS can resolve theme variables). At runtime, the host provides the real theme via `themeConfigUrl`. This duplication is a known trade-off — the host's runtime values take precedence.
|
|
74
|
+
|
|
75
|
+
## API Reference
|
|
76
|
+
|
|
77
|
+
### `WippyElement` (abstract class)
|
|
78
|
+
|
|
79
|
+
Extend this class and implement the hooks you need.
|
|
80
|
+
|
|
81
|
+
#### Static getters to override
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
static get wippyConfig(): WippyElementConfig
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Returns the component configuration. **Must be overridden** — the default returns an empty schema.
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
static get observedAttributes(): string[]
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Automatically derived from `wippyConfig.propsSchema.properties` + `extraObservedAttributes`. You rarely need to override this.
|
|
94
|
+
|
|
95
|
+
#### Lifecycle hooks
|
|
96
|
+
|
|
97
|
+
All hooks are optional except `onMount` and `onUnmount` (abstract).
|
|
98
|
+
|
|
99
|
+
| Hook | When it runs | Use case |
|
|
100
|
+
|------|-------------|----------|
|
|
101
|
+
| `onInit(shadow)` | After shadow DOM attached, before CSS/container | Add extra DOM elements, configure shadow root |
|
|
102
|
+
| `onMount(shadow, container, props, errors)` | After CSS, container, icons, and props are ready | **Abstract.** Mount your framework here |
|
|
103
|
+
| `onReady()` | After internals state → `ready`, before `load` event | Post-mount logic, telemetry, deferred setup |
|
|
104
|
+
| `onError(error)` | When `connectedCallback` throws | Custom error reporting (default: `console.error`) |
|
|
105
|
+
| `onUnmount()` | During `disconnectedCallback` | **Abstract.** Tear down framework |
|
|
106
|
+
| `onPropsChanged(props, errors)` | When observed attributes change | Push new values into framework reactivity |
|
|
107
|
+
|
|
108
|
+
**Full lifecycle order:**
|
|
109
|
+
1. Shadow DOM attached (`open` or `closed`)
|
|
110
|
+
2. `onInit(shadow)`
|
|
111
|
+
3. Inline CSS injected (sync)
|
|
112
|
+
4. Host CSS loading started (async, non-blocking)
|
|
113
|
+
5. Container `<div>` created and appended
|
|
114
|
+
6. Icons registered
|
|
115
|
+
7. Props parsed from attributes
|
|
116
|
+
8. `onMount(shadow, container, props, errors)`
|
|
117
|
+
9. Internals state → `ready`
|
|
118
|
+
10. `onReady()`
|
|
119
|
+
11. `load` event emitted
|
|
120
|
+
|
|
121
|
+
On error: `onError(error)` → state → `error` → `error` event
|
|
122
|
+
On disconnect: `onUnmount()` → `unload` event → states cleared
|
|
123
|
+
|
|
124
|
+
#### Utility
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
protected emitEvent(eventName: string, detail?: unknown): void
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Dispatches a `CustomEvent` with `bubbles: true, composed: true`.
|
|
131
|
+
|
|
132
|
+
### `define(importMetaUrl, ComponentClass)`
|
|
133
|
+
|
|
134
|
+
Re-exported from `@wippy-fe/proxy`. Registers the custom element if the import URL contains a `declare-tag` parameter.
|
|
135
|
+
|
|
136
|
+
### `parseProps(element, schema)`
|
|
137
|
+
|
|
138
|
+
Parses all attributes on an element according to a `WippyPropsSchema`. Returns `{ props, errors }`.
|
|
139
|
+
|
|
140
|
+
### `loadHostCss(shadow, keys?)`
|
|
141
|
+
|
|
142
|
+
Loads host CSS URLs into a shadow root. Non-blocking (returns a Promise).
|
|
143
|
+
|
|
144
|
+
### `injectInlineCss(shadow, text)`
|
|
145
|
+
|
|
146
|
+
Injects a `<style>` element with the given CSS text into the shadow root. Synchronous.
|
|
147
|
+
|
|
148
|
+
### `attrToCamel(attr)`
|
|
149
|
+
|
|
150
|
+
Converts kebab-case (`allowed-types`) to camelCase (`allowedTypes`).
|
|
151
|
+
|
|
152
|
+
## Types
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
interface WippyElementConfig {
|
|
156
|
+
propsSchema: WippyPropsSchema
|
|
157
|
+
shadowMode?: 'open' | 'closed' // default: 'open'
|
|
158
|
+
hostCssKeys?: HostCssKey[] // default: font + theme + primeVue + markdown
|
|
159
|
+
inlineCss?: string
|
|
160
|
+
containerClasses?: string[] // default: none
|
|
161
|
+
extraObservedAttributes?: string[]
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
interface WippyPropsSchema {
|
|
165
|
+
type?: string
|
|
166
|
+
properties: Record<string, WippyPropDefinition>
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
interface WippyPropDefinition {
|
|
170
|
+
type: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object'
|
|
171
|
+
default?: unknown
|
|
172
|
+
description?: string
|
|
173
|
+
items?: { type: string }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
type HostCssKey = 'fontCssUrl' | 'themeConfigUrl' | 'primeVueCssUrl' | 'markdownCssUrl' | 'iframeCssUrl'
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Migration from monolithic pattern
|
|
180
|
+
|
|
181
|
+
**Before** — 170+ lines of boilerplate in every component:
|
|
182
|
+
```ts
|
|
183
|
+
class MyElement extends HTMLElement {
|
|
184
|
+
// Manual shadow DOM, CSS loading, prop parsing, Vue setup, ...
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
**After** — extend `WippyElement` (or `WippyVueElement` for Vue components):
|
|
189
|
+
```ts
|
|
190
|
+
class MyElement extends WippyElement {
|
|
191
|
+
static get wippyConfig() {
|
|
192
|
+
return {
|
|
193
|
+
propsSchema: pkg.wippy.props,
|
|
194
|
+
hostCssKeys: ['fontCssUrl', 'themeConfigUrl', 'primeVueCssUrl', 'iframeCssUrl'],
|
|
195
|
+
containerClasses: ['h-full'],
|
|
196
|
+
inlineCss: stylesText,
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
onMount(shadow, container, props, errors) { /* framework setup */ }
|
|
200
|
+
onUnmount() { /* cleanup */ }
|
|
201
|
+
}
|
|
202
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wippy-fe/webcomponent-core",
|
|
3
|
+
"version": "0.0.6",
|
|
4
|
+
"description": "Framework-agnostic base class for Wippy web components — shadow DOM, CSS loading, schema-driven prop parsing, and lifecycle management.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"src/",
|
|
10
|
+
"package.json"
|
|
11
|
+
],
|
|
12
|
+
"peerDependencies": {
|
|
13
|
+
"@wippy-fe/proxy": "^0.0.6",
|
|
14
|
+
"@iconify/vue": "^5.0.0"
|
|
15
|
+
},
|
|
16
|
+
"license": "UNLICENSED"
|
|
17
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { addIcons, define } from '@wippy-fe/proxy'
|
|
2
|
+
import { addCollection } from '@iconify/vue'
|
|
3
|
+
import { loadHostCss, injectInlineCss } from './css-loader.ts'
|
|
4
|
+
import { parseProps } from './prop-parser.ts'
|
|
5
|
+
import type { WippyElementConfig } from './types.ts'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Abstract base class for Wippy web components.
|
|
9
|
+
*
|
|
10
|
+
* Generic over `Props` — the parsed prop object type. This types `onMount`,
|
|
11
|
+
* `onPropsChanged`, and `validateProps`. Defaults to `Record<string, unknown>`.
|
|
12
|
+
*
|
|
13
|
+
* Lifecycle order:
|
|
14
|
+
* 1. shadow DOM attached
|
|
15
|
+
* 2. `onInit(shadow)` — hook for early shadow DOM customization
|
|
16
|
+
* 3. inline CSS injected
|
|
17
|
+
* 4. host CSS loading started (async, non-blocking)
|
|
18
|
+
* 5. container div created and appended
|
|
19
|
+
* 6. icons registered
|
|
20
|
+
* 7. props parsed from attributes + custom validation
|
|
21
|
+
* 8. `onMount(shadow, container, props, errors)` — framework setup
|
|
22
|
+
* 9. internals state → ready
|
|
23
|
+
* 10. `onReady()` — post-mount hook
|
|
24
|
+
* 11. `load` event emitted
|
|
25
|
+
*
|
|
26
|
+
* On error: `onError(error)` → internals state → error → `error` event
|
|
27
|
+
* On disconnect: `onUnmount()` → `unload` event → internals cleared
|
|
28
|
+
*/
|
|
29
|
+
export abstract class WippyElement<Props = Record<string, unknown>> extends HTMLElement {
|
|
30
|
+
private _internals!: ElementInternals
|
|
31
|
+
private _contentObserver: MutationObserver | null = null
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Override to provide the component's configuration.
|
|
35
|
+
* Must be static because `observedAttributes` reads it before construction.
|
|
36
|
+
*
|
|
37
|
+
* Specify the generic to get typed `validateProps`:
|
|
38
|
+
* ```ts
|
|
39
|
+
* static get wippyConfig(): WippyElementConfig<MyProps> { ... }
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
static get wippyConfig(): WippyElementConfig {
|
|
43
|
+
return { propsSchema: { properties: {} } }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Derived from the props schema + any `extraObservedAttributes`.
|
|
48
|
+
*/
|
|
49
|
+
static get observedAttributes(): string[] {
|
|
50
|
+
const config = this.wippyConfig
|
|
51
|
+
const schemaAttrs = Object.keys(config.propsSchema.properties)
|
|
52
|
+
const extra = config.extraObservedAttributes ?? []
|
|
53
|
+
return [...schemaAttrs, ...extra]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
constructor() {
|
|
57
|
+
super()
|
|
58
|
+
this._internals = this.attachInternals()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Emit a CustomEvent that bubbles and crosses shadow DOM boundaries.
|
|
63
|
+
*/
|
|
64
|
+
protected emitEvent(eventName: string, detail?: unknown): void {
|
|
65
|
+
this.dispatchEvent(new CustomEvent(eventName, {
|
|
66
|
+
bubbles: true,
|
|
67
|
+
composed: true,
|
|
68
|
+
detail,
|
|
69
|
+
}))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Lifecycle ──────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
connectedCallback(): void {
|
|
75
|
+
this._internals.states.add('loading')
|
|
76
|
+
try {
|
|
77
|
+
const config = (this.constructor as typeof WippyElement).wippyConfig
|
|
78
|
+
|
|
79
|
+
// 1. Shadow DOM (guard against reconnect — attachShadow throws if called twice)
|
|
80
|
+
const shadow = this.shadowRoot ?? this.attachShadow({ mode: config.shadowMode ?? 'open' })
|
|
81
|
+
|
|
82
|
+
// 2. Early hook — customize shadow before CSS/container
|
|
83
|
+
this.onInit(shadow)
|
|
84
|
+
|
|
85
|
+
// 3. Inline CSS
|
|
86
|
+
if (config.inlineCss) {
|
|
87
|
+
injectInlineCss(shadow, config.inlineCss)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 4. Host CSS (async, non-blocking)
|
|
91
|
+
if (config.hostCssKeys === undefined || config.hostCssKeys.length > 0) {
|
|
92
|
+
loadHostCss(shadow, config.hostCssKeys)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 5. Container
|
|
96
|
+
const container = document.createElement('div')
|
|
97
|
+
const classes = config.containerClasses ?? []
|
|
98
|
+
if (classes.length > 0) {
|
|
99
|
+
container.classList.add(...classes)
|
|
100
|
+
}
|
|
101
|
+
shadow.appendChild(container)
|
|
102
|
+
|
|
103
|
+
// 6. Icons
|
|
104
|
+
addIcons(addCollection)
|
|
105
|
+
|
|
106
|
+
// 7. Parse initial props + custom validation
|
|
107
|
+
const { props, errors } = parseProps(this, config.propsSchema)
|
|
108
|
+
if (config.validateProps) {
|
|
109
|
+
errors.push(...config.validateProps(props))
|
|
110
|
+
}
|
|
111
|
+
const typedProps = props as Props
|
|
112
|
+
|
|
113
|
+
// 7b. Extract children content (if contentTemplate configured)
|
|
114
|
+
let initialContent: string | null = null
|
|
115
|
+
if (config.contentTemplate) {
|
|
116
|
+
initialContent = this._extractContent(config.contentTemplate)
|
|
117
|
+
this._contentObserver = new MutationObserver(() => {
|
|
118
|
+
const content = this._extractContent(config.contentTemplate!)
|
|
119
|
+
this.onContentChanged(content)
|
|
120
|
+
})
|
|
121
|
+
this._contentObserver.observe(this, {
|
|
122
|
+
childList: true,
|
|
123
|
+
characterData: true,
|
|
124
|
+
subtree: true,
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 8. Framework mount
|
|
129
|
+
this.onMount(shadow, container, typedProps, errors, initialContent)
|
|
130
|
+
|
|
131
|
+
// 9. Ready
|
|
132
|
+
this._internals.states.delete('loading')
|
|
133
|
+
this._internals.states.add('ready')
|
|
134
|
+
|
|
135
|
+
// 10. Post-mount hook
|
|
136
|
+
this.onReady()
|
|
137
|
+
|
|
138
|
+
// 11. Emit load
|
|
139
|
+
this.emitEvent('load')
|
|
140
|
+
} catch (error) {
|
|
141
|
+
this.onError(error)
|
|
142
|
+
this._internals.states.delete('loading')
|
|
143
|
+
this._internals.states.add('error')
|
|
144
|
+
this.emitEvent('error', {
|
|
145
|
+
message: error instanceof Error ? error.message : String(error),
|
|
146
|
+
error,
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
disconnectedCallback(): void {
|
|
152
|
+
if (this._contentObserver) {
|
|
153
|
+
this._contentObserver.disconnect()
|
|
154
|
+
this._contentObserver = null
|
|
155
|
+
}
|
|
156
|
+
this.onUnmount()
|
|
157
|
+
this.emitEvent('unload')
|
|
158
|
+
this._internals.states.clear()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
attributeChangedCallback(_name: string, oldVal: string | null, newVal: string | null): void {
|
|
162
|
+
if (oldVal === newVal) return
|
|
163
|
+
const config = (this.constructor as typeof WippyElement).wippyConfig
|
|
164
|
+
const { props, errors } = parseProps(this, config.propsSchema)
|
|
165
|
+
if (config.validateProps) {
|
|
166
|
+
errors.push(...config.validateProps(props))
|
|
167
|
+
}
|
|
168
|
+
this.onPropsChanged(props as Props, errors)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── Hooks ──────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
/** Called right after shadow DOM is attached, before CSS or container. */
|
|
174
|
+
protected onInit(_shadow: ShadowRoot): void {}
|
|
175
|
+
|
|
176
|
+
/** Called once after shadow DOM, CSS, and container are ready. Mount your framework here. */
|
|
177
|
+
protected abstract onMount(
|
|
178
|
+
shadow: ShadowRoot,
|
|
179
|
+
container: HTMLElement,
|
|
180
|
+
initialProps: Props,
|
|
181
|
+
initialErrors: string[],
|
|
182
|
+
initialContent?: string | null,
|
|
183
|
+
): void
|
|
184
|
+
|
|
185
|
+
/** Called after internals state is set to ready, before the `load` event. */
|
|
186
|
+
protected onReady(): void {}
|
|
187
|
+
|
|
188
|
+
/** Called when connectedCallback throws. Default logs to console. */
|
|
189
|
+
protected onError(error: unknown): void {
|
|
190
|
+
console.error(`${this.constructor.name} initialization failed:`, error)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Called during disconnectedCallback — clean up framework resources. */
|
|
194
|
+
protected abstract onUnmount(): void
|
|
195
|
+
|
|
196
|
+
/** Called when observed attributes change. Override to update framework state. */
|
|
197
|
+
protected onPropsChanged(_props: Props, _errors: string[]): void {}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Extract text from a child `<template data-type="...">` element.
|
|
201
|
+
* Uses `.content.textContent` since `<template>` stores content in a DocumentFragment.
|
|
202
|
+
*/
|
|
203
|
+
private _extractContent(dataType: string): string | null {
|
|
204
|
+
const tpl = this.querySelector(`template[data-type="${dataType}"]`) as HTMLTemplateElement | null
|
|
205
|
+
return tpl?.content.textContent?.trim() ?? null
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Called when child `<template>` content changes. Override to update framework state. */
|
|
209
|
+
protected onContentChanged(_content: string | null): void {}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export { define }
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { hostCss, loadCss } from '@wippy-fe/proxy'
|
|
2
|
+
import type { HostCssKey } from './types.ts'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_HOST_CSS_KEYS: HostCssKey[] = [
|
|
5
|
+
'fontCssUrl',
|
|
6
|
+
'themeConfigUrl',
|
|
7
|
+
'primeVueCssUrl',
|
|
8
|
+
'markdownCssUrl',
|
|
9
|
+
'iframeCssUrl',
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Loads host CSS URLs into the shadow root as `<style>` elements.
|
|
14
|
+
* Non-blocking — returns a promise but the component doesn't need to await it.
|
|
15
|
+
*/
|
|
16
|
+
export function loadHostCss(shadow: ShadowRoot, keys?: HostCssKey[]): Promise<void> {
|
|
17
|
+
const cssKeys = keys ?? DEFAULT_HOST_CSS_KEYS
|
|
18
|
+
return Promise.all(
|
|
19
|
+
cssKeys.map((key) => loadCss(hostCss[key])),
|
|
20
|
+
).then((cssChunks) => {
|
|
21
|
+
for (const css of cssChunks) {
|
|
22
|
+
const style = document.createElement('style')
|
|
23
|
+
style.textContent = css
|
|
24
|
+
style.setAttribute('role', '@wippy/host-css')
|
|
25
|
+
shadow.appendChild(style)
|
|
26
|
+
}
|
|
27
|
+
}).catch((err) => {
|
|
28
|
+
console.warn('Failed to load host CSS:', err)
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Injects inline CSS text into the shadow root.
|
|
34
|
+
*/
|
|
35
|
+
export function injectInlineCss(shadow: ShadowRoot, text: string): void {
|
|
36
|
+
const style = document.createElement('style')
|
|
37
|
+
style.textContent = text
|
|
38
|
+
shadow.appendChild(style)
|
|
39
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { WippyElement, define } from './base-element.ts'
|
|
2
|
+
export { loadHostCss, injectInlineCss } from './css-loader.ts'
|
|
3
|
+
export { parseProps, attrToCamel } from './prop-parser.ts'
|
|
4
|
+
export type {
|
|
5
|
+
WippyElementConfig,
|
|
6
|
+
WippyPropsSchema,
|
|
7
|
+
WippyPropDefinition,
|
|
8
|
+
HostCssKey,
|
|
9
|
+
} from './types.ts'
|
|
10
|
+
export type { ParseResult } from './prop-parser.ts'
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { WippyPropsSchema, WippyPropDefinition } from './types.ts'
|
|
2
|
+
|
|
3
|
+
export interface ParseResult {
|
|
4
|
+
props: Record<string, unknown>
|
|
5
|
+
errors: string[]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Converts a kebab-case attribute name to camelCase.
|
|
10
|
+
* `allowed-types` → `allowedTypes`
|
|
11
|
+
*/
|
|
12
|
+
export function attrToCamel(attr: string): string {
|
|
13
|
+
return attr.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase())
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parses a single attribute value according to its schema definition.
|
|
18
|
+
*/
|
|
19
|
+
function parseValue(attr: string, raw: string, def: WippyPropDefinition): { value: unknown; error?: string } {
|
|
20
|
+
switch (def.type) {
|
|
21
|
+
case 'string':
|
|
22
|
+
return { value: raw }
|
|
23
|
+
|
|
24
|
+
case 'number': {
|
|
25
|
+
const n = parseFloat(raw)
|
|
26
|
+
if (isNaN(n)) return { value: undefined, error: `Invalid ${attr}: expected a number` }
|
|
27
|
+
return { value: n }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
case 'integer': {
|
|
31
|
+
const n = parseInt(raw, 10)
|
|
32
|
+
if (isNaN(n)) return { value: undefined, error: `Invalid ${attr}: expected an integer` }
|
|
33
|
+
return { value: n }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
case 'boolean':
|
|
37
|
+
// Presence of the attribute means true (HTML convention), explicit "false" means false
|
|
38
|
+
return { value: raw !== 'false' }
|
|
39
|
+
|
|
40
|
+
case 'array':
|
|
41
|
+
case 'object': {
|
|
42
|
+
try {
|
|
43
|
+
const parsed = JSON.parse(raw)
|
|
44
|
+
if (def.type === 'array' && !Array.isArray(parsed)) {
|
|
45
|
+
return { value: undefined, error: `Invalid ${attr}: expected a JSON array` }
|
|
46
|
+
}
|
|
47
|
+
return { value: parsed }
|
|
48
|
+
} catch {
|
|
49
|
+
return { value: undefined, error: `Invalid ${attr}: must be valid JSON` }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
default:
|
|
54
|
+
return { value: raw }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parses all attributes on an element according to its props schema.
|
|
60
|
+
* Returns `{ props, errors }` where errors is an array of human-readable messages.
|
|
61
|
+
*/
|
|
62
|
+
export function parseProps(element: HTMLElement, schema: WippyPropsSchema): ParseResult {
|
|
63
|
+
const props: Record<string, unknown> = {}
|
|
64
|
+
const errors: string[] = []
|
|
65
|
+
|
|
66
|
+
for (const [attr, def] of Object.entries(schema.properties)) {
|
|
67
|
+
const raw = element.getAttribute(attr)
|
|
68
|
+
const camel = attrToCamel(attr)
|
|
69
|
+
|
|
70
|
+
if (raw === null) {
|
|
71
|
+
// Attribute not set — use default if available
|
|
72
|
+
if (def.default !== undefined) {
|
|
73
|
+
props[camel] = def.default
|
|
74
|
+
}
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const result = parseValue(attr, raw, def)
|
|
79
|
+
if (result.error) {
|
|
80
|
+
errors.push(result.error)
|
|
81
|
+
} else {
|
|
82
|
+
props[camel] = result.value
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { props, errors }
|
|
87
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON-Schema-style property descriptor used for attribute→prop parsing.
|
|
3
|
+
*/
|
|
4
|
+
export interface WippyPropDefinition {
|
|
5
|
+
type: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object'
|
|
6
|
+
default?: unknown
|
|
7
|
+
description?: string
|
|
8
|
+
items?: { type: string }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* JSON-Schema-style props block — matches the `wippy.props` field in package.json.
|
|
13
|
+
*
|
|
14
|
+
* Example:
|
|
15
|
+
* ```json
|
|
16
|
+
* {
|
|
17
|
+
* "type": "object",
|
|
18
|
+
* "properties": {
|
|
19
|
+
* "allowed-types": { "type": "array", "items": { "type": "string" } },
|
|
20
|
+
* "max-file-size": { "type": "number", "default": 104857600 }
|
|
21
|
+
* }
|
|
22
|
+
* }
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export interface WippyPropsSchema {
|
|
26
|
+
type?: string
|
|
27
|
+
properties: Record<string, WippyPropDefinition>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Keys that map to CSS URLs exposed by `@wippy-fe/proxy`'s `hostCss` object.
|
|
32
|
+
* Pass a subset to `hostCssKeys` in config to load only what you need.
|
|
33
|
+
*/
|
|
34
|
+
export type HostCssKey = 'fontCssUrl' | 'themeConfigUrl' | 'primeVueCssUrl' | 'markdownCssUrl' | 'iframeCssUrl'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Configuration object returned by `static get wippyConfig()`.
|
|
38
|
+
*
|
|
39
|
+
* Generic over `Props` so that `validateProps` receives the typed prop object.
|
|
40
|
+
* Use `WippyElementConfig<MyProps>` as the return type of your static getter
|
|
41
|
+
* to get typed validation:
|
|
42
|
+
*
|
|
43
|
+
* ```ts
|
|
44
|
+
* static get wippyConfig(): WippyElementConfig<MyProps> { ... }
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export interface WippyElementConfig<Props = Record<string, unknown>> {
|
|
48
|
+
/** JSON-schema props block (usually from package.json `wippy.props`). */
|
|
49
|
+
propsSchema: WippyPropsSchema
|
|
50
|
+
/** Shadow DOM mode. Defaults to `'open'`. */
|
|
51
|
+
shadowMode?: 'open' | 'closed'
|
|
52
|
+
/**
|
|
53
|
+
* Host CSS to inherit from the Wippy platform into this component's shadow DOM.
|
|
54
|
+
*
|
|
55
|
+
* Shadow DOM blocks style inheritance, so platform styles (theme variables, fonts,
|
|
56
|
+
* UI framework classes, etc.) must be explicitly loaded into each component's shadow root.
|
|
57
|
+
* The host app provides these CSS assets at runtime via `@wippy-fe/proxy`.
|
|
58
|
+
*
|
|
59
|
+
* Available keys:
|
|
60
|
+
* - `fontCssUrl` — Platform font definitions. Include unless using fully custom fonts.
|
|
61
|
+
* - `themeConfigUrl` — CSS custom properties (color scales, spacing, etc.) matching the
|
|
62
|
+
* host theme. **Recommended for all components** — gives your component the same look
|
|
63
|
+
* as the host app. At dev time, a local copy (`theme-config.css`) provides fallback
|
|
64
|
+
* values; at runtime, the host injects the real theme.
|
|
65
|
+
* - `primeVueCssUrl` — PrimeVue component classes (p-button, p-input, etc.) in unstyled
|
|
66
|
+
* mode, matching the host's appearance. **Include if using any PrimeVue components**
|
|
67
|
+
* (buttons, forms, tables, etc.). Skip only for fully custom UI with no PrimeVue.
|
|
68
|
+
* - `markdownCssUrl` — Styles for rendered markdown blocks. **Include only if your
|
|
69
|
+
* component renders markdown content.**
|
|
70
|
+
* - `iframeCssUrl` — Scrollbar styling and iframe-related styles. **Recommended for all
|
|
71
|
+
* components** so scrollbars look identical to the host app.
|
|
72
|
+
*
|
|
73
|
+
* Pass `[]` to skip host CSS entirely (fully self-styled component).
|
|
74
|
+
* Defaults to `['fontCssUrl', 'themeConfigUrl', 'primeVueCssUrl', 'markdownCssUrl', 'iframeCssUrl']`.
|
|
75
|
+
*/
|
|
76
|
+
hostCssKeys?: HostCssKey[]
|
|
77
|
+
/**
|
|
78
|
+
* Component-specific CSS text to inject into the shadow root (synchronous).
|
|
79
|
+
*
|
|
80
|
+
* Typically from a Vite `?inline` import of your component's stylesheet.
|
|
81
|
+
* This is your component's own styling — Tailwind utilities, custom classes, etc.
|
|
82
|
+
* Unlike `hostCssKeys` which loads platform CSS at runtime, this is bundled at build time.
|
|
83
|
+
*/
|
|
84
|
+
inlineCss?: string
|
|
85
|
+
/**
|
|
86
|
+
* Custom prop validation that runs after type parsing.
|
|
87
|
+
*
|
|
88
|
+
* Receives the already type-coerced props from the schema parser.
|
|
89
|
+
* Return an array of error messages for any invalid values, or an empty array if all valid.
|
|
90
|
+
* Errors are merged with any type-parsing errors and emitted as `invalid` events.
|
|
91
|
+
*
|
|
92
|
+
* Use this for domain-specific checks the JSON schema can't express:
|
|
93
|
+
* - Value ranges (`max-file-size` must be positive)
|
|
94
|
+
* - Array item types (`allowed-types` must be an array of strings)
|
|
95
|
+
* - Cross-prop constraints (if X is set, Y must also be set)
|
|
96
|
+
*
|
|
97
|
+
* Example:
|
|
98
|
+
* ```ts
|
|
99
|
+
* validateProps: (props) => {
|
|
100
|
+
* const errors: string[] = []
|
|
101
|
+
* if (typeof props.maxFileSize === 'number' && props.maxFileSize < 0) {
|
|
102
|
+
* errors.push('max-file-size must be a positive number')
|
|
103
|
+
* }
|
|
104
|
+
* return errors
|
|
105
|
+
* }
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
validateProps?: (props: Props) => string[]
|
|
109
|
+
/** CSS classes to add to the container div. Defaults to none. */
|
|
110
|
+
containerClasses?: string[]
|
|
111
|
+
/** Additional attribute names to observe beyond those in the props schema. */
|
|
112
|
+
extraObservedAttributes?: string[]
|
|
113
|
+
/**
|
|
114
|
+
* If set, reads text content from a child `<template data-type="...">` element.
|
|
115
|
+
* The value is the type to match, e.g. `'text/vnd.mermaid'`.
|
|
116
|
+
* Content is extracted once on mount and updated via MutationObserver.
|
|
117
|
+
* Props take priority over children content.
|
|
118
|
+
*
|
|
119
|
+
* Usage:
|
|
120
|
+
* ```html
|
|
121
|
+
* <example-mermaid>
|
|
122
|
+
* <template data-type="text/vnd.mermaid">graph TD; A --> B</template>
|
|
123
|
+
* </example-mermaid>
|
|
124
|
+
* ```
|
|
125
|
+
*
|
|
126
|
+
* Uses `<template>` instead of `<script>` because Vue templates strip script tags.
|
|
127
|
+
* The native `<template>` element is inert (not rendered) and works in both
|
|
128
|
+
* raw HTML and Vue SFC templates.
|
|
129
|
+
*/
|
|
130
|
+
contentTemplate?: string
|
|
131
|
+
}
|