@hot-page/fun 0.0.1
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 +879 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +705 -0
- package/package.json +57 -0
- package/src/index.ts +279 -0
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hot-page/fun",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Simple define helper for functional web components",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/hot-page/fun.git"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"main": "./dist/index.js",
|
|
11
|
+
"module": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"import": "./dist/index.js",
|
|
17
|
+
"source": "./src/index.ts"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"src"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"start": "vite",
|
|
26
|
+
"build": "tsc && vite build",
|
|
27
|
+
"preview": "vite preview",
|
|
28
|
+
"test": "wtr",
|
|
29
|
+
"prepublishOnly": "npm run build"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"custom",
|
|
33
|
+
"element",
|
|
34
|
+
"web",
|
|
35
|
+
"component",
|
|
36
|
+
"functional",
|
|
37
|
+
"reactive"
|
|
38
|
+
],
|
|
39
|
+
"author": "Hot Page",
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/hot-page/functional-element/issues"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"lit-html": "^3.3.1",
|
|
46
|
+
"signal-polyfill": "^0.2.2"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/chai": "^5.2.3",
|
|
50
|
+
"@web/dev-server-esbuild": "^1.0.5",
|
|
51
|
+
"@web/test-runner": "^0.20.2",
|
|
52
|
+
"@web/test-runner-playwright": "^0.11.1",
|
|
53
|
+
"chai": "^6.2.2",
|
|
54
|
+
"typescript": "^5.7.2",
|
|
55
|
+
"vite": "^7.2.4"
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { html, svg, render } from 'lit-html'
|
|
2
|
+
import { Signal } from 'signal-polyfill'
|
|
3
|
+
|
|
4
|
+
export { html, svg }
|
|
5
|
+
|
|
6
|
+
export type RenderFunction = () => ReturnType<typeof html>
|
|
7
|
+
export type CleanupFunction = () => void
|
|
8
|
+
export type EffectFunction = () => CleanupFunction | void
|
|
9
|
+
export type StylePropsFunction = (
|
|
10
|
+
props: Record<string, string | number | null>
|
|
11
|
+
) => void
|
|
12
|
+
|
|
13
|
+
type AttributeSignals<Attrs extends string> = {
|
|
14
|
+
[K in Attrs]: Signal.State<string | null>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type ComponentContext<Attrs extends string = never> =
|
|
18
|
+
AttributeSignals<Attrs> & {
|
|
19
|
+
internals: ElementInternals
|
|
20
|
+
effect: (fn: EffectFunction) => void
|
|
21
|
+
styleProps: StylePropsFunction
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type FunctionalComponent<Attrs extends string = never> =
|
|
25
|
+
(context: ComponentContext<Attrs>) => RenderFunction
|
|
26
|
+
|
|
27
|
+
export interface DefineOptions<Attrs extends string = never> {
|
|
28
|
+
setup: FunctionalComponent<Attrs>
|
|
29
|
+
tagName?: string
|
|
30
|
+
attributes?: Attrs[]
|
|
31
|
+
useShadow?: boolean
|
|
32
|
+
formAssociated?: boolean
|
|
33
|
+
styles?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const RESERVED_KEYS = new Set<string>(['internals', 'effect', 'styleProps'])
|
|
37
|
+
|
|
38
|
+
// Convert camelCase property key to kebab-case CSS custom property name.
|
|
39
|
+
// `hueShift` -> `--hue-shift`, `hue` -> `--hue`
|
|
40
|
+
function toCustomProperty(key: string): string {
|
|
41
|
+
const kebab = key
|
|
42
|
+
.replaceAll(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
43
|
+
.replaceAll(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
|
|
44
|
+
.toLowerCase()
|
|
45
|
+
return `--${kebab}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function lightElement(fn: FunctionalComponent): void
|
|
49
|
+
function lightElement(styles: string, fn: FunctionalComponent): void
|
|
50
|
+
function lightElement<Attrs extends string>(
|
|
51
|
+
observedAttributes: Attrs[],
|
|
52
|
+
fn: FunctionalComponent<Attrs>
|
|
53
|
+
): void
|
|
54
|
+
function lightElement<Attrs extends string>(
|
|
55
|
+
observedAttributes: Attrs[],
|
|
56
|
+
styles: string,
|
|
57
|
+
fn: FunctionalComponent<Attrs>
|
|
58
|
+
): void
|
|
59
|
+
function lightElement<Attrs extends string>(
|
|
60
|
+
a: FunctionalComponent | Attrs[] | string,
|
|
61
|
+
b?: FunctionalComponent<Attrs> | string,
|
|
62
|
+
c?: FunctionalComponent<Attrs>
|
|
63
|
+
): void {
|
|
64
|
+
const { setup, attributes, styles } = resolveOverload<Attrs>(a, b, c)
|
|
65
|
+
define({ setup, attributes, styles, useShadow: false })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function shadowElement(fn: FunctionalComponent): void
|
|
69
|
+
function shadowElement(styles: string, fn: FunctionalComponent): void
|
|
70
|
+
function shadowElement<Attrs extends string>(
|
|
71
|
+
observedAttributes: Attrs[],
|
|
72
|
+
fn: FunctionalComponent<Attrs>
|
|
73
|
+
): void
|
|
74
|
+
function shadowElement<Attrs extends string>(
|
|
75
|
+
observedAttributes: Attrs[],
|
|
76
|
+
styles: string,
|
|
77
|
+
fn: FunctionalComponent<Attrs>
|
|
78
|
+
): void
|
|
79
|
+
function shadowElement<Attrs extends string>(
|
|
80
|
+
a: FunctionalComponent | Attrs[] | string,
|
|
81
|
+
b?: FunctionalComponent<Attrs> | string,
|
|
82
|
+
c?: FunctionalComponent<Attrs>
|
|
83
|
+
): void {
|
|
84
|
+
const { setup, attributes, styles } = resolveOverload<Attrs>(a, b, c)
|
|
85
|
+
define({ setup, attributes, styles, useShadow: true })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resolveOverload<Attrs extends string>(
|
|
89
|
+
a: FunctionalComponent | Attrs[] | string,
|
|
90
|
+
b?: FunctionalComponent<Attrs> | string,
|
|
91
|
+
c?: FunctionalComponent<Attrs>
|
|
92
|
+
): { setup: FunctionalComponent<Attrs>; attributes?: Attrs[]; styles?: string } {
|
|
93
|
+
if (typeof a === 'function') {
|
|
94
|
+
return { setup: a as FunctionalComponent<Attrs> }
|
|
95
|
+
}
|
|
96
|
+
if (typeof a === 'string') {
|
|
97
|
+
return { styles: a, setup: b as FunctionalComponent<Attrs> }
|
|
98
|
+
}
|
|
99
|
+
if (typeof b === 'function') {
|
|
100
|
+
return { attributes: a, setup: b }
|
|
101
|
+
}
|
|
102
|
+
return { attributes: a, styles: b, setup: c! }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const state = <T>(value: T) => new Signal.State(value)
|
|
106
|
+
|
|
107
|
+
export { state, lightElement, shadowElement }
|
|
108
|
+
|
|
109
|
+
export function define<Attrs extends string = never>(options: DefineOptions<Attrs>) {
|
|
110
|
+
const {
|
|
111
|
+
setup,
|
|
112
|
+
tagName,
|
|
113
|
+
attributes = [] as unknown as Attrs[],
|
|
114
|
+
useShadow = true,
|
|
115
|
+
formAssociated = false,
|
|
116
|
+
styles
|
|
117
|
+
} = options
|
|
118
|
+
|
|
119
|
+
const elementName = (tagName ?? setup.name
|
|
120
|
+
.replaceAll(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
121
|
+
.replaceAll(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
|
|
122
|
+
.toLowerCase())
|
|
123
|
+
|
|
124
|
+
if (!elementName.includes('-')) {
|
|
125
|
+
throw new Error(`Function ${setup.name} must include at least one capital letter to be converted to a valid custom element name`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (customElements.get(elementName)) {
|
|
129
|
+
throw new Error(`Custom element with name ${elementName} already defined`)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const attr of attributes) {
|
|
133
|
+
if (RESERVED_KEYS.has(attr)) {
|
|
134
|
+
throw new Error(`Attribute name "${attr}" conflicts with a reserved context property.`)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// One constructed stylesheet per element type, shared across all instances.
|
|
139
|
+
// For shadow DOM, it's adopted into each shadowRoot.
|
|
140
|
+
// For light DOM, it's wrapped in @scope and adopted into the document once.
|
|
141
|
+
let stylesheet: CSSStyleSheet | undefined
|
|
142
|
+
if (styles !== undefined) {
|
|
143
|
+
stylesheet = new CSSStyleSheet()
|
|
144
|
+
if (useShadow) {
|
|
145
|
+
stylesheet.replaceSync(styles)
|
|
146
|
+
} else {
|
|
147
|
+
stylesheet.replaceSync(`@scope (${elementName}) { ${styles} }`)
|
|
148
|
+
document.adoptedStyleSheets = [
|
|
149
|
+
...document.adoptedStyleSheets,
|
|
150
|
+
stylesheet
|
|
151
|
+
]
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
customElements.define(elementName, class extends HTMLElement {
|
|
156
|
+
#template!: Signal.Computed<ReturnType<typeof html>>
|
|
157
|
+
#watcher!: any
|
|
158
|
+
#effects: EffectFunction[] = []
|
|
159
|
+
#cleanups: CleanupFunction[] = []
|
|
160
|
+
#attributeSignals: Map<string, Signal.State<string | null>> = new Map()
|
|
161
|
+
|
|
162
|
+
static get observedAttributes() {
|
|
163
|
+
return attributes
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
static get formAssociated() {
|
|
167
|
+
return formAssociated
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
constructor() {
|
|
171
|
+
super()
|
|
172
|
+
if (useShadow) this.attachShadow({ mode: 'open' })
|
|
173
|
+
|
|
174
|
+
if (stylesheet && this.shadowRoot) {
|
|
175
|
+
this.shadowRoot.adoptedStyleSheets = [
|
|
176
|
+
...this.shadowRoot.adoptedStyleSheets,
|
|
177
|
+
stylesheet
|
|
178
|
+
]
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const context = {
|
|
182
|
+
internals: this.attachInternals(),
|
|
183
|
+
effect: (fn: EffectFunction) => {
|
|
184
|
+
this.#effects.push(fn)
|
|
185
|
+
},
|
|
186
|
+
styleProps: (props: Record<string, string | number | null>) => {
|
|
187
|
+
for (const key in props) {
|
|
188
|
+
const value = props[key]
|
|
189
|
+
const name = toCustomProperty(key)
|
|
190
|
+
if (value === null) {
|
|
191
|
+
this.style.removeProperty(name)
|
|
192
|
+
} else {
|
|
193
|
+
this.style.setProperty(name, String(value))
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} as ComponentContext<Attrs>
|
|
198
|
+
|
|
199
|
+
attributes.forEach(attr => {
|
|
200
|
+
const signal = new Signal.State(this.getAttribute(attr), {
|
|
201
|
+
equals: (prev, next) => prev === next
|
|
202
|
+
})
|
|
203
|
+
this.#attributeSignals.set(attr, signal)
|
|
204
|
+
;(context as Record<string, unknown>)[attr] = signal
|
|
205
|
+
|
|
206
|
+
Object.defineProperty(this, attr, {
|
|
207
|
+
get() {
|
|
208
|
+
return signal.get()
|
|
209
|
+
},
|
|
210
|
+
set(value: unknown) {
|
|
211
|
+
if (value !== null && typeof value !== 'string') {
|
|
212
|
+
throw new TypeError(`Attribute "${attr}" only accepts strings or null, got ${typeof value}.`)
|
|
213
|
+
}
|
|
214
|
+
signal.set(value)
|
|
215
|
+
},
|
|
216
|
+
enumerable: true,
|
|
217
|
+
configurable: true
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
const watcher = new Signal.subtle.Watcher(() => {
|
|
221
|
+
// Microtask required: setAttribute triggers attributeChangedCallback
|
|
222
|
+
// which calls signal.set(), and writing to a signal inside a watcher
|
|
223
|
+
// notify callback is not allowed.
|
|
224
|
+
queueMicrotask(() => {
|
|
225
|
+
const value = signal.get()
|
|
226
|
+
if (value === null) {
|
|
227
|
+
this.removeAttribute(attr)
|
|
228
|
+
} else {
|
|
229
|
+
this.setAttribute(attr, value)
|
|
230
|
+
}
|
|
231
|
+
watcher.watch()
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
watcher.watch(signal)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
const templateFn = setup.call(this, context)
|
|
238
|
+
|
|
239
|
+
this.#template = new Signal.Computed(() => templateFn())
|
|
240
|
+
|
|
241
|
+
const renderTemplate = () =>
|
|
242
|
+
render(this.#template.get(), useShadow ? this.shadowRoot! : this)
|
|
243
|
+
|
|
244
|
+
let renderPending = false
|
|
245
|
+
this.#watcher = new Signal.subtle.Watcher(() => {
|
|
246
|
+
if (renderPending) return
|
|
247
|
+
renderPending = true
|
|
248
|
+
queueMicrotask(() => {
|
|
249
|
+
renderPending = false
|
|
250
|
+
renderTemplate()
|
|
251
|
+
this.#watcher.watch()
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
this.#watcher.watch(this.#template)
|
|
256
|
+
|
|
257
|
+
renderTemplate()
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
connectedCallback() {
|
|
261
|
+
this.#watcher.watch(this.#template)
|
|
262
|
+
this.#cleanups = this.#effects
|
|
263
|
+
.map(effect => effect())
|
|
264
|
+
.filter((cleanup): cleanup is CleanupFunction =>
|
|
265
|
+
typeof cleanup === 'function'
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
disconnectedCallback() {
|
|
270
|
+
this.#watcher.unwatch(this.#template)
|
|
271
|
+
this.#cleanups.forEach(cleanup => cleanup())
|
|
272
|
+
this.#cleanups = []
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) {
|
|
276
|
+
this.#attributeSignals.get(name)?.set(newValue)
|
|
277
|
+
}
|
|
278
|
+
})
|
|
279
|
+
}
|