@bquery/bquery 1.4.0 → 1.6.0

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 (164) hide show
  1. package/README.md +586 -527
  2. package/dist/component/component.d.ts +13 -5
  3. package/dist/component/component.d.ts.map +1 -1
  4. package/dist/component/html.d.ts +40 -3
  5. package/dist/component/html.d.ts.map +1 -1
  6. package/dist/component/index.d.ts +4 -2
  7. package/dist/component/index.d.ts.map +1 -1
  8. package/dist/component/library.d.ts +34 -0
  9. package/dist/component/library.d.ts.map +1 -0
  10. package/dist/component/types.d.ts +132 -13
  11. package/dist/component/types.d.ts.map +1 -1
  12. package/dist/component-BEQgt5hl.js +600 -0
  13. package/dist/component-BEQgt5hl.js.map +1 -0
  14. package/dist/component.es.mjs +7 -184
  15. package/dist/config-DRmZZno3.js +40 -0
  16. package/dist/config-DRmZZno3.js.map +1 -0
  17. package/dist/core-BGQJVw0-.js +35 -0
  18. package/dist/core-BGQJVw0-.js.map +1 -0
  19. package/dist/core-CCEabVHl.js +648 -0
  20. package/dist/core-CCEabVHl.js.map +1 -0
  21. package/dist/core.es.mjs +45 -1261
  22. package/dist/effect-AFRW_Plg.js +84 -0
  23. package/dist/effect-AFRW_Plg.js.map +1 -0
  24. package/dist/full.d.ts +8 -8
  25. package/dist/full.d.ts.map +1 -1
  26. package/dist/full.es.mjs +101 -91
  27. package/dist/full.iife.js +173 -3
  28. package/dist/full.iife.js.map +1 -1
  29. package/dist/full.umd.js +173 -3
  30. package/dist/full.umd.js.map +1 -1
  31. package/dist/index.es.mjs +147 -139
  32. package/dist/motion/transition.d.ts +1 -1
  33. package/dist/motion/transition.d.ts.map +1 -1
  34. package/dist/motion/types.d.ts +11 -1
  35. package/dist/motion/types.d.ts.map +1 -1
  36. package/dist/motion-D9TcHxOF.js +415 -0
  37. package/dist/motion-D9TcHxOF.js.map +1 -0
  38. package/dist/motion.es.mjs +25 -361
  39. package/dist/object-qGpWr6-J.js +38 -0
  40. package/dist/object-qGpWr6-J.js.map +1 -0
  41. package/dist/platform/announcer.d.ts +59 -0
  42. package/dist/platform/announcer.d.ts.map +1 -0
  43. package/dist/platform/config.d.ts +92 -0
  44. package/dist/platform/config.d.ts.map +1 -0
  45. package/dist/platform/cookies.d.ts +45 -0
  46. package/dist/platform/cookies.d.ts.map +1 -0
  47. package/dist/platform/index.d.ts +8 -0
  48. package/dist/platform/index.d.ts.map +1 -1
  49. package/dist/platform/meta.d.ts +62 -0
  50. package/dist/platform/meta.d.ts.map +1 -0
  51. package/dist/platform-Dr9b6fsq.js +362 -0
  52. package/dist/platform-Dr9b6fsq.js.map +1 -0
  53. package/dist/platform.es.mjs +11 -248
  54. package/dist/reactive/async-data.d.ts +114 -0
  55. package/dist/reactive/async-data.d.ts.map +1 -0
  56. package/dist/reactive/index.d.ts +2 -2
  57. package/dist/reactive/index.d.ts.map +1 -1
  58. package/dist/reactive/signal.d.ts +2 -0
  59. package/dist/reactive/signal.d.ts.map +1 -1
  60. package/dist/reactive-DSkct0dO.js +254 -0
  61. package/dist/reactive-DSkct0dO.js.map +1 -0
  62. package/dist/reactive.es.mjs +18 -32
  63. package/dist/router-CbDhl8rS.js +188 -0
  64. package/dist/router-CbDhl8rS.js.map +1 -0
  65. package/dist/router.es.mjs +11 -200
  66. package/dist/sanitize-Bs2dkMby.js +313 -0
  67. package/dist/sanitize-Bs2dkMby.js.map +1 -0
  68. package/dist/security/constants.d.ts.map +1 -1
  69. package/dist/security/index.d.ts +4 -2
  70. package/dist/security/index.d.ts.map +1 -1
  71. package/dist/security/sanitize.d.ts +4 -1
  72. package/dist/security/sanitize.d.ts.map +1 -1
  73. package/dist/security/trusted-html.d.ts +53 -0
  74. package/dist/security/trusted-html.d.ts.map +1 -0
  75. package/dist/security.es.mjs +11 -56
  76. package/dist/store/define-store.d.ts +1 -1
  77. package/dist/store/define-store.d.ts.map +1 -1
  78. package/dist/store/mapping.d.ts +1 -1
  79. package/dist/store/mapping.d.ts.map +1 -1
  80. package/dist/store/persisted.d.ts +1 -1
  81. package/dist/store/persisted.d.ts.map +1 -1
  82. package/dist/store/types.d.ts +2 -2
  83. package/dist/store/types.d.ts.map +1 -1
  84. package/dist/store/watch.d.ts +1 -1
  85. package/dist/store/watch.d.ts.map +1 -1
  86. package/dist/store-BwDvI45q.js +263 -0
  87. package/dist/store-BwDvI45q.js.map +1 -0
  88. package/dist/store.es.mjs +12 -25
  89. package/dist/storybook/index.d.ts +37 -0
  90. package/dist/storybook/index.d.ts.map +1 -0
  91. package/dist/storybook.es.mjs +151 -0
  92. package/dist/storybook.es.mjs.map +1 -0
  93. package/dist/untrack-B0rVscTc.js +7 -0
  94. package/dist/untrack-B0rVscTc.js.map +1 -0
  95. package/dist/view-C70lA3vf.js +397 -0
  96. package/dist/view-C70lA3vf.js.map +1 -0
  97. package/dist/view.es.mjs +11 -430
  98. package/package.json +141 -132
  99. package/src/component/component.ts +524 -289
  100. package/src/component/html.ts +153 -53
  101. package/src/component/index.ts +50 -40
  102. package/src/component/library.ts +518 -0
  103. package/src/component/types.ts +256 -85
  104. package/src/core/collection.ts +628 -628
  105. package/src/core/element.ts +774 -774
  106. package/src/core/index.ts +48 -48
  107. package/src/core/utils/function.ts +151 -151
  108. package/src/full.ts +229 -187
  109. package/src/motion/animate.ts +113 -113
  110. package/src/motion/flip.ts +176 -176
  111. package/src/motion/scroll.ts +57 -57
  112. package/src/motion/spring.ts +150 -150
  113. package/src/motion/timeline.ts +246 -246
  114. package/src/motion/transition.ts +97 -51
  115. package/src/motion/types.ts +11 -1
  116. package/src/platform/announcer.ts +208 -0
  117. package/src/platform/config.ts +163 -0
  118. package/src/platform/cookies.ts +165 -0
  119. package/src/platform/index.ts +21 -0
  120. package/src/platform/meta.ts +168 -0
  121. package/src/platform/storage.ts +215 -215
  122. package/src/reactive/async-data.ts +486 -0
  123. package/src/reactive/core.ts +114 -114
  124. package/src/reactive/effect.ts +54 -54
  125. package/src/reactive/index.ts +15 -1
  126. package/src/reactive/internals.ts +122 -122
  127. package/src/reactive/signal.ts +9 -0
  128. package/src/security/constants.ts +3 -1
  129. package/src/security/index.ts +17 -10
  130. package/src/security/sanitize-core.ts +364 -364
  131. package/src/security/sanitize.ts +70 -66
  132. package/src/security/trusted-html.ts +71 -0
  133. package/src/store/define-store.ts +49 -48
  134. package/src/store/mapping.ts +74 -73
  135. package/src/store/persisted.ts +62 -61
  136. package/src/store/types.ts +92 -94
  137. package/src/store/watch.ts +53 -52
  138. package/src/storybook/index.ts +479 -0
  139. package/src/view/evaluate.ts +290 -290
  140. package/dist/batch-x7b2eZST.js +0 -13
  141. package/dist/batch-x7b2eZST.js.map +0 -1
  142. package/dist/component.es.mjs.map +0 -1
  143. package/dist/core-BhpuvPhy.js +0 -170
  144. package/dist/core-BhpuvPhy.js.map +0 -1
  145. package/dist/core.es.mjs.map +0 -1
  146. package/dist/full.es.mjs.map +0 -1
  147. package/dist/index.es.mjs.map +0 -1
  148. package/dist/motion.es.mjs.map +0 -1
  149. package/dist/persisted-DHoi3uEs.js +0 -278
  150. package/dist/persisted-DHoi3uEs.js.map +0 -1
  151. package/dist/platform.es.mjs.map +0 -1
  152. package/dist/reactive.es.mjs.map +0 -1
  153. package/dist/router.es.mjs.map +0 -1
  154. package/dist/sanitize-Cxvxa-DX.js +0 -283
  155. package/dist/sanitize-Cxvxa-DX.js.map +0 -1
  156. package/dist/security.es.mjs.map +0 -1
  157. package/dist/store.es.mjs.map +0 -1
  158. package/dist/type-guards-BdKlYYlS.js +0 -32
  159. package/dist/type-guards-BdKlYYlS.js.map +0 -1
  160. package/dist/untrack-DNnnqdlR.js +0 -6
  161. package/dist/untrack-DNnnqdlR.js.map +0 -1
  162. package/dist/view.es.mjs.map +0 -1
  163. package/dist/watch-DXXv3iAI.js +0 -58
  164. package/dist/watch-DXXv3iAI.js.map +0 -1
