@bquery/bquery 1.7.0 → 1.8.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.
Files changed (262) hide show
  1. package/README.md +760 -716
  2. package/dist/{a11y-C5QOVvRn.js → a11y-DVBCy09c.js} +3 -3
  3. package/dist/a11y-DVBCy09c.js.map +1 -0
  4. package/dist/a11y.es.mjs +1 -1
  5. package/dist/component/library.d.ts.map +1 -1
  6. package/dist/{component-CuuTijA6.js → component-L3-JfOFz.js} +5 -5
  7. package/dist/component-L3-JfOFz.js.map +1 -0
  8. package/dist/component.es.mjs +1 -1
  9. package/dist/{config-BW35FKuA.js → config-DhT9auRm.js} +1 -1
  10. package/dist/{config-BW35FKuA.js.map → config-DhT9auRm.js.map} +1 -1
  11. package/dist/{constraints-3lV9yyBw.js → constraints-D5RHQLmP.js} +1 -1
  12. package/dist/constraints-D5RHQLmP.js.map +1 -0
  13. package/dist/core/collection.d.ts +86 -0
  14. package/dist/core/collection.d.ts.map +1 -1
  15. package/dist/core/element.d.ts +28 -0
  16. package/dist/core/element.d.ts.map +1 -1
  17. package/dist/core/shared.d.ts +6 -0
  18. package/dist/core/shared.d.ts.map +1 -1
  19. package/dist/core-DdtZHzsS.js +168 -0
  20. package/dist/core-DdtZHzsS.js.map +1 -0
  21. package/dist/{core-Cjl7GUu8.js → core-EMYSLzaT.js} +289 -259
  22. package/dist/core-EMYSLzaT.js.map +1 -0
  23. package/dist/core.es.mjs +48 -47
  24. package/dist/{custom-directives-7wAShnnd.js → custom-directives-Dr4C5lVV.js} +1 -1
  25. package/dist/custom-directives-Dr4C5lVV.js.map +1 -0
  26. package/dist/{devtools-D2fQLhDN.js → devtools-BhB2iDPT.js} +2 -2
  27. package/dist/devtools-BhB2iDPT.js.map +1 -0
  28. package/dist/devtools.es.mjs +1 -1
  29. package/dist/{dnd-B8EgyzaI.js → dnd-NwZBYh4l.js} +1 -1
  30. package/dist/dnd-NwZBYh4l.js.map +1 -0
  31. package/dist/dnd.es.mjs +1 -1
  32. package/dist/{env-NeVmr4Gf.js → env-CTdvLaH2.js} +1 -1
  33. package/dist/env-CTdvLaH2.js.map +1 -0
  34. package/dist/forms/create-form.d.ts.map +1 -1
  35. package/dist/forms/index.d.ts +3 -2
  36. package/dist/forms/index.d.ts.map +1 -1
  37. package/dist/forms/types.d.ts +46 -0
  38. package/dist/forms/types.d.ts.map +1 -1
  39. package/dist/forms/use-field.d.ts +34 -0
  40. package/dist/forms/use-field.d.ts.map +1 -0
  41. package/dist/forms/validators.d.ts +25 -0
  42. package/dist/forms/validators.d.ts.map +1 -1
  43. package/dist/forms-UcRHsYxC.js +227 -0
  44. package/dist/forms-UcRHsYxC.js.map +1 -0
  45. package/dist/forms.es.mjs +14 -12
  46. package/dist/full.d.ts +17 -26
  47. package/dist/full.d.ts.map +1 -1
  48. package/dist/full.es.mjs +206 -181
  49. package/dist/full.iife.js +33 -33
  50. package/dist/full.iife.js.map +1 -1
  51. package/dist/full.umd.js +33 -33
  52. package/dist/full.umd.js.map +1 -1
  53. package/dist/function-Cybd57JV.js +33 -0
  54. package/dist/function-Cybd57JV.js.map +1 -0
  55. package/dist/{i18n-BnnhTFOS.js → i18n-kuF6Ekj6.js} +3 -3
  56. package/dist/i18n-kuF6Ekj6.js.map +1 -0
  57. package/dist/i18n.es.mjs +1 -1
  58. package/dist/index.es.mjs +251 -228
  59. package/dist/media/breakpoints.d.ts.map +1 -1
  60. package/dist/media/types.d.ts +2 -2
  61. package/dist/media/types.d.ts.map +1 -1
  62. package/dist/{media-Di2Ta22s.js → media-i-fB5WxI.js} +3 -3
  63. package/dist/media-i-fB5WxI.js.map +1 -0
  64. package/dist/media.es.mjs +1 -1
  65. package/dist/{motion-qPj_TYGv.js → motion-BJsAuULb.js} +2 -2
  66. package/dist/motion-BJsAuULb.js.map +1 -0
  67. package/dist/motion.es.mjs +1 -1
  68. package/dist/{mount-SM07RUa6.js → mount-B4Y8bk8Z.js} +5 -5
  69. package/dist/mount-B4Y8bk8Z.js.map +1 -0
  70. package/dist/{platform-CPbCprb6.js → platform-Dw2gE3zI.js} +3 -3
  71. package/dist/{platform-CPbCprb6.js.map → platform-Dw2gE3zI.js.map} +1 -1
  72. package/dist/platform.es.mjs +2 -2
  73. package/dist/plugin/registry.d.ts.map +1 -1
  74. package/dist/{plugin-cPoOHFLY.js → plugin-C2WuC8SF.js} +20 -18
  75. package/dist/plugin-C2WuC8SF.js.map +1 -0
  76. package/dist/plugin.es.mjs +1 -1
  77. package/dist/reactive/async-data.d.ts +28 -3
  78. package/dist/reactive/async-data.d.ts.map +1 -1
  79. package/dist/reactive/computed.d.ts +3 -0
  80. package/dist/reactive/computed.d.ts.map +1 -1
  81. package/dist/reactive/effect.d.ts +3 -0
  82. package/dist/reactive/effect.d.ts.map +1 -1
  83. package/dist/reactive/http.d.ts +194 -0
  84. package/dist/reactive/http.d.ts.map +1 -0
  85. package/dist/reactive/index.d.ts +2 -2
  86. package/dist/reactive/index.d.ts.map +1 -1
  87. package/dist/reactive/pagination.d.ts +126 -0
  88. package/dist/reactive/pagination.d.ts.map +1 -0
  89. package/dist/reactive/polling.d.ts +55 -0
  90. package/dist/reactive/polling.d.ts.map +1 -0
  91. package/dist/reactive/readonly.d.ts +20 -1
  92. package/dist/reactive/readonly.d.ts.map +1 -1
  93. package/dist/reactive/rest.d.ts +293 -0
  94. package/dist/reactive/rest.d.ts.map +1 -0
  95. package/dist/reactive/scope.d.ts +140 -0
  96. package/dist/reactive/scope.d.ts.map +1 -0
  97. package/dist/reactive/signal.d.ts +16 -2
  98. package/dist/reactive/signal.d.ts.map +1 -1
  99. package/dist/reactive/to-value.d.ts +57 -0
  100. package/dist/reactive/to-value.d.ts.map +1 -0
  101. package/dist/reactive/websocket.d.ts +285 -0
  102. package/dist/reactive/websocket.d.ts.map +1 -0
  103. package/dist/reactive-DwkhUJfP.js +1148 -0
  104. package/dist/reactive-DwkhUJfP.js.map +1 -0
  105. package/dist/reactive.es.mjs +38 -19
  106. package/dist/{registry-CWf368tT.js → registry-B08iilIh.js} +1 -1
  107. package/dist/{registry-CWf368tT.js.map → registry-B08iilIh.js.map} +1 -1
  108. package/dist/router/constraints.d.ts.map +1 -1
  109. package/dist/router/index.d.ts +1 -1
  110. package/dist/router/index.d.ts.map +1 -1
  111. package/dist/router/router.d.ts.map +1 -1
  112. package/dist/router/state.d.ts +25 -2
  113. package/dist/router/state.d.ts.map +1 -1
  114. package/dist/router-CQikC9Ed.js +492 -0
  115. package/dist/router-CQikC9Ed.js.map +1 -0
  116. package/dist/router.es.mjs +9 -8
  117. package/dist/ssr/hydrate.d.ts.map +1 -1
  118. package/dist/{ssr-B2qd_WBB.js → ssr-_dAcGdzu.js} +4 -4
  119. package/dist/ssr-_dAcGdzu.js.map +1 -0
  120. package/dist/ssr.es.mjs +1 -1
  121. package/dist/store/persisted.d.ts.map +1 -1
  122. package/dist/{store-DWpyH6p5.js → store-Cb3gPRve.js} +7 -7
  123. package/dist/store-Cb3gPRve.js.map +1 -0
  124. package/dist/store.es.mjs +2 -2
  125. package/dist/storybook.es.mjs.map +1 -1
  126. package/dist/{testing-CsqjNUyy.js → testing-C5Sjfsna.js} +8 -8
  127. package/dist/testing-C5Sjfsna.js.map +1 -0
  128. package/dist/testing.es.mjs +1 -1
  129. package/dist/{type-guards-Do9DWgNp.js → type-guards-BMX2c0LP.js} +1 -1
  130. package/dist/{type-guards-Do9DWgNp.js.map → type-guards-BMX2c0LP.js.map} +1 -1
  131. package/dist/untrack-D0fnO5k2.js +36 -0
  132. package/dist/untrack-D0fnO5k2.js.map +1 -0
  133. package/dist/view/custom-directives.d.ts.map +1 -1
  134. package/dist/view.es.mjs +4 -4
  135. package/package.json +178 -177
  136. package/src/a11y/announce.ts +131 -131
  137. package/src/a11y/audit.ts +314 -314
  138. package/src/a11y/index.ts +68 -68
  139. package/src/a11y/media-preferences.ts +255 -255
  140. package/src/a11y/roving-tab-index.ts +164 -164
  141. package/src/a11y/skip-link.ts +255 -255
  142. package/src/a11y/trap-focus.ts +184 -184
  143. package/src/a11y/types.ts +183 -183
  144. package/src/component/component.ts +599 -599
  145. package/src/component/html.ts +153 -153
  146. package/src/component/index.ts +52 -52
  147. package/src/component/library.ts +540 -542
  148. package/src/component/scope.ts +212 -212
  149. package/src/component/types.ts +310 -310
  150. package/src/core/collection.ts +876 -707
  151. package/src/core/element.ts +1015 -981
  152. package/src/core/env.ts +60 -60
  153. package/src/core/index.ts +49 -49
  154. package/src/core/shared.ts +77 -62
  155. package/src/core/utils/index.ts +148 -148
  156. package/src/devtools/devtools.ts +410 -410
  157. package/src/devtools/index.ts +48 -48
  158. package/src/devtools/types.ts +104 -104
  159. package/src/dnd/draggable.ts +296 -296
  160. package/src/dnd/droppable.ts +228 -228
  161. package/src/dnd/index.ts +62 -62
  162. package/src/dnd/sortable.ts +307 -307
  163. package/src/dnd/types.ts +293 -293
  164. package/src/forms/create-form.ts +320 -278
  165. package/src/forms/index.ts +70 -65
  166. package/src/forms/types.ts +203 -154
  167. package/src/forms/use-field.ts +231 -0
  168. package/src/forms/validators.ts +294 -265
  169. package/src/full.ts +554 -480
  170. package/src/i18n/formatting.ts +67 -67
  171. package/src/i18n/i18n.ts +200 -200
  172. package/src/i18n/index.ts +67 -67
  173. package/src/i18n/translate.ts +182 -182
  174. package/src/i18n/types.ts +171 -171
  175. package/src/index.ts +108 -108
  176. package/src/media/battery.ts +116 -116
  177. package/src/media/breakpoints.ts +129 -131
  178. package/src/media/clipboard.ts +80 -80
  179. package/src/media/device-sensors.ts +158 -158
  180. package/src/media/geolocation.ts +119 -119
  181. package/src/media/index.ts +76 -76
  182. package/src/media/media-query.ts +92 -92
  183. package/src/media/network.ts +115 -115
  184. package/src/media/types.ts +177 -177
  185. package/src/media/viewport.ts +84 -84
  186. package/src/motion/index.ts +57 -57
  187. package/src/motion/morph.ts +151 -151
  188. package/src/motion/parallax.ts +120 -120
  189. package/src/motion/reduced-motion.ts +66 -66
  190. package/src/motion/types.ts +271 -271
  191. package/src/motion/typewriter.ts +164 -164
  192. package/src/plugin/index.ts +37 -37
  193. package/src/plugin/registry.ts +284 -269
  194. package/src/plugin/types.ts +137 -137
  195. package/src/reactive/async-data.ts +250 -29
  196. package/src/reactive/computed.ts +144 -130
  197. package/src/reactive/effect.ts +29 -6
  198. package/src/reactive/http.ts +790 -0
  199. package/src/reactive/index.ts +60 -0
  200. package/src/reactive/pagination.ts +317 -0
  201. package/src/reactive/polling.ts +179 -0
  202. package/src/reactive/readonly.ts +52 -8
  203. package/src/reactive/rest.ts +859 -0
  204. package/src/reactive/scope.ts +276 -0
  205. package/src/reactive/signal.ts +61 -1
  206. package/src/reactive/to-value.ts +71 -0
  207. package/src/reactive/websocket.ts +849 -0
  208. package/src/router/bq-link.ts +279 -279
  209. package/src/router/constraints.ts +204 -201
  210. package/src/router/index.ts +49 -49
  211. package/src/router/match.ts +312 -312
  212. package/src/router/path-pattern.ts +52 -52
  213. package/src/router/query.ts +38 -38
  214. package/src/router/router.ts +421 -402
  215. package/src/router/state.ts +51 -3
  216. package/src/router/types.ts +139 -139
  217. package/src/router/use-route.ts +68 -68
  218. package/src/router/utils.ts +157 -157
  219. package/src/security/index.ts +12 -12
  220. package/src/ssr/hydrate.ts +84 -82
  221. package/src/ssr/index.ts +70 -70
  222. package/src/ssr/render.ts +508 -508
  223. package/src/ssr/serialize.ts +296 -296
  224. package/src/ssr/types.ts +81 -81
  225. package/src/store/create-store.ts +467 -467
  226. package/src/store/index.ts +27 -27
  227. package/src/store/persisted.ts +245 -249
  228. package/src/store/types.ts +247 -247
  229. package/src/store/utils.ts +135 -135
  230. package/src/storybook/index.ts +480 -480
  231. package/src/testing/index.ts +42 -42
  232. package/src/testing/testing.ts +593 -593
  233. package/src/testing/types.ts +170 -170
  234. package/src/view/custom-directives.ts +28 -30
  235. package/src/view/evaluate.ts +292 -292
  236. package/src/view/process.ts +108 -108
  237. package/dist/a11y-C5QOVvRn.js.map +0 -1
  238. package/dist/component-CuuTijA6.js.map +0 -1
  239. package/dist/constraints-3lV9yyBw.js.map +0 -1
  240. package/dist/core-Cjl7GUu8.js.map +0 -1
  241. package/dist/core-DnlyjbF2.js +0 -112
  242. package/dist/core-DnlyjbF2.js.map +0 -1
  243. package/dist/custom-directives-7wAShnnd.js.map +0 -1
  244. package/dist/devtools-D2fQLhDN.js.map +0 -1
  245. package/dist/dnd-B8EgyzaI.js.map +0 -1
  246. package/dist/env-NeVmr4Gf.js.map +0 -1
  247. package/dist/forms-C3yovgH9.js +0 -141
  248. package/dist/forms-C3yovgH9.js.map +0 -1
  249. package/dist/i18n-BnnhTFOS.js.map +0 -1
  250. package/dist/media-Di2Ta22s.js.map +0 -1
  251. package/dist/motion-qPj_TYGv.js.map +0 -1
  252. package/dist/mount-SM07RUa6.js.map +0 -1
  253. package/dist/plugin-cPoOHFLY.js.map +0 -1
  254. package/dist/reactive-Cfv0RK6x.js +0 -233
  255. package/dist/reactive-Cfv0RK6x.js.map +0 -1
  256. package/dist/router-BrthaP_z.js +0 -473
  257. package/dist/router-BrthaP_z.js.map +0 -1
  258. package/dist/ssr-B2qd_WBB.js.map +0 -1
  259. package/dist/store-DWpyH6p5.js.map +0 -1
  260. package/dist/testing-CsqjNUyy.js.map +0 -1
  261. package/dist/untrack-DJVQQ2WM.js +0 -33
  262. package/dist/untrack-DJVQQ2WM.js.map +0 -1
