@zeix/cause-effect 0.18.0 → 0.18.2

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.
@@ -0,0 +1,187 @@
1
+ import { createSensor, isSensor, type Sensor } from '..'
2
+
3
+ /* === Types === */
4
+
5
+ type ReservedWords =
6
+ | 'constructor'
7
+ | 'prototype'
8
+ | '__proto__'
9
+ | 'toString'
10
+ | 'valueOf'
11
+ | 'hasOwnProperty'
12
+ | 'isPrototypeOf'
13
+ | 'propertyIsEnumerable'
14
+ | 'toLocaleString'
15
+
16
+ type ComponentProp = Exclude<string, keyof HTMLElement | ReservedWords>
17
+ type ComponentProps = Record<ComponentProp, NonNullable<unknown>>
18
+
19
+ type Component<P extends ComponentProps> = HTMLElement & P
20
+
21
+ type UI = Record<string, Element | Sensor<Element[]>>
22
+
23
+ type EventType<K extends string> = K extends keyof HTMLElementEventMap
24
+ ? HTMLElementEventMap[K]
25
+ : Event
26
+
27
+ type EventHandler<
28
+ T extends {},
29
+ Evt extends Event,
30
+ U extends UI,
31
+ E extends Element,
32
+ > = (context: {
33
+ event: Evt
34
+ ui: U
35
+ target: E
36
+ prev: T
37
+ }) => T | void | Promise<void>
38
+
39
+ type EventHandlers<T extends {}, U extends UI, E extends Element> = {
40
+ [K in keyof HTMLElementEventMap]?: EventHandler<T, EventType<K>, U, E>
41
+ }
42
+
43
+ type ElementFromKey<U extends UI, K extends keyof U> = NonNullable<
44
+ U[K] extends Sensor<infer E extends Element>
45
+ ? E
46
+ : U[K] extends Element
47
+ ? U[K]
48
+ : never
49
+ >
50
+
51
+ type Parser<T extends {}, U extends UI> = (
52
+ ui: U,
53
+ value: string | null | undefined,
54
+ old?: string | null,
55
+ ) => T
56
+
57
+ type Reader<T extends {}, U extends UI> = (ui: U) => T
58
+ type Fallback<T extends {}, U extends UI> = T | Reader<T, U>
59
+
60
+ type ParserOrFallback<T extends {}, U extends UI> =
61
+ | Parser<T, U>
62
+ | Fallback<T, U>
63
+
64
+ /* === Internal === */
65
+
66
+ const pendingElements = new Set<Element>()
67
+ const tasks = new WeakMap<Element, () => void>()
68
+ let requestId: number | undefined
69
+
70
+ const runTasks = () => {
71
+ requestId = undefined
72
+ const elements = Array.from(pendingElements)
73
+ pendingElements.clear()
74
+ for (const element of elements) tasks.get(element)?.()
75
+ }
76
+
77
+ const requestTick = () => {
78
+ if (requestId) cancelAnimationFrame(requestId)
79
+ requestId = requestAnimationFrame(runTasks)
80
+ }
81
+
82
+ const schedule = (element: Element, task: () => void) => {
83
+ tasks.set(element, task)
84
+ pendingElements.add(element)
85
+ requestTick()
86
+ }
87
+
88
+ // High-frequency events that are passive by default and should be scheduled
89
+ const PASSIVE_EVENTS = new Set([
90
+ 'scroll',
91
+ 'resize',
92
+ 'mousewheel',
93
+ 'touchstart',
94
+ 'touchmove',
95
+ 'wheel',
96
+ ])
97
+
98
+ const isReader = <T extends {}, U extends UI>(
99
+ value: unknown,
100
+ ): value is Reader<T, U> => typeof value === 'function'
101
+
102
+ const getFallback = <T extends {}, U extends UI>(
103
+ ui: U,
104
+ fallback: ParserOrFallback<T, U>,
105
+ ): T => (isReader<T, U>(fallback) ? fallback(ui) : (fallback as T))
106
+
107
+ /* === Exported Functions === */
108
+
109
+ /**
110
+ * Produce an event-driven sensor from transformed event data
111
+ *
112
+ * @since 0.16.0
113
+ * @param {S} key - name of UI key
114
+ * @param {ParserOrFallback<T>} init - Initial value, reader or parser
115
+ * @param {EventHandlers<T, ElementFromSelector<S>, C>} events - Transformation functions for events
116
+ * @returns {Extractor<Sensor<T>, C>} Extractor function for value from event
117
+ */
118
+ const createEventsSensor =
119
+ <T extends {}, P extends ComponentProps, U extends UI, K extends keyof U>(
120
+ init: ParserOrFallback<T, U>,
121
+ key: K,
122
+ events: EventHandlers<T, U, ElementFromKey<U, K>>,
123
+ ): ((ui: U & { host: Component<P> }) => Sensor<T>) =>
124
+ (ui: U & { host: Component<P> }) => {
125
+ const { host } = ui
126
+ let value: T = getFallback(ui, init)
127
+ const targets = isSensor<ElementFromKey<U, K>[]>(ui[key])
128
+ ? ui[key].get()
129
+ : [ui[key] as ElementFromKey<U & { host: Component<P> }, K>]
130
+ const eventMap = new Map<string, EventListener>()
131
+
132
+ const getTarget = (
133
+ eventTarget: Node,
134
+ ): ElementFromKey<U, K> | undefined => {
135
+ for (const t of targets)
136
+ if (t.contains(eventTarget)) return t as ElementFromKey<U, K>
137
+ }
138
+
139
+ return createSensor<T>(
140
+ set => {
141
+ for (const [type, handler] of Object.entries(events)) {
142
+ const options = { passive: PASSIVE_EVENTS.has(type) }
143
+ const listener = (e: Event) => {
144
+ const eventTarget = e.target as Node
145
+ if (!eventTarget) return
146
+ const target = getTarget(eventTarget)
147
+ if (!target) return
148
+ e.stopPropagation()
149
+
150
+ const task = () => {
151
+ try {
152
+ const next = handler({
153
+ event: e as any,
154
+ ui,
155
+ target,
156
+ prev: value,
157
+ })
158
+ if (next == null || next instanceof Promise)
159
+ return
160
+ if (!Object.is(next, value)) {
161
+ value = next
162
+ set(next)
163
+ }
164
+ } catch (error) {
165
+ e.stopImmediatePropagation()
166
+ throw error
167
+ }
168
+ }
169
+ if (options.passive) schedule(host, task)
170
+ else task()
171
+ }
172
+ eventMap.set(type, listener)
173
+ host.addEventListener(type, listener, options)
174
+ }
175
+ return () => {
176
+ if (eventMap.size) {
177
+ for (const [type, listener] of eventMap)
178
+ host.removeEventListener(type, listener)
179
+ eventMap.clear()
180
+ }
181
+ }
182
+ },
183
+ { value },
184
+ )
185
+ }
186
+
187
+ export { createEventsSensor, type EventHandler, type EventHandlers }
@@ -0,0 +1,173 @@
1
+ import { createMemo, type Memo } from '..'
2
+
3
+ /* === Types === */
4
+
5
+ // Split a comma-separated selector into individual selectors
6
+ type SplitByComma<S extends string> = S extends `${infer First},${infer Rest}`
7
+ ? [TrimWhitespace<First>, ...SplitByComma<Rest>]
8
+ : [TrimWhitespace<S>]
9
+
10
+ // Trim leading/trailing whitespace from a string
11
+ type TrimWhitespace<S extends string> = S extends ` ${infer Rest}`
12
+ ? TrimWhitespace<Rest>
13
+ : S extends `${infer Rest} `
14
+ ? TrimWhitespace<Rest>
15
+ : S
16
+
17
+ // Extract the rightmost selector part from combinator selectors (space, >, +, ~)
18
+ type ExtractRightmostSelector<S extends string> =
19
+ S extends `${string} ${infer Rest}`
20
+ ? ExtractRightmostSelector<Rest>
21
+ : S extends `${string}>${infer Rest}`
22
+ ? ExtractRightmostSelector<Rest>
23
+ : S extends `${string}+${infer Rest}`
24
+ ? ExtractRightmostSelector<Rest>
25
+ : S extends `${string}~${infer Rest}`
26
+ ? ExtractRightmostSelector<Rest>
27
+ : S
28
+
29
+ // Extract tag name from a simple selector (without combinators)
30
+ type ExtractTagFromSimpleSelector<S extends string> =
31
+ S extends `${infer T}.${string}`
32
+ ? T
33
+ : S extends `${infer T}#${string}`
34
+ ? T
35
+ : S extends `${infer T}:${string}`
36
+ ? T
37
+ : S extends `${infer T}[${string}`
38
+ ? T
39
+ : S
40
+
41
+ // Main extraction logic for a single selector
42
+ type ExtractTag<S extends string> = ExtractTagFromSimpleSelector<
43
+ ExtractRightmostSelector<S>
44
+ >
45
+
46
+ // Normalize to lowercase and ensure it's a known HTML tag
47
+ type KnownTag<S extends string> =
48
+ Lowercase<ExtractTag<S>> extends
49
+ | keyof HTMLElementTagNameMap
50
+ | keyof SVGElementTagNameMap
51
+ | keyof MathMLElementTagNameMap
52
+ ? Lowercase<ExtractTag<S>>
53
+ : never
54
+
55
+ // Get element type from a single selector
56
+ type ElementFromSingleSelector<S extends string> =
57
+ KnownTag<S> extends never
58
+ ? HTMLElement
59
+ : KnownTag<S> extends keyof HTMLElementTagNameMap
60
+ ? HTMLElementTagNameMap[KnownTag<S>]
61
+ : KnownTag<S> extends keyof SVGElementTagNameMap
62
+ ? SVGElementTagNameMap[KnownTag<S>]
63
+ : KnownTag<S> extends keyof MathMLElementTagNameMap
64
+ ? MathMLElementTagNameMap[KnownTag<S>]
65
+ : HTMLElement
66
+
67
+ // Map a tuple of selectors to a union of their element types
68
+ type ElementsFromSelectorArray<Selectors extends readonly string[]> = {
69
+ [K in keyof Selectors]: Selectors[K] extends string
70
+ ? ElementFromSingleSelector<Selectors[K]>
71
+ : never
72
+ }[number]
73
+
74
+ // Main type: handle both single selectors and comma-separated selectors
75
+ type ElementFromSelector<S extends string> = S extends `${string},${string}`
76
+ ? ElementsFromSelectorArray<SplitByComma<S>>
77
+ : ElementFromSingleSelector<S>
78
+
79
+ type ElementChanges<E extends Element> = {
80
+ current: Set<E>
81
+ added: E[]
82
+ removed: E[]
83
+ }
84
+
85
+ /* === Internal Functions === */
86
+
87
+ /**
88
+ * Extract attribute names from a CSS selector
89
+ * Handles various attribute selector formats: .class, #id, [attr], [attr=value], [attr^=value], etc.
90
+ *
91
+ * @param {string} selector - CSS selector to parse
92
+ * @returns {string[]} - Array of attribute names found in the selector
93
+ */
94
+ const extractAttributes = (selector: string): string[] => {
95
+ const attributes = new Set<string>()
96
+ if (selector.includes('.')) attributes.add('class')
97
+ if (selector.includes('#')) attributes.add('id')
98
+ if (selector.includes('[')) {
99
+ const parts = selector.split('[')
100
+ for (let i = 1; i < parts.length; i++) {
101
+ const part = parts[i]
102
+ if (!part.includes(']')) continue
103
+ const attrName = part
104
+ .split('=')[0]
105
+ .trim()
106
+ .replace(/[^a-zA-Z0-9_-]/g, '')
107
+ if (attrName) attributes.add(attrName)
108
+ }
109
+ }
110
+ return [...attributes]
111
+ }
112
+
113
+ /* === Exported Functions === */
114
+
115
+ /**
116
+ * Observe changes to elements matching a CSS selector.
117
+ * Returns a Memo that tracks which elements were added and removed.
118
+ * The MutationObserver is lazily activated when an effect first reads
119
+ * the memo, and disconnected when no effects are watching.
120
+ *
121
+ * @since 0.16.0
122
+ * @param parent - The parent node to search within
123
+ * @param selector - The CSS selector to match elements
124
+ * @returns A Memo of element changes (current set, added, removed)
125
+ */
126
+ function observeSelectorChanges<S extends string>(
127
+ parent: ParentNode,
128
+ selector: S,
129
+ ): Memo<ElementChanges<ElementFromSelector<S>>>
130
+ function observeSelectorChanges<E extends Element>(
131
+ parent: ParentNode,
132
+ selector: string,
133
+ ): Memo<ElementChanges<E>>
134
+ function observeSelectorChanges<S extends string>(
135
+ parent: ParentNode,
136
+ selector: S,
137
+ ): Memo<ElementChanges<ElementFromSelector<S>>> {
138
+ type E = ElementFromSelector<S>
139
+
140
+ return createMemo(
141
+ (prev: ElementChanges<E>) => {
142
+ const next = new Set(
143
+ Array.from(parent.querySelectorAll<E>(selector)),
144
+ )
145
+ const added: E[] = []
146
+ const removed: E[] = []
147
+
148
+ for (const el of next) if (!prev.current.has(el)) added.push(el)
149
+ for (const el of prev.current) if (!next.has(el)) removed.push(el)
150
+
151
+ return { current: next, added, removed }
152
+ },
153
+ {
154
+ value: { current: new Set<E>(), added: [], removed: [] },
155
+ watched: invalidate => {
156
+ const observerConfig: MutationObserverInit = {
157
+ childList: true,
158
+ subtree: true,
159
+ }
160
+ const observedAttributes = extractAttributes(selector)
161
+ if (observedAttributes.length) {
162
+ observerConfig.attributes = true
163
+ observerConfig.attributeFilter = observedAttributes
164
+ }
165
+ const observer = new MutationObserver(() => invalidate())
166
+ observer.observe(parent, observerConfig)
167
+ return () => observer.disconnect()
168
+ },
169
+ },
170
+ )
171
+ }
172
+
173
+ export { observeSelectorChanges, type ElementChanges }