@@ -1,289 +1,524 @@
1
- /**
2
- * Web Component factory and registry.
3
- *
4
- * @module bquery/component
5
- */
6
-
7
- import { sanitizeHtml } from '../security/sanitize';
8
- import { coercePropValue } from './props';
9
- import type { ComponentDefinition, PropDefinition } from './types';
10
-
11
- /**
12
- * Creates a custom element class for a component definition.
13
- *
14
- * This is useful when you want to extend or register the class manually
15
- * (e.g. with different tag names in tests or custom registries).
16
- *
17
- * @template TProps - Type of the component's props
18
- * @param tagName - The custom element tag name (used for diagnostics)
19
- * @param definition - The component configuration
20
- */
21
- export const defineComponent = <TProps extends Record<string, unknown>>(
22
- tagName: string,
23
- definition: ComponentDefinition<TProps>
24
- ): typeof HTMLElement => {
25
- class BQueryComponent extends HTMLElement {
26
- /** Internal state object for the component */
27
- private readonly state = { ...(definition.state ?? {}) };
28
- /** Typed props object populated from attributes */
29
- private props = {} as TProps;
30
- /** Tracks missing required props for validation during connectedCallback */
31
- private missingRequiredProps = new Set<string>();
32
- /** Tracks whether the component has completed its initial mount */
33
- private hasMounted = false;
34
-
35
- constructor() {
36
- super();
37
- this.attachShadow({ mode: 'open' });
38
- this.syncProps();
39
- }
40
-
41
- /**
42
- * Returns the list of attributes to observe for changes.
43
- */
44
- static get observedAttributes(): string[] {
45
- return Object.keys(definition.props ?? {});
46
- }
47
-
48
- /**
49
- * Called when the element is added to the DOM.
50
- */
51
- connectedCallback(): void {
52
- try {
53
- // Defer initial render until all required props are present
54
- // This allows attributes to be set after element creation
55
- if (this.missingRequiredProps.size > 0) {
56
- // Component will mount once all required props are satisfied
57
- // via attributeChangedCallback
58
- return;
59
- }
60
- this.mount();
61
- } catch (error) {
62
- this.handleError(error as Error);
63
- }
64
- }
65
-
66
- /**
67
- * Performs the initial mount of the component.
68
- * Called when the element is connected and all required props are present.
69
- * @internal
70
- */
71
- private mount(): void {
72
- if (this.hasMounted) return;
73
- definition.beforeMount?.call(this);
74
- definition.connected?.call(this);
75
- this.render();
76
- this.hasMounted = true;
77
- }
78
-
79
- /**
80
- * Called when the element is removed from the DOM.
81
- */
82
- disconnectedCallback(): void {
83
- try {
84
- definition.disconnected?.call(this);
85
- } catch (error) {
86
- this.handleError(error as Error);
87
- }
88
- }
89
-
90
- /**
91
- * Called when an observed attribute changes.
92
- */
93
- attributeChangedCallback(
94
- _name: string,
95
- _oldValue: string | null,
96
- _newValue: string | null
97
- ): void {
98
- try {
99
- this.syncProps();
100
-
101
- if (this.hasMounted) {
102
- // Component already mounted - trigger update render
103
- this.render(true);
104
- } else if (this.isConnected && this.missingRequiredProps.size === 0) {
105
- // All required props are now satisfied and element is connected
106
- // Trigger the deferred initial mount
107
- this.mount();
108
- }
109
- } catch (error) {
110
- this.handleError(error as Error);
111
- }
112
- }
113
-
114
- /**
115
- * Handles errors during component lifecycle.
116
- * @internal
117
- */
118
- private handleError(error: Error): void {
119
- if (definition.onError) {
120
- definition.onError.call(this, error);
121
- } else {
122
- console.error(`bQuery component error in <${tagName}>:`, error);
123
- }
124
- }
125
-
126
- /**
127
- * Updates a state property and triggers a re-render.
128
- *
129
- * @param key - The state property key
130
- * @param value - The new value
131
- */
132
- setState(key: string, value: unknown): void {
133
- this.state[key] = value;
134
- this.render(true);
135
- }
136
-
137
- /**
138
- * Gets a state property value.
139
- *
140
- * @param key - The state property key
141
- * @returns The current value
142
- */
143
- getState<T = unknown>(key: string): T {
144
- return this.state[key] as T;
145
- }
146
-
147
- /**
148
- * Synchronizes props from attributes.
149
- * @internal
150
- */
151
- private syncProps(): void {
152
- const props = definition.props ?? {};
153
- for (const [key, config] of Object.entries(props) as [string, PropDefinition][]) {
154
- const attrValue = this.getAttribute(key);
155
- let value: unknown;
156
-
157
- if (attrValue == null) {
158
- if (config.required && config.default === undefined) {
159
- // Mark as missing instead of throwing - validate during connectedCallback
160
- this.missingRequiredProps.add(key);
161
- value = undefined;
162
- } else {
163
- value = config.default ?? undefined;
164
- }
165
- } else {
166
- // Attribute is present, remove from missing set if it was there
167
- if (this.missingRequiredProps.has(key)) {
168
- this.missingRequiredProps.delete(key);
169
- }
170
- value = coercePropValue(attrValue, config);
171
- }
172
-
173
- if (config.validator && value !== undefined) {
174
- const isValid = config.validator(value);
175
- if (!isValid) {
176
- throw new Error(
177
- `bQuery component: validation failed for prop "${key}" with value ${JSON.stringify(value)}`
178
- );
179
- }
180
- }
181
-
182
- (this.props as Record<string, unknown>)[key] = value;
183
- }
184
- }
185
-
186
- /**
187
- * Renders the component to its shadow root.
188
- * @internal
189
- */
190
- private render(triggerUpdated = false): void {
191
- try {
192
- if (triggerUpdated && definition.beforeUpdate) {
193
- const shouldUpdate = definition.beforeUpdate.call(this, this.props);
194
- if (shouldUpdate === false) return;
195
- }
196
-
197
- const emit = (event: string, detail?: unknown): void => {
198
- this.dispatchEvent(new CustomEvent(event, { detail, bubbles: true, composed: true }));
199
- };
200
-
201
- if (!this.shadowRoot) return;
202
-
203
- const markup = definition.render({
204
- props: this.props,
205
- state: this.state,
206
- emit,
207
- });
208
-
209
- const sanitizedMarkup = sanitizeHtml(markup);
210
- this.shadowRoot.innerHTML = sanitizedMarkup;
211
-
212
- if (definition.styles) {
213
- const styleElement = document.createElement('style');
214
- styleElement.textContent = definition.styles;
215
- this.shadowRoot.prepend(styleElement);
216
- }
217
-
218
- if (triggerUpdated) {
219
- definition.updated?.call(this);
220
- }
221
- } catch (error) {
222
- this.handleError(error as Error);
223
- }
224
- }
225
- }
226
-
227
- return BQueryComponent;
228
- };
229
-
230
- /**
231
- * Defines and registers a custom Web Component.
232
- *
233
- * This function creates a new custom element with the given tag name
234
- * and configuration. The component uses Shadow DOM for encapsulation
235
- * and automatically re-renders when observed attributes change.
236
- *
237
- * @template TProps - Type of the component's props
238
- * @param tagName - The custom element tag name (must contain a hyphen)
239
- * @param definition - The component configuration
240
- *
241
- * @example
242
- * ```ts
243
- * component('counter-button', {
244
- * props: {
245
- * start: { type: Number, default: 0 },
246
- * },
247
- * state: { count: 0 },
248
- * styles: `
249
- * button { padding: 0.5rem 1rem; }
250
- * `,
251
- * connected() {
252
- * // Use event delegation on shadow root so handler survives re-renders
253
- * const handleClick = (event: Event) => {
254
- * const target = event.target as HTMLElement | null;
255
- * if (target?.matches('button')) {
256
- * this.setState('count', (this.getState('count') as number) + 1);
257
- * }
258
- * };
259
- * this.shadowRoot?.addEventListener('click', handleClick);
260
- * // Store handler for cleanup
261
- * (this as any)._handleClick = handleClick;
262
- * },
263
- * disconnected() {
264
- * // Clean up event listener to prevent memory leaks
265
- * const handleClick = (this as any)._handleClick;
266
- * if (handleClick) {
267
- * this.shadowRoot?.removeEventListener('click', handleClick);
268
- * }
269
- * },
270
- * render({ props, state }) {
271
- * return html`
272
- * <button>
273
- * Count: ${state.count}
274
- * </button>
275
- * `;
276
- * },
277
- * });
278
- * ```
279
- */
280
- export const component = <TProps extends Record<string, unknown>>(
281
- tagName: string,
282
- definition: ComponentDefinition<TProps>
283
- ): void => {
284
- const elementClass = defineComponent(tagName, definition);
285
-
286
- if (!customElements.get(tagName)) {
287
- customElements.define(tagName, elementClass);
288
- }
289
- };
1
+ /**
2
+ * Web Component factory and registry.
3
+ *
4
+ * @module bquery/component
5
+ */
6
+
7
+ import { sanitizeHtml } from '../security/sanitize';
8
+ import { effect, untrack } from '../reactive/signal';
9
+ import type { CleanupFn } from '../reactive/signal';
10
+ import { coercePropValue } from './props';
11
+ import type {
12
+ AttributeChange,
13
+ ComponentClass,
14
+ ComponentDefinition,
15
+ ComponentSignalLike,
16
+ ComponentSignals,
17
+ ComponentStateShape,
18
+ PropDefinition,
19
+ } from './types';
20
+
21
+ /**
22
+ * Base extra tags preserved for component shadow DOM renders in addition to the
23
+ * global sanitizer defaults. `slot` must remain allowed here because shadow DOM
24
+ * content projection depends on authored `<slot>` elements in component render
25
+ * output.
26
+ */
27
+ const COMPONENT_ALLOWED_TAGS = ['slot'];
28
+
29
+ /**
30
+ * Base extra attributes preserved for component shadow DOM renders in addition
31
+ * to the global sanitizer defaults.
32
+ */
33
+ const COMPONENT_ALLOWED_ATTRIBUTES = [
34
+ 'part',
35
+ // Standard form attributes required by interactive shadow DOM content
36
+ 'disabled',
37
+ 'checked',
38
+ 'placeholder',
39
+ 'value',
40
+ 'rows',
41
+ 'cols',
42
+ 'readonly',
43
+ 'required',
44
+ 'maxlength',
45
+ 'minlength',
46
+ 'max',
47
+ 'min',
48
+ 'step',
49
+ 'pattern',
50
+ 'autocomplete',
51
+ 'autofocus',
52
+ 'for',
53
+ 'multiple',
54
+ 'selected',
55
+ 'wrap',
56
+ ];
57
+
58
+ /**
59
+ * Creates a custom element class for a component definition.
60
+ *
61
+ * This is useful when you want to extend or register the class manually
62
+ * (e.g. with different tag names in tests or custom registries).
63
+ *
64
+ * @template TProps - Type of the component's props
65
+ * @param tagName - The custom element tag name (used for diagnostics)
66
+ * @param definition - The component configuration
67
+ */
68
+ const createComponentClass = <
69
+ TProps extends Record<string, unknown>,
70
+ TState extends Record<string, unknown> | undefined = undefined,
71
+ TSignals extends ComponentSignals = Record<string, never>,
72
+ >(
73
+ tagName: string,
74
+ definition: ComponentDefinition<TProps, TState, TSignals>
75
+ ): ComponentClass<TState> => {
76
+ const componentAllowedTags = [...COMPONENT_ALLOWED_TAGS, ...(definition.sanitize?.allowTags ?? [])];
77
+ const componentAllowedAttributes = [
78
+ ...COMPONENT_ALLOWED_ATTRIBUTES,
79
+ ...(definition.sanitize?.allowAttributes ?? []),
80
+ ];
81
+ const signalSources = Object.values(definition.signals ?? {}) as ComponentSignalLike<unknown>[];
82
+
83
+ class BQueryComponent extends HTMLElement {
84
+ /** Internal state object for the component */
85
+ private readonly state: ComponentStateShape<TState> = {
86
+ ...(definition.state ?? {}),
87
+ } as ComponentStateShape<TState>;
88
+ /** Typed props object populated from attributes */
89
+ private props = {} as TProps;
90
+ /** Tracks missing required props for validation during connectedCallback */
91
+ private missingRequiredProps = new Set<string>();
92
+ /** Tracks whether the component has completed its initial mount */
93
+ private hasMounted = false;
94
+ /** Cleanup for external signal subscriptions */
95
+ private signalEffectCleanup?: CleanupFn;
96
+
97
+ constructor() {
98
+ super();
99
+ this.attachShadow({ mode: 'open' });
100
+ this.syncProps();
101
+ }
102
+
103
+ /**
104
+ * Returns the list of attributes to observe for changes.
105
+ */
106
+ static get observedAttributes(): string[] {
107
+ return Object.keys(definition.props ?? {});
108
+ }
109
+
110
+ /**
111
+ * Called when the element is added to the DOM.
112
+ */
113
+ connectedCallback(): void {
114
+ try {
115
+ // Defer only the initial mount until all required props are present.
116
+ // Already-mounted components must still reconnect their signal
117
+ // subscriptions so reactive updates can resume after reattachment.
118
+ if (!this.hasMounted && this.missingRequiredProps.size > 0) {
119
+ // Component will mount once all required props are satisfied
120
+ // via attributeChangedCallback
121
+ return;
122
+ }
123
+ if (this.hasMounted) {
124
+ try {
125
+ definition.connected?.call(this);
126
+ } catch (error) {
127
+ this.handleError(error as Error);
128
+ }
129
+ this.setupSignalSubscriptions(true);
130
+ return;
131
+ }
132
+ this.mount();
133
+ } catch (error) {
134
+ this.handleError(error as Error);
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Performs the initial mount of the component.
140
+ * Called when the element is connected and all required props are present.
141
+ * @internal
142
+ */
143
+ private mount(): void {
144
+ if (this.hasMounted) return;
145
+ definition.beforeMount?.call(this);
146
+ definition.connected?.call(this);
147
+ this.render();
148
+ this.setupSignalSubscriptions();
149
+ this.hasMounted = true;
150
+ }
151
+
152
+ /**
153
+ * Called when the element is removed from the DOM.
154
+ */
155
+ disconnectedCallback(): void {
156
+ try {
157
+ this.signalEffectCleanup?.();
158
+ this.signalEffectCleanup = undefined;
159
+ definition.disconnected?.call(this);
160
+ } catch (error) {
161
+ this.handleError(error as Error);
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Called when an observed attribute changes.
167
+ */
168
+ attributeChangedCallback(
169
+ name: string,
170
+ oldValue: string | null,
171
+ newValue: string | null
172
+ ): void {
173
+ try {
174
+ const previousProps = this.cloneProps();
175
+ this.syncProps();
176
+
177
+ if (this.hasMounted) {
178
+ // Component already mounted - trigger update render
179
+ this.render(true, previousProps, { name, oldValue, newValue });
180
+ } else if (this.isConnected && this.missingRequiredProps.size === 0) {
181
+ // All required props are now satisfied and element is connected
182
+ // Trigger the deferred initial mount
183
+ this.mount();
184
+ }
185
+ } catch (error) {
186
+ this.handleError(error as Error);
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Handles errors during component lifecycle.
192
+ * @internal
193
+ */
194
+ private handleError(error: Error): void {
195
+ if (definition.onError) {
196
+ definition.onError.call(this, error);
197
+ } else {
198
+ console.error(`bQuery component error in <${tagName}>:`, error);
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Updates a state property and triggers a re-render.
204
+ *
205
+ * @param key - The state property key
206
+ * @param value - The new value
207
+ */
208
+ setState<TKey extends keyof ComponentStateShape<TState>>(
209
+ key: TKey,
210
+ value: ComponentStateShape<TState>[TKey]
211
+ ): void {
212
+ this.state[key] = value;
213
+ this.render(true, this.cloneProps(), undefined, false);
214
+ }
215
+
216
+ /**
217
+ * Gets a state property value.
218
+ *
219
+ * @param key - The state property key
220
+ * @returns The current value
221
+ */
222
+ getState<TKey extends keyof ComponentStateShape<TState>>(
223
+ key: TKey
224
+ ): ComponentStateShape<TState>[TKey];
225
+ getState<TResult = unknown>(key: string): TResult;
226
+ getState(key: string): unknown {
227
+ return (this.state as Record<string, unknown>)[key];
228
+ }
229
+
230
+ /**
231
+ * Subscribes to declared reactive sources and re-renders on change.
232
+ *
233
+ * @param renderOnInitialRun - When true, immediately re-renders after
234
+ * re-subscribing so detached components resync with any signal changes
235
+ * that happened while they were disconnected.
236
+ * @internal
237
+ */
238
+ private setupSignalSubscriptions(renderOnInitialRun = false): void {
239
+ if (this.signalEffectCleanup || signalSources.length === 0) return;
240
+
241
+ let isInitialRun = true;
242
+ this.signalEffectCleanup = effect(() => {
243
+ try {
244
+ for (const source of signalSources) {
245
+ // Intentionally read each source to register this effect as a subscriber.
246
+ void source.value;
247
+ }
248
+
249
+ if (isInitialRun) {
250
+ isInitialRun = false;
251
+ if (renderOnInitialRun && this.hasMounted && this.isConnected) {
252
+ // Signal-driven reconnect renders do not change props, so the
253
+ // previous-props snapshot is the current prop set at reconnect time.
254
+ const previousProps = this.cloneProps();
255
+ untrack(() => {
256
+ this.render(true, previousProps, undefined, false);
257
+ });
258
+ }
259
+ return;
260
+ }
261
+
262
+ if (!this.hasMounted || !this.isConnected) return;
263
+
264
+ // Signal updates leave props unchanged, so cloning the current props
265
+ // provides the previous-props snapshot expected by beforeUpdate().
266
+ const previousProps = this.cloneProps();
267
+ untrack(() => {
268
+ this.render(true, previousProps, undefined, false);
269
+ });
270
+ } catch (error) {
271
+ this.handleError(error as Error);
272
+ }
273
+ });
274
+ }
275
+
276
+ /**
277
+ * Synchronizes props from attributes.
278
+ * @internal
279
+ */
280
+ private syncProps(): void {
281
+ const props = definition.props ?? {};
282
+ for (const [key, config] of Object.entries(props) as [string, PropDefinition][]) {
283
+ const attrValue = this.getAttribute(key);
284
+ let value: unknown;
285
+
286
+ if (attrValue == null) {
287
+ if (config.required && config.default === undefined) {
288
+ // Mark as missing instead of throwing - validate during connectedCallback
289
+ this.missingRequiredProps.add(key);
290
+ value = undefined;
291
+ } else {
292
+ value = config.default ?? undefined;
293
+ }
294
+ } else {
295
+ // Attribute is present, remove from missing set if it was there
296
+ if (this.missingRequiredProps.has(key)) {
297
+ this.missingRequiredProps.delete(key);
298
+ }
299
+ value = coercePropValue(attrValue, config);
300
+ }
301
+
302
+ if (config.validator && value !== undefined) {
303
+ const isValid = config.validator(value);
304
+ if (!isValid) {
305
+ throw new Error(
306
+ `bQuery component: validation failed for prop "${key}" with value ${JSON.stringify(value)}`
307
+ );
308
+ }
309
+ }
310
+
311
+ (this.props as Record<string, unknown>)[key] = value;
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Creates a shallow snapshot of the current props for lifecycle diffing.
317
+ * A shallow copy is sufficient because component props are re-derived from
318
+ * reflected attributes on each update, so nested object mutation is not
319
+ * tracked as part of this lifecycle diff.
320
+ * @internal
321
+ */
322
+ private cloneProps(): TProps {
323
+ return { ...(this.props as Record<string, unknown>) } as TProps;
324
+ }
325
+
326
+ /**
327
+ * Renders the component to its shadow root.
328
+ * @internal
329
+ */
330
+ private render(): void;
331
+ private render(triggerUpdated: true, oldProps: TProps, change?: AttributeChange): void;
332
+ private render(
333
+ triggerUpdated: true,
334
+ oldProps: TProps,
335
+ change: AttributeChange | undefined,
336
+ runBeforeUpdate: boolean
337
+ ): void;
338
+ private render(
339
+ triggerUpdated = false,
340
+ oldProps?: TProps,
341
+ change?: AttributeChange,
342
+ runBeforeUpdate = true
343
+ ): void {
344
+ try {
345
+ if (triggerUpdated && runBeforeUpdate && definition.beforeUpdate) {
346
+ if (!oldProps) {
347
+ throw new Error('bQuery component: previous props are required for update renders');
348
+ }
349
+ const shouldUpdate = definition.beforeUpdate.call(this, this.props, oldProps);
350
+ if (shouldUpdate === false) return;
351
+ }
352
+
353
+ const emit = (event: string, detail?: unknown): void => {
354
+ this.dispatchEvent(new CustomEvent(event, { detail, bubbles: true, composed: true }));
355
+ };
356
+
357
+ if (!this.shadowRoot) return;
358
+
359
+ const markup = definition.render({
360
+ props: this.props,
361
+ state: this.state,
362
+ signals: (definition.signals ?? {}) as TSignals,
363
+ emit,
364
+ });
365
+
366
+ // Component render output is authored by the component definition itself,
367
+ // so we can explicitly preserve shadow-DOM-specific markup such as <slot>,
368
+ // the stylistic `part` attribute, and standard form/input attributes without
369
+ // relaxing the global DOM sanitization rules.
370
+ const sanitizedMarkup = sanitizeHtml(markup, {
371
+ allowTags: componentAllowedTags,
372
+ allowAttributes: componentAllowedAttributes,
373
+ });
374
+ let existingStyleElement: HTMLStyleElement | null = null;
375
+ if (definition.styles) {
376
+ existingStyleElement = this.shadowRoot.querySelector<HTMLStyleElement>(
377
+ 'style[data-bquery-component-style]'
378
+ );
379
+ }
380
+
381
+ this.shadowRoot.innerHTML = sanitizedMarkup;
382
+
383
+ if (definition.styles) {
384
+ const styleElement = existingStyleElement ?? document.createElement('style');
385
+ if (!existingStyleElement) {
386
+ styleElement.setAttribute('data-bquery-component-style', '');
387
+ }
388
+ styleElement.textContent = definition.styles;
389
+ this.shadowRoot.prepend(styleElement);
390
+ }
391
+
392
+ if (triggerUpdated) {
393
+ definition.updated?.call(this, change);
394
+ }
395
+ } catch (error) {
396
+ this.handleError(error as Error);
397
+ }
398
+ }
399
+ }
400
+
401
+ return BQueryComponent as ComponentClass<TState>;
402
+ };
403
+
404
+ /**
405
+ * Creates a custom element class for a component definition.
406
+ *
407
+ * This is useful when you want to extend or register the class manually
408
+ * (e.g. with different tag names in tests or custom registries).
409
+ *
410
+ * @template TProps - Type of the component's props
411
+ * @template TState - Type of the component's internal state. When provided,
412
+ * `definition.state` is required, `render({ state })` is strongly typed, and
413
+ * returned instances expose typed `getState()` / `setState()` helpers.
414
+ * @param tagName - The custom element tag name (used for diagnostics)
415
+ * @param definition - The component configuration
416
+ */
417
+ export function defineComponent<
418
+ TProps extends Record<string, unknown>,
419
+ TSignals extends ComponentSignals = Record<string, never>,
420
+ >(
421
+ tagName: string,
422
+ definition: ComponentDefinition<TProps, undefined, TSignals>
423
+ ): ComponentClass<undefined>;
424
+ export function defineComponent<
425
+ TProps extends Record<string, unknown>,
426
+ TState extends Record<string, unknown>,
427
+ TSignals extends ComponentSignals = Record<string, never>,
428
+ >(
429
+ tagName: string,
430
+ definition: ComponentDefinition<TProps, TState, TSignals>
431
+ ): ComponentClass<TState>;
432
+ export function defineComponent<
433
+ TProps extends Record<string, unknown>,
434
+ TState extends Record<string, unknown> | undefined = undefined,
435
+ TSignals extends ComponentSignals = Record<string, never>,
436
+ >(
437
+ tagName: string,
438
+ definition: ComponentDefinition<TProps, TState, TSignals>
439
+ ): ComponentClass<TState> {
440
+ return createComponentClass(tagName, definition);
441
+ }
442
+
443
+ /**
444
+ * Defines and registers a custom Web Component.
445
+ *
446
+ * This function creates a new custom element with the given tag name
447
+ * and configuration. The component uses Shadow DOM for encapsulation
448
+ * and automatically re-renders when observed attributes change.
449
+ *
450
+ * @template TProps - Type of the component's props
451
+ * @template TState - Type of the component's internal state. When provided,
452
+ * `definition.state` is required and lifecycle hooks receive typed state
453
+ * helpers via `this.getState()` / `this.setState()`.
454
+ * @param tagName - The custom element tag name (must contain a hyphen)
455
+ * @param definition - The component configuration
456
+ *
457
+ * @example
458
+ * ```ts
459
+ * component<{ start: number }, { count: number }>('counter-button', {
460
+ * props: {
461
+ * start: { type: Number, default: 0 },
462
+ * },
463
+ * state: { count: 0 },
464
+ * styles: `
465
+ * button { padding: 0.5rem 1rem; }
466
+ * `,
467
+ * connected() {
468
+ * // Use event delegation on shadow root so handler survives re-renders
469
+ * const handleClick = (event: Event) => {
470
+ * const target = event.target as HTMLElement | null;
471
+ * if (target?.matches('button')) {
472
+ * this.setState('count', this.getState('count') + 1);
473
+ * }
474
+ * };
475
+ * this.shadowRoot?.addEventListener('click', handleClick);
476
+ * // Store handler for cleanup
477
+ * (this as any)._handleClick = handleClick;
478
+ * },
479
+ * disconnected() {
480
+ * // Clean up event listener to prevent memory leaks
481
+ * const handleClick = (this as any)._handleClick;
482
+ * if (handleClick) {
483
+ * this.shadowRoot?.removeEventListener('click', handleClick);
484
+ * }
485
+ * },
486
+ * render({ props, state }) {
487
+ * return html`
488
+ * <button>
489
+ * Count: ${state.count}
490
+ * </button>
491
+ * `;
492
+ * },
493
+ * });
494
+ * ```
495
+ */
496
+ export function component<
497
+ TProps extends Record<string, unknown>,
498
+ TSignals extends ComponentSignals = Record<string, never>,
499
+ >(
500
+ tagName: string,
501
+ definition: ComponentDefinition<TProps, undefined, TSignals>
502
+ ): void;
503
+ export function component<
504
+ TProps extends Record<string, unknown>,
505
+ TState extends Record<string, unknown>,
506
+ TSignals extends ComponentSignals = Record<string, never>,
507
+ >(
508
+ tagName: string,
509
+ definition: ComponentDefinition<TProps, TState, TSignals>
510
+ ): void;
511
+ export function component<
512
+ TProps extends Record<string, unknown>,
513
+ TState extends Record<string, unknown> | undefined = undefined,
514
+ TSignals extends ComponentSignals = Record<string, never>,
515
+ >(
516
+ tagName: string,
517
+ definition: ComponentDefinition<TProps, TState, TSignals>
518
+ ): void {
519
+ const elementClass = createComponentClass(tagName, definition);
520
+
521
+ if (!customElements.get(tagName)) {
522
+ customElements.define(tagName, elementClass);
523
+ }
524
+ }