@@ -1,599 +1,599 @@
1
- /**
2
- * Web Component factory and registry.
3
- *
4
- * @module bquery/component
5
- */
6
-
7
- import type { CleanupFn } from '../reactive/signal';
8
- import { effect, untrack } from '../reactive/signal';
9
- import { sanitizeHtml } from '../security/sanitize';
10
- import { coercePropValue } from './props';
11
- import { createComponentScope, setCurrentScope, type ComponentScope } from './scope';
12
- import type {
13
- AttributeChange,
14
- ComponentClass,
15
- ComponentDefinition,
16
- ComponentSignalLike,
17
- ComponentSignals,
18
- ComponentStateShape,
19
- PropDefinition,
20
- ShadowMode,
21
- } from './types';
22
-
23
- /**
24
- * Base extra tags preserved for component shadow DOM renders in addition to the
25
- * global sanitizer defaults. `slot` must remain allowed here because shadow DOM
26
- * content projection depends on authored `<slot>` elements in component render
27
- * output.
28
- */
29
- const COMPONENT_ALLOWED_TAGS = ['slot'];
30
-
31
- /**
32
- * Base extra attributes preserved for component shadow DOM renders in addition
33
- * to the global sanitizer defaults.
34
- */
35
- const COMPONENT_ALLOWED_ATTRIBUTES = [
36
- 'part',
37
- // Standard form attributes required by interactive shadow DOM content
38
- 'disabled',
39
- 'checked',
40
- 'placeholder',
41
- 'value',
42
- 'rows',
43
- 'cols',
44
- 'readonly',
45
- 'required',
46
- 'maxlength',
47
- 'minlength',
48
- 'max',
49
- 'min',
50
- 'step',
51
- 'pattern',
52
- 'autocomplete',
53
- 'autofocus',
54
- 'for',
55
- 'multiple',
56
- 'selected',
57
- 'wrap',
58
- ];
59
-
60
- /**
61
- * Creates a custom element class for a component definition.
62
- *
63
- * This is useful when you want to extend or register the class manually
64
- * (e.g. with different tag names in tests or custom registries).
65
- *
66
- * @template TProps - Type of the component's props
67
- * @param tagName - The custom element tag name (used for diagnostics)
68
- * @param definition - The component configuration
69
- */
70
- const createComponentClass = <
71
- TProps extends Record<string, unknown>,
72
- TState extends Record<string, unknown> | undefined = undefined,
73
- TSignals extends ComponentSignals = Record<string, never>,
74
- >(
75
- tagName: string,
76
- definition: ComponentDefinition<TProps, TState, TSignals>
77
- ): ComponentClass<TState> => {
78
- const componentAllowedTags = [
79
- ...COMPONENT_ALLOWED_TAGS,
80
- ...(definition.sanitize?.allowTags ?? []),
81
- ];
82
- const componentAllowedAttributes = [
83
- ...COMPONENT_ALLOWED_ATTRIBUTES,
84
- ...(definition.sanitize?.allowAttributes ?? []),
85
- ];
86
- const signalSources = Object.values(definition.signals ?? {}) as ComponentSignalLike<unknown>[];
87
-
88
- /** Resolve the Shadow DOM mode from the `shadow` option. */
89
- const resolveShadowMode = (option: ShadowMode | undefined): 'open' | 'closed' | false => {
90
- if (option === false) return false;
91
- if (option === 'closed') return 'closed';
92
- // true, 'open', or undefined all resolve to 'open'
93
- return 'open';
94
- };
95
- const shadowMode = resolveShadowMode(definition.shadow);
96
-
97
- /**
98
- * Merges prop-derived observed attributes with any extra attributes from
99
- * `observeAttributes`, deduplicating to avoid redundant callbacks.
100
- */
101
- const observedAttrs = Array.from(
102
- new Set([...Object.keys(definition.props ?? {}), ...(definition.observeAttributes ?? [])])
103
- );
104
-
105
- class BQueryComponent extends HTMLElement {
106
- /** Internal state object for the component */
107
- private readonly state: ComponentStateShape<TState> = {
108
- ...(definition.state ?? {}),
109
- } as ComponentStateShape<TState>;
110
- /** Typed props object populated from attributes */
111
- private props = {} as TProps;
112
- /** Tracks missing required props for validation during connectedCallback */
113
- private missingRequiredProps = new Set<string>();
114
- /** Tracks whether the component has completed its initial mount */
115
- private hasMounted = false;
116
- /** Cleanup for external signal subscriptions */
117
- private signalEffectCleanup?: CleanupFn;
118
- /** Component-scoped reactive resource tracker */
119
- private scope?: ComponentScope;
120
- /** Render target for open/closed shadow roots or the host element when shadow DOM is disabled */
121
- private readonly renderRootNode: HTMLElement | ShadowRoot;
122
-
123
- constructor() {
124
- super();
125
- if (shadowMode !== false) {
126
- this.renderRootNode = this.attachShadow({ mode: shadowMode });
127
- } else {
128
- this.renderRootNode = this;
129
- }
130
- this.syncProps();
131
- }
132
-
133
- /**
134
- * Returns the list of attributes to observe for changes.
135
- */
136
- static get observedAttributes(): string[] {
137
- return observedAttrs;
138
- }
139
-
140
- /**
141
- * Called when the element is added to the DOM.
142
- */
143
- connectedCallback(): void {
144
- try {
145
- // Defer only the initial mount until all required props are present.
146
- // Already-mounted components must still reconnect their signal
147
- // subscriptions so reactive updates can resume after reattachment.
148
- if (!this.hasMounted && this.missingRequiredProps.size > 0) {
149
- // Component will mount once all required props are satisfied
150
- // via attributeChangedCallback
151
- return;
152
- }
153
- if (this.hasMounted) {
154
- // Recreate scope for reconnected component
155
- this.scope = createComponentScope();
156
- const previousScope = setCurrentScope(this.scope);
157
- try {
158
- definition.connected?.call(this);
159
- } catch (error) {
160
- this.handleError(error as Error);
161
- } finally {
162
- setCurrentScope(previousScope);
163
- }
164
- this.setupSignalSubscriptions(true);
165
- return;
166
- }
167
- this.mount();
168
- } catch (error) {
169
- this.handleError(error as Error);
170
- }
171
- }
172
-
173
- /**
174
- * Performs the initial mount of the component.
175
- * Called when the element is connected and all required props are present.
176
- * @internal
177
- */
178
- private mount(): void {
179
- if (this.hasMounted) return;
180
- const previousScope = setCurrentScope(this.ensureScope());
181
- let hookError = false;
182
- try {
183
- definition.beforeMount?.call(this);
184
- definition.connected?.call(this);
185
- } catch (error) {
186
- hookError = true;
187
- this.handleError(error as Error);
188
- } finally {
189
- setCurrentScope(previousScope);
190
- }
191
- if (hookError) {
192
- this.scope?.dispose();
193
- this.scope = undefined;
194
- return;
195
- }
196
- this.render();
197
- this.setupSignalSubscriptions();
198
- this.hasMounted = true;
199
- }
200
-
201
- /**
202
- * Called when the element is removed from the DOM.
203
- */
204
- disconnectedCallback(): void {
205
- try {
206
- this.signalEffectCleanup?.();
207
- this.signalEffectCleanup = undefined;
208
- // Dispose all scoped reactive resources (useSignal, useComputed, useEffect)
209
- this.scope?.dispose();
210
- this.scope = undefined;
211
- definition.disconnected?.call(this);
212
- } catch (error) {
213
- this.handleError(error as Error);
214
- }
215
- }
216
-
217
- /**
218
- * Called when an observed attribute changes.
219
- */
220
- attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
221
- try {
222
- const previousProps = this.cloneProps();
223
- this.syncProps();
224
-
225
- // Fire the user-facing onAttributeChanged hook for every observed attribute change
226
- if (definition.onAttributeChanged) {
227
- const previousScope = setCurrentScope(this.ensureScope());
228
- try {
229
- definition.onAttributeChanged.call(this, name, oldValue, newValue);
230
- } finally {
231
- setCurrentScope(previousScope);
232
- }
233
- }
234
-
235
- if (this.hasMounted) {
236
- // Component already mounted - trigger update render
237
- this.render(true, previousProps, { name, oldValue, newValue });
238
- } else if (this.isConnected && this.missingRequiredProps.size === 0) {
239
- // All required props are now satisfied and element is connected
240
- // Trigger the deferred initial mount
241
- this.mount();
242
- }
243
- } catch (error) {
244
- this.handleError(error as Error);
245
- }
246
- }
247
-
248
- /**
249
- * Called when the element is moved to a new document (e.g. via `document.adoptNode`).
250
- */
251
- adoptedCallback(): void {
252
- if (!definition.onAdopted) {
253
- return;
254
- }
255
-
256
- const previousScope = setCurrentScope(this.ensureScope());
257
- try {
258
- definition.onAdopted.call(this);
259
- } catch (error) {
260
- this.handleError(error as Error);
261
- } finally {
262
- setCurrentScope(previousScope);
263
- }
264
- }
265
-
266
- /**
267
- * Handles errors during component lifecycle.
268
- * @internal
269
- */
270
- private handleError(error: Error): void {
271
- if (definition.onError) {
272
- definition.onError.call(this, error);
273
- } else {
274
- console.error(`bQuery component error in <${tagName}>:`, error);
275
- }
276
- }
277
-
278
- /**
279
- * Ensures the component has an active scope for scoped reactive primitives.
280
- * @internal
281
- */
282
- private ensureScope(): ComponentScope {
283
- return (this.scope ??= createComponentScope());
284
- }
285
-
286
- /**
287
- * Updates a state property and triggers a re-render.
288
- *
289
- * @param key - The state property key
290
- * @param value - The new value
291
- */
292
- setState<TKey extends keyof ComponentStateShape<TState>>(
293
- key: TKey,
294
- value: ComponentStateShape<TState>[TKey]
295
- ): void {
296
- this.state[key] = value;
297
- this.render(true, this.cloneProps(), undefined, false);
298
- }
299
-
300
- /**
301
- * Gets a state property value.
302
- *
303
- * @param key - The state property key
304
- * @returns The current value
305
- */
306
- getState<TKey extends keyof ComponentStateShape<TState>>(
307
- key: TKey
308
- ): ComponentStateShape<TState>[TKey];
309
- getState<TResult = unknown>(key: string): TResult;
310
- getState(key: string): unknown {
311
- return (this.state as Record<string, unknown>)[key];
312
- }
313
-
314
- /**
315
- * Subscribes to declared reactive sources and re-renders on change.
316
- *
317
- * @param renderOnInitialRun - When true, immediately re-renders after
318
- * re-subscribing so detached components resync with any signal changes
319
- * that happened while they were disconnected.
320
- * @internal
321
- */
322
- private setupSignalSubscriptions(renderOnInitialRun = false): void {
323
- if (this.signalEffectCleanup || signalSources.length === 0) return;
324
-
325
- let isInitialRun = true;
326
- this.signalEffectCleanup = effect(() => {
327
- try {
328
- for (const source of signalSources) {
329
- // Intentionally read each source to register this effect as a subscriber.
330
- void source.value;
331
- }
332
-
333
- if (isInitialRun) {
334
- isInitialRun = false;
335
- if (renderOnInitialRun && this.hasMounted && this.isConnected) {
336
- // Signal-driven reconnect renders do not change props, so the
337
- // previous-props snapshot is the current prop set at reconnect time.
338
- const previousProps = this.cloneProps();
339
- untrack(() => {
340
- this.render(true, previousProps, undefined, false);
341
- });
342
- }
343
- return;
344
- }
345
-
346
- if (!this.hasMounted || !this.isConnected) return;
347
-
348
- // Signal updates leave props unchanged, so cloning the current props
349
- // provides the previous-props snapshot expected by beforeUpdate().
350
- const previousProps = this.cloneProps();
351
- untrack(() => {
352
- this.render(true, previousProps, undefined, false);
353
- });
354
- } catch (error) {
355
- this.handleError(error as Error);
356
- }
357
- });
358
- }
359
-
360
- /**
361
- * Synchronizes props from attributes.
362
- * @internal
363
- */
364
- private syncProps(): void {
365
- const props = definition.props ?? {};
366
- for (const [key, config] of Object.entries(props) as [string, PropDefinition][]) {
367
- const attrValue = this.getAttribute(key);
368
- let value: unknown;
369
-
370
- if (attrValue == null) {
371
- if (config.required && config.default === undefined) {
372
- // Mark as missing instead of throwing - validate during connectedCallback
373
- this.missingRequiredProps.add(key);
374
- value = undefined;
375
- } else {
376
- value = config.default ?? undefined;
377
- }
378
- } else {
379
- // Attribute is present, remove from missing set if it was there
380
- if (this.missingRequiredProps.has(key)) {
381
- this.missingRequiredProps.delete(key);
382
- }
383
- value = coercePropValue(attrValue, config);
384
- }
385
-
386
- if (config.validator && value !== undefined) {
387
- const isValid = config.validator(value);
388
- if (!isValid) {
389
- throw new Error(
390
- `bQuery component: validation failed for prop "${key}" with value ${JSON.stringify(value)}`
391
- );
392
- }
393
- }
394
-
395
- (this.props as Record<string, unknown>)[key] = value;
396
- }
397
- }
398
-
399
- /**
400
- * Creates a shallow snapshot of the current props for lifecycle diffing.
401
- * A shallow copy is sufficient because component props are re-derived from
402
- * reflected attributes on each update, so nested object mutation is not
403
- * tracked as part of this lifecycle diff.
404
- * @internal
405
- */
406
- private cloneProps(): TProps {
407
- return { ...(this.props as Record<string, unknown>) } as TProps;
408
- }
409
-
410
- /**
411
- * Renders the component to its shadow root or host element.
412
- * @internal
413
- */
414
- private render(): void;
415
- private render(triggerUpdated: true, oldProps: TProps, change?: AttributeChange): void;
416
- private render(
417
- triggerUpdated: true,
418
- oldProps: TProps,
419
- change: AttributeChange | undefined,
420
- runBeforeUpdate: boolean
421
- ): void;
422
- private render(
423
- triggerUpdated = false,
424
- oldProps?: TProps,
425
- change?: AttributeChange,
426
- runBeforeUpdate = true
427
- ): void {
428
- try {
429
- if (triggerUpdated && runBeforeUpdate && definition.beforeUpdate) {
430
- if (!oldProps) {
431
- throw new Error('bQuery component: previous props are required for update renders');
432
- }
433
- const shouldUpdate = definition.beforeUpdate.call(this, this.props, oldProps);
434
- if (shouldUpdate === false) return;
435
- }
436
-
437
- const emit = (event: string, detail?: unknown): void => {
438
- this.dispatchEvent(new CustomEvent(event, { detail, bubbles: true, composed: true }));
439
- };
440
-
441
- const renderRoot = this.renderRootNode;
442
-
443
- const markup = definition.render({
444
- props: this.props,
445
- state: this.state,
446
- signals: (definition.signals ?? {}) as TSignals,
447
- emit,
448
- });
449
-
450
- // Component render output is authored by the component definition itself,
451
- // so we can explicitly preserve shadow-DOM-specific markup such as <slot>,
452
- // the stylistic `part` attribute, and standard form/input attributes without
453
- // relaxing the global DOM sanitization rules.
454
- const sanitizedMarkup = sanitizeHtml(markup, {
455
- allowTags: componentAllowedTags,
456
- allowAttributes: componentAllowedAttributes,
457
- });
458
- let existingStyleElement: HTMLStyleElement | null = null;
459
- if (definition.styles) {
460
- existingStyleElement = renderRoot.querySelector<HTMLStyleElement>(
461
- 'style[data-bquery-component-style]'
462
- );
463
- }
464
-
465
- renderRoot.innerHTML = sanitizedMarkup;
466
-
467
- if (definition.styles) {
468
- const styleElement = existingStyleElement ?? document.createElement('style');
469
- if (!existingStyleElement) {
470
- styleElement.setAttribute('data-bquery-component-style', '');
471
- }
472
- styleElement.textContent = definition.styles;
473
- renderRoot.prepend(styleElement);
474
- }
475
-
476
- if (triggerUpdated) {
477
- definition.updated?.call(this, change);
478
- }
479
- } catch (error) {
480
- this.handleError(error as Error);
481
- }
482
- }
483
- }
484
-
485
- return BQueryComponent as ComponentClass<TState>;
486
- };
487
-
488
- /**
489
- * Creates a custom element class for a component definition.
490
- *
491
- * This is useful when you want to extend or register the class manually
492
- * (e.g. with different tag names in tests or custom registries).
493
- *
494
- * @template TProps - Type of the component's props
495
- * @template TState - Type of the component's internal state. When provided,
496
- * `definition.state` is required, `render({ state })` is strongly typed, and
497
- * returned instances expose typed `getState()` / `setState()` helpers.
498
- * @param tagName - The custom element tag name (used for diagnostics)
499
- * @param definition - The component configuration
500
- */
501
- export function defineComponent<
502
- TProps extends Record<string, unknown>,
503
- TSignals extends ComponentSignals = Record<string, never>,
504
- >(
505
- tagName: string,
506
- definition: ComponentDefinition<TProps, undefined, TSignals>
507
- ): ComponentClass<undefined>;
508
- export function defineComponent<
509
- TProps extends Record<string, unknown>,
510
- TState extends Record<string, unknown>,
511
- TSignals extends ComponentSignals = Record<string, never>,
512
- >(
513
- tagName: string,
514
- definition: ComponentDefinition<TProps, TState, TSignals>
515
- ): ComponentClass<TState>;
516
- export function defineComponent<
517
- TProps extends Record<string, unknown>,
518
- TState extends Record<string, unknown> | undefined = undefined,
519
- TSignals extends ComponentSignals = Record<string, never>,
520
- >(
521
- tagName: string,
522
- definition: ComponentDefinition<TProps, TState, TSignals>
523
- ): ComponentClass<TState> {
524
- return createComponentClass(tagName, definition);
525
- }
526
-
527
- /**
528
- * Defines and registers a custom Web Component.
529
- *
530
- * This function creates a new custom element with the given tag name
531
- * and configuration. The component uses Shadow DOM for encapsulation
532
- * and automatically re-renders when observed attributes change.
533
- *
534
- * @template TProps - Type of the component's props
535
- * @template TState - Type of the component's internal state. When provided,
536
- * `definition.state` is required and lifecycle hooks receive typed state
537
- * helpers via `this.getState()` / `this.setState()`.
538
- * @param tagName - The custom element tag name (must contain a hyphen)
539
- * @param definition - The component configuration
540
- *
541
- * @example
542
- * ```ts
543
- * component<{ start: number }, { count: number }>('counter-button', {
544
- * props: {
545
- * start: { type: Number, default: 0 },
546
- * },
547
- * state: { count: 0 },
548
- * styles: `
549
- * button { padding: 0.5rem 1rem; }
550
- * `,
551
- * connected() {
552
- * // Use event delegation on shadow root so handler survives re-renders
553
- * const handleClick = (event: Event) => {
554
- * const target = event.target as HTMLElement | null;
555
- * if (target?.matches('button')) {
556
- * this.setState('count', this.getState('count') + 1);
557
- * }
558
- * };
559
- * this.shadowRoot?.addEventListener('click', handleClick);
560
- * // Store handler for cleanup
561
- * (this as any)._handleClick = handleClick;
562
- * },
563
- * disconnected() {
564
- * // Clean up event listener to prevent memory leaks
565
- * const handleClick = (this as any)._handleClick;
566
- * if (handleClick) {
567
- * this.shadowRoot?.removeEventListener('click', handleClick);
568
- * }
569
- * },
570
- * render({ props, state }) {
571
- * return html`
572
- * <button>
573
- * Count: ${state.count}
574
- * </button>
575
- * `;
576
- * },
577
- * });
578
- * ```
579
- */
580
- export function component<
581
- TProps extends Record<string, unknown>,
582
- TSignals extends ComponentSignals = Record<string, never>,
583
- >(tagName: string, definition: ComponentDefinition<TProps, undefined, TSignals>): void;
584
- export function component<
585
- TProps extends Record<string, unknown>,
586
- TState extends Record<string, unknown>,
587
- TSignals extends ComponentSignals = Record<string, never>,
588
- >(tagName: string, definition: ComponentDefinition<TProps, TState, TSignals>): void;
589
- export function component<
590
- TProps extends Record<string, unknown>,
591
- TState extends Record<string, unknown> | undefined = undefined,
592
- TSignals extends ComponentSignals = Record<string, never>,
593
- >(tagName: string, definition: ComponentDefinition<TProps, TState, TSignals>): void {
594
- const elementClass = createComponentClass(tagName, definition);
595
-
596
- if (!customElements.get(tagName)) {
597
- customElements.define(tagName, elementClass);
598
- }
599
- }
1
+ /**
2
+ * Web Component factory and registry.
3
+ *
4
+ * @module bquery/component
5
+ */
6
+
7
+ import type { CleanupFn } from '../reactive/signal';
8
+ import { effect, untrack } from '../reactive/signal';
9
+ import { sanitizeHtml } from '../security/sanitize';
10
+ import { coercePropValue } from './props';
11
+ import { createComponentScope, setCurrentScope, type ComponentScope } from './scope';
12
+ import type {
13
+ AttributeChange,
14
+ ComponentClass,
15
+ ComponentDefinition,
16
+ ComponentSignalLike,
17
+ ComponentSignals,
18
+ ComponentStateShape,
19
+ PropDefinition,
20
+ ShadowMode,
21
+ } from './types';
22
+
23
+ /**
24
+ * Base extra tags preserved for component shadow DOM renders in addition to the
25
+ * global sanitizer defaults. `slot` must remain allowed here because shadow DOM
26
+ * content projection depends on authored `<slot>` elements in component render
27
+ * output.
28
+ */
29
+ const COMPONENT_ALLOWED_TAGS = ['slot'];
30
+
31
+ /**
32
+ * Base extra attributes preserved for component shadow DOM renders in addition
33
+ * to the global sanitizer defaults.
34
+ */
35
+ const COMPONENT_ALLOWED_ATTRIBUTES = [
36
+ 'part',
37
+ // Standard form attributes required by interactive shadow DOM content
38
+ 'disabled',
39
+ 'checked',
40
+ 'placeholder',
41
+ 'value',
42
+ 'rows',
43
+ 'cols',
44
+ 'readonly',
45
+ 'required',
46
+ 'maxlength',
47
+ 'minlength',
48
+ 'max',
49
+ 'min',
50
+ 'step',
51
+ 'pattern',
52
+ 'autocomplete',
53
+ 'autofocus',
54
+ 'for',
55
+ 'multiple',
56
+ 'selected',
57
+ 'wrap',
58
+ ];
59
+
60
+ /**
61
+ * Creates a custom element class for a component definition.
62
+ *
63
+ * This is useful when you want to extend or register the class manually
64
+ * (e.g. with different tag names in tests or custom registries).
65
+ *
66
+ * @template TProps - Type of the component's props
67
+ * @param tagName - The custom element tag name (used for diagnostics)
68
+ * @param definition - The component configuration
69
+ */
70
+ const createComponentClass = <
71
+ TProps extends Record<string, unknown>,
72
+ TState extends Record<string, unknown> | undefined = undefined,
73
+ TSignals extends ComponentSignals = Record<string, never>,
74
+ >(
75
+ tagName: string,
76
+ definition: ComponentDefinition<TProps, TState, TSignals>
77
+ ): ComponentClass<TState> => {
78
+ const componentAllowedTags = [
79
+ ...COMPONENT_ALLOWED_TAGS,
80
+ ...(definition.sanitize?.allowTags ?? []),
81
+ ];
82
+ const componentAllowedAttributes = [
83
+ ...COMPONENT_ALLOWED_ATTRIBUTES,
84
+ ...(definition.sanitize?.allowAttributes ?? []),
85
+ ];
86
+ const signalSources = Object.values(definition.signals ?? {}) as ComponentSignalLike<unknown>[];
87
+
88
+ /** Resolve the Shadow DOM mode from the `shadow` option. */
89
+ const resolveShadowMode = (option: ShadowMode | undefined): 'open' | 'closed' | false => {
90
+ if (option === false) return false;
91
+ if (option === 'closed') return 'closed';
92
+ // true, 'open', or undefined all resolve to 'open'
93
+ return 'open';
94
+ };
95
+ const shadowMode = resolveShadowMode(definition.shadow);
96
+
97
+ /**
98
+ * Merges prop-derived observed attributes with any extra attributes from
99
+ * `observeAttributes`, deduplicating to avoid redundant callbacks.
100
+ */
101
+ const observedAttrs = Array.from(
102
+ new Set([...Object.keys(definition.props ?? {}), ...(definition.observeAttributes ?? [])])
103
+ );
104
+
105
+ class BQueryComponent extends HTMLElement {
106
+ /** Internal state object for the component */
107
+ private readonly state: ComponentStateShape<TState> = {
108
+ ...(definition.state ?? {}),
109
+ } as ComponentStateShape<TState>;
110
+ /** Typed props object populated from attributes */
111
+ private props = {} as TProps;
112
+ /** Tracks missing required props for validation during connectedCallback */
113
+ private missingRequiredProps = new Set<string>();
114
+ /** Tracks whether the component has completed its initial mount */
115
+ private hasMounted = false;
116
+ /** Cleanup for external signal subscriptions */
117
+ private signalEffectCleanup?: CleanupFn;
118
+ /** Component-scoped reactive resource tracker */
119
+ private scope?: ComponentScope;
120
+ /** Render target for open/closed shadow roots or the host element when shadow DOM is disabled */
121
+ private readonly renderRootNode: HTMLElement | ShadowRoot;
122
+
123
+ constructor() {
124
+ super();
125
+ if (shadowMode !== false) {
126
+ this.renderRootNode = this.attachShadow({ mode: shadowMode });
127
+ } else {
128
+ this.renderRootNode = this;
129
+ }
130
+ this.syncProps();
131
+ }
132
+
133
+ /**
134
+ * Returns the list of attributes to observe for changes.
135
+ */
136
+ static get observedAttributes(): string[] {
137
+ return observedAttrs;
138
+ }
139
+
140
+ /**
141
+ * Called when the element is added to the DOM.
142
+ */
143
+ connectedCallback(): void {
144
+ try {
145
+ // Defer only the initial mount until all required props are present.
146
+ // Already-mounted components must still reconnect their signal
147
+ // subscriptions so reactive updates can resume after reattachment.
148
+ if (!this.hasMounted && this.missingRequiredProps.size > 0) {
149
+ // Component will mount once all required props are satisfied
150
+ // via attributeChangedCallback
151
+ return;
152
+ }
153
+ if (this.hasMounted) {
154
+ // Recreate scope for reconnected component
155
+ this.scope = createComponentScope();
156
+ const previousScope = setCurrentScope(this.scope);
157
+ try {
158
+ definition.connected?.call(this);
159
+ } catch (error) {
160
+ this.handleError(error as Error);
161
+ } finally {
162
+ setCurrentScope(previousScope);
163
+ }
164
+ this.setupSignalSubscriptions(true);
165
+ return;
166
+ }
167
+ this.mount();
168
+ } catch (error) {
169
+ this.handleError(error as Error);
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Performs the initial mount of the component.
175
+ * Called when the element is connected and all required props are present.
176
+ * @internal
177
+ */
178
+ private mount(): void {
179
+ if (this.hasMounted) return;
180
+ const previousScope = setCurrentScope(this.ensureScope());
181
+ let hookError = false;
182
+ try {
183
+ definition.beforeMount?.call(this);
184
+ definition.connected?.call(this);
185
+ } catch (error) {
186
+ hookError = true;
187
+ this.handleError(error as Error);
188
+ } finally {
189
+ setCurrentScope(previousScope);
190
+ }
191
+ if (hookError) {
192
+ this.scope?.dispose();
193
+ this.scope = undefined;
194
+ return;
195
+ }
196
+ this.render();
197
+ this.setupSignalSubscriptions();
198
+ this.hasMounted = true;
199
+ }
200
+
201
+ /**
202
+ * Called when the element is removed from the DOM.
203
+ */
204
+ disconnectedCallback(): void {
205
+ try {
206
+ this.signalEffectCleanup?.();
207
+ this.signalEffectCleanup = undefined;
208
+ // Dispose all scoped reactive resources (useSignal, useComputed, useEffect)
209
+ this.scope?.dispose();
210
+ this.scope = undefined;
211
+ definition.disconnected?.call(this);
212
+ } catch (error) {
213
+ this.handleError(error as Error);
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Called when an observed attribute changes.
219
+ */
220
+ attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
221
+ try {
222
+ const previousProps = this.cloneProps();
223
+ this.syncProps();
224
+
225
+ // Fire the user-facing onAttributeChanged hook for every observed attribute change
226
+ if (definition.onAttributeChanged) {
227
+ const previousScope = setCurrentScope(this.ensureScope());
228
+ try {
229
+ definition.onAttributeChanged.call(this, name, oldValue, newValue);
230
+ } finally {
231
+ setCurrentScope(previousScope);
232
+ }
233
+ }
234
+
235
+ if (this.hasMounted) {
236
+ // Component already mounted - trigger update render
237
+ this.render(true, previousProps, { name, oldValue, newValue });
238
+ } else if (this.isConnected && this.missingRequiredProps.size === 0) {
239
+ // All required props are now satisfied and element is connected
240
+ // Trigger the deferred initial mount
241
+ this.mount();
242
+ }
243
+ } catch (error) {
244
+ this.handleError(error as Error);
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Called when the element is moved to a new document (e.g. via `document.adoptNode`).
250
+ */
251
+ adoptedCallback(): void {
252
+ if (!definition.onAdopted) {
253
+ return;
254
+ }
255
+
256
+ const previousScope = setCurrentScope(this.ensureScope());
257
+ try {
258
+ definition.onAdopted.call(this);
259
+ } catch (error) {
260
+ this.handleError(error as Error);
261
+ } finally {
262
+ setCurrentScope(previousScope);
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Handles errors during component lifecycle.
268
+ * @internal
269
+ */
270
+ private handleError(error: Error): void {
271
+ if (definition.onError) {
272
+ definition.onError.call(this, error);
273
+ } else {
274
+ console.error(`bQuery component error in <${tagName}>:`, error);
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Ensures the component has an active scope for scoped reactive primitives.
280
+ * @internal
281
+ */
282
+ private ensureScope(): ComponentScope {
283
+ return (this.scope ??= createComponentScope());
284
+ }
285
+
286
+ /**
287
+ * Updates a state property and triggers a re-render.
288
+ *
289
+ * @param key - The state property key
290
+ * @param value - The new value
291
+ */
292
+ setState<TKey extends keyof ComponentStateShape<TState>>(
293
+ key: TKey,
294
+ value: ComponentStateShape<TState>[TKey]
295
+ ): void {
296
+ this.state[key] = value;
297
+ this.render(true, this.cloneProps(), undefined, false);
298
+ }
299
+
300
+ /**
301
+ * Gets a state property value.
302
+ *
303
+ * @param key - The state property key
304
+ * @returns The current value
305
+ */
306
+ getState<TKey extends keyof ComponentStateShape<TState>>(
307
+ key: TKey
308
+ ): ComponentStateShape<TState>[TKey];
309
+ getState<TResult = unknown>(key: string): TResult;
310
+ getState(key: string): unknown {
311
+ return (this.state as Record<string, unknown>)[key];
312
+ }
313
+
314
+ /**
315
+ * Subscribes to declared reactive sources and re-renders on change.
316
+ *
317
+ * @param renderOnInitialRun - When true, immediately re-renders after
318
+ * re-subscribing so detached components resync with any signal changes
319
+ * that happened while they were disconnected.
320
+ * @internal
321
+ */
322
+ private setupSignalSubscriptions(renderOnInitialRun = false): void {
323
+ if (this.signalEffectCleanup || signalSources.length === 0) return;
324
+
325
+ let isInitialRun = true;
326
+ this.signalEffectCleanup = effect(() => {
327
+ try {
328
+ for (const source of signalSources) {
329
+ // Intentionally read each source to register this effect as a subscriber.
330
+ void source.value;
331
+ }
332
+
333
+ if (isInitialRun) {
334
+ isInitialRun = false;
335
+ if (renderOnInitialRun && this.hasMounted && this.isConnected) {
336
+ // Signal-driven reconnect renders do not change props, so the
337
+ // previous-props snapshot is the current prop set at reconnect time.
338
+ const previousProps = this.cloneProps();
339
+ untrack(() => {
340
+ this.render(true, previousProps, undefined, false);
341
+ });
342
+ }
343
+ return;
344
+ }
345
+
346
+ if (!this.hasMounted || !this.isConnected) return;
347
+
348
+ // Signal updates leave props unchanged, so cloning the current props
349
+ // provides the previous-props snapshot expected by beforeUpdate().
350
+ const previousProps = this.cloneProps();
351
+ untrack(() => {
352
+ this.render(true, previousProps, undefined, false);
353
+ });
354
+ } catch (error) {
355
+ this.handleError(error as Error);
356
+ }
357
+ });
358
+ }
359
+
360
+ /**
361
+ * Synchronizes props from attributes.
362
+ * @internal
363
+ */
364
+ private syncProps(): void {
365
+ const props = definition.props ?? {};
366
+ for (const [key, config] of Object.entries(props) as [string, PropDefinition][]) {
367
+ const attrValue = this.getAttribute(key);
368
+ let value: unknown;
369
+
370
+ if (attrValue == null) {
371
+ if (config.required && config.default === undefined) {
372
+ // Mark as missing instead of throwing - validate during connectedCallback
373
+ this.missingRequiredProps.add(key);
374
+ value = undefined;
375
+ } else {
376
+ value = config.default ?? undefined;
377
+ }
378
+ } else {
379
+ // Attribute is present, remove from missing set if it was there
380
+ if (this.missingRequiredProps.has(key)) {
381
+ this.missingRequiredProps.delete(key);
382
+ }
383
+ value = coercePropValue(attrValue, config);
384
+ }
385
+
386
+ if (config.validator && value !== undefined) {
387
+ const isValid = config.validator(value);
388
+ if (!isValid) {
389
+ throw new Error(
390
+ `bQuery component: validation failed for prop "${key}" with value ${JSON.stringify(value)}`
391
+ );
392
+ }
393
+ }
394
+
395
+ (this.props as Record<string, unknown>)[key] = value;
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Creates a shallow snapshot of the current props for lifecycle diffing.
401
+ * A shallow copy is sufficient because component props are re-derived from
402
+ * reflected attributes on each update, so nested object mutation is not
403
+ * tracked as part of this lifecycle diff.
404
+ * @internal
405
+ */
406
+ private cloneProps(): TProps {
407
+ return { ...(this.props as Record<string, unknown>) } as TProps;
408
+ }
409
+
410
+ /**
411
+ * Renders the component to its shadow root or host element.
412
+ * @internal
413
+ */
414
+ private render(): void;
415
+ private render(triggerUpdated: true, oldProps: TProps, change?: AttributeChange): void;
416
+ private render(
417
+ triggerUpdated: true,
418
+ oldProps: TProps,
419
+ change: AttributeChange | undefined,
420
+ runBeforeUpdate: boolean
421
+ ): void;
422
+ private render(
423
+ triggerUpdated = false,
424
+ oldProps?: TProps,
425
+ change?: AttributeChange,
426
+ runBeforeUpdate = true
427
+ ): void {
428
+ try {
429
+ if (triggerUpdated && runBeforeUpdate && definition.beforeUpdate) {
430
+ if (!oldProps) {
431
+ throw new Error('bQuery component: previous props are required for update renders');
432
+ }
433
+ const shouldUpdate = definition.beforeUpdate.call(this, this.props, oldProps);
434
+ if (shouldUpdate === false) return;
435
+ }
436
+
437
+ const emit = (event: string, detail?: unknown): void => {
438
+ this.dispatchEvent(new CustomEvent(event, { detail, bubbles: true, composed: true }));
439
+ };
440
+
441
+ const renderRoot = this.renderRootNode;
442
+
443
+ const markup = definition.render({
444
+ props: this.props,
445
+ state: this.state,
446
+ signals: (definition.signals ?? {}) as TSignals,
447
+ emit,
448
+ });
449
+
450
+ // Component render output is authored by the component definition itself,
451
+ // so we can explicitly preserve shadow-DOM-specific markup such as <slot>,
452
+ // the stylistic `part` attribute, and standard form/input attributes without
453
+ // relaxing the global DOM sanitization rules.
454
+ const sanitizedMarkup = sanitizeHtml(markup, {
455
+ allowTags: componentAllowedTags,
456
+ allowAttributes: componentAllowedAttributes,
457
+ });
458
+ let existingStyleElement: HTMLStyleElement | null = null;
459
+ if (definition.styles) {
460
+ existingStyleElement = renderRoot.querySelector<HTMLStyleElement>(
461
+ 'style[data-bquery-component-style]'
462
+ );
463
+ }
464
+
465
+ renderRoot.innerHTML = sanitizedMarkup;
466
+
467
+ if (definition.styles) {
468
+ const styleElement = existingStyleElement ?? document.createElement('style');
469
+ if (!existingStyleElement) {
470
+ styleElement.setAttribute('data-bquery-component-style', '');
471
+ }
472
+ styleElement.textContent = definition.styles;
473
+ renderRoot.prepend(styleElement);
474
+ }
475
+
476
+ if (triggerUpdated) {
477
+ definition.updated?.call(this, change);
478
+ }
479
+ } catch (error) {
480
+ this.handleError(error as Error);
481
+ }
482
+ }
483
+ }
484
+
485
+ return BQueryComponent as ComponentClass<TState>;
486
+ };
487
+
488
+ /**
489
+ * Creates a custom element class for a component definition.
490
+ *
491
+ * This is useful when you want to extend or register the class manually
492
+ * (e.g. with different tag names in tests or custom registries).
493
+ *
494
+ * @template TProps - Type of the component's props
495
+ * @template TState - Type of the component's internal state. When provided,
496
+ * `definition.state` is required, `render({ state })` is strongly typed, and
497
+ * returned instances expose typed `getState()` / `setState()` helpers.
498
+ * @param tagName - The custom element tag name (used for diagnostics)
499
+ * @param definition - The component configuration
500
+ */
501
+ export function defineComponent<
502
+ TProps extends Record<string, unknown>,
503
+ TSignals extends ComponentSignals = Record<string, never>,
504
+ >(
505
+ tagName: string,
506
+ definition: ComponentDefinition<TProps, undefined, TSignals>
507
+ ): ComponentClass<undefined>;
508
+ export function defineComponent<
509
+ TProps extends Record<string, unknown>,
510
+ TState extends Record<string, unknown>,
511
+ TSignals extends ComponentSignals = Record<string, never>,
512
+ >(
513
+ tagName: string,
514
+ definition: ComponentDefinition<TProps, TState, TSignals>
515
+ ): ComponentClass<TState>;
516
+ export function defineComponent<
517
+ TProps extends Record<string, unknown>,
518
+ TState extends Record<string, unknown> | undefined = undefined,
519
+ TSignals extends ComponentSignals = Record<string, never>,
520
+ >(
521
+ tagName: string,
522
+ definition: ComponentDefinition<TProps, TState, TSignals>
523
+ ): ComponentClass<TState> {
524
+ return createComponentClass(tagName, definition);
525
+ }
526
+
527
+ /**
528
+ * Defines and registers a custom Web Component.
529
+ *
530
+ * This function creates a new custom element with the given tag name
531
+ * and configuration. The component uses Shadow DOM for encapsulation
532
+ * and automatically re-renders when observed attributes change.
533
+ *
534
+ * @template TProps - Type of the component's props
535
+ * @template TState - Type of the component's internal state. When provided,
536
+ * `definition.state` is required and lifecycle hooks receive typed state
537
+ * helpers via `this.getState()` / `this.setState()`.
538
+ * @param tagName - The custom element tag name (must contain a hyphen)
539
+ * @param definition - The component configuration
540
+ *
541
+ * @example
542
+ * ```ts
543
+ * component<{ start: number }, { count: number }>('counter-button', {
544
+ * props: {
545
+ * start: { type: Number, default: 0 },
546
+ * },
547
+ * state: { count: 0 },
548
+ * styles: `
549
+ * button { padding: 0.5rem 1rem; }
550
+ * `,
551
+ * connected() {
552
+ * // Use event delegation on shadow root so handler survives re-renders
553
+ * const handleClick = (event: Event) => {
554
+ * const target = event.target as HTMLElement | null;
555
+ * if (target?.matches('button')) {
556
+ * this.setState('count', this.getState('count') + 1);
557
+ * }
558
+ * };
559
+ * this.shadowRoot?.addEventListener('click', handleClick);
560
+ * // Store handler for cleanup
561
+ * (this as any)._handleClick = handleClick;
562
+ * },
563
+ * disconnected() {
564
+ * // Clean up event listener to prevent memory leaks
565
+ * const handleClick = (this as any)._handleClick;
566
+ * if (handleClick) {
567
+ * this.shadowRoot?.removeEventListener('click', handleClick);
568
+ * }
569
+ * },
570
+ * render({ props, state }) {
571
+ * return html`
572
+ * <button>
573
+ * Count: ${state.count}
574
+ * </button>
575
+ * `;
576
+ * },
577
+ * });
578
+ * ```
579
+ */
580
+ export function component<
581
+ TProps extends Record<string, unknown>,
582
+ TSignals extends ComponentSignals = Record<string, never>,
583
+ >(tagName: string, definition: ComponentDefinition<TProps, undefined, TSignals>): void;
584
+ export function component<
585
+ TProps extends Record<string, unknown>,
586
+ TState extends Record<string, unknown>,
587
+ TSignals extends ComponentSignals = Record<string, never>,
588
+ >(tagName: string, definition: ComponentDefinition<TProps, TState, TSignals>): void;
589
+ export function component<
590
+ TProps extends Record<string, unknown>,
591
+ TState extends Record<string, unknown> | undefined = undefined,
592
+ TSignals extends ComponentSignals = Record<string, never>,
593
+ >(tagName: string, definition: ComponentDefinition<TProps, TState, TSignals>): void {
594
+ const elementClass = createComponentClass(tagName, definition);
595
+
596
+ if (!customElements.get(tagName)) {
597
+ customElements.define(tagName, elementClass);
598
+ }
599
+ }