@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/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
+ }