@bquery/bquery 1.5.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.
- package/README.md +586 -546
- package/dist/component/component.d.ts +13 -5
- package/dist/component/component.d.ts.map +1 -1
- package/dist/component/html.d.ts +40 -3
- package/dist/component/html.d.ts.map +1 -1
- package/dist/component/index.d.ts +2 -2
- package/dist/component/index.d.ts.map +1 -1
- package/dist/component/library.d.ts.map +1 -1
- package/dist/component/types.d.ts +131 -16
- package/dist/component/types.d.ts.map +1 -1
- package/dist/component-BEQgt5hl.js +600 -0
- package/dist/component-BEQgt5hl.js.map +1 -0
- package/dist/component.es.mjs +7 -6
- package/dist/config-DRmZZno3.js.map +1 -1
- package/dist/core-BGQJVw0-.js +35 -0
- package/dist/core-BGQJVw0-.js.map +1 -0
- package/dist/{core-CK2Mfpf4.js → core-CCEabVHl.js} +2 -2
- package/dist/{core-CK2Mfpf4.js.map → core-CCEabVHl.js.map} +1 -1
- package/dist/core.es.mjs +1 -1
- package/dist/effect-AFRW_Plg.js +84 -0
- package/dist/effect-AFRW_Plg.js.map +1 -0
- package/dist/full.d.ts +4 -4
- package/dist/full.d.ts.map +1 -1
- package/dist/full.es.mjs +98 -94
- package/dist/full.iife.js +14 -14
- package/dist/full.iife.js.map +1 -1
- package/dist/full.umd.js +14 -14
- package/dist/full.umd.js.map +1 -1
- package/dist/index.es.mjs +143 -139
- package/dist/{motion-C5DRdPnO.js → motion-D9TcHxOF.js} +1 -1
- package/dist/{motion-C5DRdPnO.js.map → motion-D9TcHxOF.js.map} +1 -1
- package/dist/motion.es.mjs +1 -1
- package/dist/{platform-B7JhGBc7.js → platform-Dr9b6fsq.js} +21 -20
- package/dist/platform-Dr9b6fsq.js.map +1 -0
- package/dist/platform.es.mjs +1 -1
- package/dist/{reactive-BDya-ia8.js → reactive-DSkct0dO.js} +51 -50
- package/dist/reactive-DSkct0dO.js.map +1 -0
- package/dist/reactive.es.mjs +19 -17
- package/dist/{router-CijiICxt.js → router-CbDhl8rS.js} +3 -3
- package/dist/{router-CijiICxt.js.map → router-CbDhl8rS.js.map} +1 -1
- package/dist/router.es.mjs +1 -1
- package/dist/{sanitize-jyJ2ryE2.js → sanitize-Bs2dkMby.js} +94 -83
- package/dist/sanitize-Bs2dkMby.js.map +1 -0
- package/dist/security/index.d.ts +4 -2
- package/dist/security/index.d.ts.map +1 -1
- package/dist/security/sanitize.d.ts +4 -1
- package/dist/security/sanitize.d.ts.map +1 -1
- package/dist/security/trusted-html.d.ts +53 -0
- package/dist/security/trusted-html.d.ts.map +1 -0
- package/dist/security.es.mjs +10 -9
- package/dist/store/define-store.d.ts +1 -1
- package/dist/store/define-store.d.ts.map +1 -1
- package/dist/store/mapping.d.ts +1 -1
- package/dist/store/mapping.d.ts.map +1 -1
- package/dist/store/persisted.d.ts +1 -1
- package/dist/store/persisted.d.ts.map +1 -1
- package/dist/store/types.d.ts +2 -2
- package/dist/store/types.d.ts.map +1 -1
- package/dist/store/watch.d.ts +1 -1
- package/dist/store/watch.d.ts.map +1 -1
- package/dist/{store-CPK9E62U.js → store-BwDvI45q.js} +49 -48
- package/dist/{store-CPK9E62U.js.map → store-BwDvI45q.js.map} +1 -1
- package/dist/store.es.mjs +1 -1
- package/dist/storybook/index.d.ts +37 -0
- package/dist/storybook/index.d.ts.map +1 -0
- package/dist/storybook.es.mjs +151 -0
- package/dist/storybook.es.mjs.map +1 -0
- package/dist/untrack-B0rVscTc.js +7 -0
- package/dist/untrack-B0rVscTc.js.map +1 -0
- package/dist/{view-Cdi0g-qo.js → view-C70lA3vf.js} +29 -28
- package/dist/{view-Cdi0g-qo.js.map → view-C70lA3vf.js.map} +1 -1
- package/dist/view.es.mjs +9 -8
- package/package.json +141 -136
- package/src/component/component.ts +259 -54
- package/src/component/html.ts +153 -53
- package/src/component/index.ts +10 -2
- package/src/component/library.ts +42 -28
- package/src/component/types.ts +184 -19
- package/src/full.ts +8 -2
- package/src/motion/transition.ts +97 -97
- package/src/motion/types.ts +208 -208
- package/src/platform/announcer.ts +208 -208
- package/src/platform/config.ts +163 -163
- package/src/platform/cookies.ts +165 -165
- package/src/platform/index.ts +39 -39
- package/src/platform/meta.ts +168 -168
- package/src/reactive/async-data.ts +486 -486
- package/src/reactive/index.ts +37 -37
- package/src/reactive/signal.ts +29 -29
- package/src/security/constants.ts +211 -211
- package/src/security/index.ts +17 -10
- package/src/security/sanitize.ts +70 -66
- package/src/security/trusted-html.ts +71 -0
- package/src/store/define-store.ts +49 -48
- package/src/store/mapping.ts +74 -73
- package/src/store/persisted.ts +62 -61
- package/src/store/types.ts +92 -94
- package/src/store/watch.ts +53 -52
- package/src/storybook/index.ts +479 -0
- package/dist/component-CY5MVoYN.js +0 -531
- package/dist/component-CY5MVoYN.js.map +0 -1
- package/dist/core-DPdbItcq.js +0 -112
- package/dist/core-DPdbItcq.js.map +0 -1
- package/dist/platform-B7JhGBc7.js.map +0 -1
- package/dist/reactive-BDya-ia8.js.map +0 -1
- package/dist/sanitize-jyJ2ryE2.js.map +0 -1
|
@@ -5,8 +5,55 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { sanitizeHtml } from '../security/sanitize';
|
|
8
|
+
import { effect, untrack } from '../reactive/signal';
|
|
9
|
+
import type { CleanupFn } from '../reactive/signal';
|
|
8
10
|
import { coercePropValue } from './props';
|
|
9
|
-
import type {
|
|
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
|
+
];
|
|
10
57
|
|
|
11
58
|
/**
|
|
12
59
|
* Creates a custom element class for a component definition.
|
|
@@ -18,19 +65,34 @@ import type { ComponentDefinition, PropDefinition } from './types';
|
|
|
18
65
|
* @param tagName - The custom element tag name (used for diagnostics)
|
|
19
66
|
* @param definition - The component configuration
|
|
20
67
|
*/
|
|
21
|
-
|
|
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
|
+
>(
|
|
22
73
|
tagName: string,
|
|
23
|
-
definition: ComponentDefinition<TProps>
|
|
24
|
-
):
|
|
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
|
+
|
|
25
83
|
class BQueryComponent extends HTMLElement {
|
|
26
84
|
/** Internal state object for the component */
|
|
27
|
-
private readonly state = {
|
|
85
|
+
private readonly state: ComponentStateShape<TState> = {
|
|
86
|
+
...(definition.state ?? {}),
|
|
87
|
+
} as ComponentStateShape<TState>;
|
|
28
88
|
/** Typed props object populated from attributes */
|
|
29
89
|
private props = {} as TProps;
|
|
30
90
|
/** Tracks missing required props for validation during connectedCallback */
|
|
31
91
|
private missingRequiredProps = new Set<string>();
|
|
32
92
|
/** Tracks whether the component has completed its initial mount */
|
|
33
93
|
private hasMounted = false;
|
|
94
|
+
/** Cleanup for external signal subscriptions */
|
|
95
|
+
private signalEffectCleanup?: CleanupFn;
|
|
34
96
|
|
|
35
97
|
constructor() {
|
|
36
98
|
super();
|
|
@@ -50,13 +112,23 @@ export const defineComponent = <TProps extends Record<string, unknown>>(
|
|
|
50
112
|
*/
|
|
51
113
|
connectedCallback(): void {
|
|
52
114
|
try {
|
|
53
|
-
// Defer initial
|
|
54
|
-
//
|
|
55
|
-
|
|
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) {
|
|
56
119
|
// Component will mount once all required props are satisfied
|
|
57
120
|
// via attributeChangedCallback
|
|
58
121
|
return;
|
|
59
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
|
+
}
|
|
60
132
|
this.mount();
|
|
61
133
|
} catch (error) {
|
|
62
134
|
this.handleError(error as Error);
|
|
@@ -73,6 +145,7 @@ export const defineComponent = <TProps extends Record<string, unknown>>(
|
|
|
73
145
|
definition.beforeMount?.call(this);
|
|
74
146
|
definition.connected?.call(this);
|
|
75
147
|
this.render();
|
|
148
|
+
this.setupSignalSubscriptions();
|
|
76
149
|
this.hasMounted = true;
|
|
77
150
|
}
|
|
78
151
|
|
|
@@ -81,6 +154,8 @@ export const defineComponent = <TProps extends Record<string, unknown>>(
|
|
|
81
154
|
*/
|
|
82
155
|
disconnectedCallback(): void {
|
|
83
156
|
try {
|
|
157
|
+
this.signalEffectCleanup?.();
|
|
158
|
+
this.signalEffectCleanup = undefined;
|
|
84
159
|
definition.disconnected?.call(this);
|
|
85
160
|
} catch (error) {
|
|
86
161
|
this.handleError(error as Error);
|
|
@@ -91,16 +166,17 @@ export const defineComponent = <TProps extends Record<string, unknown>>(
|
|
|
91
166
|
* Called when an observed attribute changes.
|
|
92
167
|
*/
|
|
93
168
|
attributeChangedCallback(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
169
|
+
name: string,
|
|
170
|
+
oldValue: string | null,
|
|
171
|
+
newValue: string | null
|
|
97
172
|
): void {
|
|
98
173
|
try {
|
|
174
|
+
const previousProps = this.cloneProps();
|
|
99
175
|
this.syncProps();
|
|
100
176
|
|
|
101
177
|
if (this.hasMounted) {
|
|
102
178
|
// Component already mounted - trigger update render
|
|
103
|
-
this.render(true);
|
|
179
|
+
this.render(true, previousProps, { name, oldValue, newValue });
|
|
104
180
|
} else if (this.isConnected && this.missingRequiredProps.size === 0) {
|
|
105
181
|
// All required props are now satisfied and element is connected
|
|
106
182
|
// Trigger the deferred initial mount
|
|
@@ -129,9 +205,12 @@ export const defineComponent = <TProps extends Record<string, unknown>>(
|
|
|
129
205
|
* @param key - The state property key
|
|
130
206
|
* @param value - The new value
|
|
131
207
|
*/
|
|
132
|
-
setState
|
|
208
|
+
setState<TKey extends keyof ComponentStateShape<TState>>(
|
|
209
|
+
key: TKey,
|
|
210
|
+
value: ComponentStateShape<TState>[TKey]
|
|
211
|
+
): void {
|
|
133
212
|
this.state[key] = value;
|
|
134
|
-
this.render(true);
|
|
213
|
+
this.render(true, this.cloneProps(), undefined, false);
|
|
135
214
|
}
|
|
136
215
|
|
|
137
216
|
/**
|
|
@@ -140,8 +219,58 @@ export const defineComponent = <TProps extends Record<string, unknown>>(
|
|
|
140
219
|
* @param key - The state property key
|
|
141
220
|
* @returns The current value
|
|
142
221
|
*/
|
|
143
|
-
getState<
|
|
144
|
-
|
|
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
|
+
});
|
|
145
274
|
}
|
|
146
275
|
|
|
147
276
|
/**
|
|
@@ -183,14 +312,41 @@ export const defineComponent = <TProps extends Record<string, unknown>>(
|
|
|
183
312
|
}
|
|
184
313
|
}
|
|
185
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
|
+
|
|
186
326
|
/**
|
|
187
327
|
* Renders the component to its shadow root.
|
|
188
328
|
* @internal
|
|
189
329
|
*/
|
|
190
|
-
private render(
|
|
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 {
|
|
191
344
|
try {
|
|
192
|
-
if (triggerUpdated && definition.beforeUpdate) {
|
|
193
|
-
|
|
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);
|
|
194
350
|
if (shouldUpdate === false) return;
|
|
195
351
|
}
|
|
196
352
|
|
|
@@ -203,6 +359,7 @@ export const defineComponent = <TProps extends Record<string, unknown>>(
|
|
|
203
359
|
const markup = definition.render({
|
|
204
360
|
props: this.props,
|
|
205
361
|
state: this.state,
|
|
362
|
+
signals: (definition.signals ?? {}) as TSignals,
|
|
206
363
|
emit,
|
|
207
364
|
});
|
|
208
365
|
|
|
@@ -211,42 +368,29 @@ export const defineComponent = <TProps extends Record<string, unknown>>(
|
|
|
211
368
|
// the stylistic `part` attribute, and standard form/input attributes without
|
|
212
369
|
// relaxing the global DOM sanitization rules.
|
|
213
370
|
const sanitizedMarkup = sanitizeHtml(markup, {
|
|
214
|
-
allowTags:
|
|
215
|
-
allowAttributes:
|
|
216
|
-
'part',
|
|
217
|
-
// Standard form attributes required by interactive shadow DOM content
|
|
218
|
-
'disabled',
|
|
219
|
-
'checked',
|
|
220
|
-
'placeholder',
|
|
221
|
-
'value',
|
|
222
|
-
'rows',
|
|
223
|
-
'cols',
|
|
224
|
-
'readonly',
|
|
225
|
-
'required',
|
|
226
|
-
'maxlength',
|
|
227
|
-
'minlength',
|
|
228
|
-
'max',
|
|
229
|
-
'min',
|
|
230
|
-
'step',
|
|
231
|
-
'pattern',
|
|
232
|
-
'autocomplete',
|
|
233
|
-
'autofocus',
|
|
234
|
-
'for',
|
|
235
|
-
'multiple',
|
|
236
|
-
'selected',
|
|
237
|
-
'wrap',
|
|
238
|
-
],
|
|
371
|
+
allowTags: componentAllowedTags,
|
|
372
|
+
allowAttributes: componentAllowedAttributes,
|
|
239
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
|
+
|
|
240
381
|
this.shadowRoot.innerHTML = sanitizedMarkup;
|
|
241
382
|
|
|
242
383
|
if (definition.styles) {
|
|
243
|
-
const styleElement = document.createElement('style');
|
|
384
|
+
const styleElement = existingStyleElement ?? document.createElement('style');
|
|
385
|
+
if (!existingStyleElement) {
|
|
386
|
+
styleElement.setAttribute('data-bquery-component-style', '');
|
|
387
|
+
}
|
|
244
388
|
styleElement.textContent = definition.styles;
|
|
245
389
|
this.shadowRoot.prepend(styleElement);
|
|
246
390
|
}
|
|
247
391
|
|
|
248
392
|
if (triggerUpdated) {
|
|
249
|
-
definition.updated?.call(this);
|
|
393
|
+
definition.updated?.call(this, change);
|
|
250
394
|
}
|
|
251
395
|
} catch (error) {
|
|
252
396
|
this.handleError(error as Error);
|
|
@@ -254,9 +398,48 @@ export const defineComponent = <TProps extends Record<string, unknown>>(
|
|
|
254
398
|
}
|
|
255
399
|
}
|
|
256
400
|
|
|
257
|
-
return BQueryComponent
|
|
401
|
+
return BQueryComponent as ComponentClass<TState>;
|
|
258
402
|
};
|
|
259
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
|
+
|
|
260
443
|
/**
|
|
261
444
|
* Defines and registers a custom Web Component.
|
|
262
445
|
*
|
|
@@ -265,12 +448,15 @@ export const defineComponent = <TProps extends Record<string, unknown>>(
|
|
|
265
448
|
* and automatically re-renders when observed attributes change.
|
|
266
449
|
*
|
|
267
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()`.
|
|
268
454
|
* @param tagName - The custom element tag name (must contain a hyphen)
|
|
269
455
|
* @param definition - The component configuration
|
|
270
456
|
*
|
|
271
457
|
* @example
|
|
272
458
|
* ```ts
|
|
273
|
-
* component('counter-button', {
|
|
459
|
+
* component<{ start: number }, { count: number }>('counter-button', {
|
|
274
460
|
* props: {
|
|
275
461
|
* start: { type: Number, default: 0 },
|
|
276
462
|
* },
|
|
@@ -283,7 +469,7 @@ export const defineComponent = <TProps extends Record<string, unknown>>(
|
|
|
283
469
|
* const handleClick = (event: Event) => {
|
|
284
470
|
* const target = event.target as HTMLElement | null;
|
|
285
471
|
* if (target?.matches('button')) {
|
|
286
|
-
* this.setState('count',
|
|
472
|
+
* this.setState('count', this.getState('count') + 1);
|
|
287
473
|
* }
|
|
288
474
|
* };
|
|
289
475
|
* this.shadowRoot?.addEventListener('click', handleClick);
|
|
@@ -307,13 +493,32 @@ export const defineComponent = <TProps extends Record<string, unknown>>(
|
|
|
307
493
|
* });
|
|
308
494
|
* ```
|
|
309
495
|
*/
|
|
310
|
-
export
|
|
496
|
+
export function component<
|
|
497
|
+
TProps extends Record<string, unknown>,
|
|
498
|
+
TSignals extends ComponentSignals = Record<string, never>,
|
|
499
|
+
>(
|
|
311
500
|
tagName: string,
|
|
312
|
-
definition: ComponentDefinition<TProps>
|
|
313
|
-
): void
|
|
314
|
-
|
|
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);
|
|
315
520
|
|
|
316
521
|
if (!customElements.get(tagName)) {
|
|
317
522
|
customElements.define(tagName, elementClass);
|
|
318
523
|
}
|
|
319
|
-
}
|
|
524
|
+
}
|
package/src/component/html.ts
CHANGED
|
@@ -1,53 +1,153 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
* @
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
'
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
1
|
+
import {
|
|
2
|
+
isTrustedHtml,
|
|
3
|
+
type SanitizedHtml,
|
|
4
|
+
toSanitizedHtml,
|
|
5
|
+
unwrapTrustedHtml,
|
|
6
|
+
} from '../security/trusted-html';
|
|
7
|
+
const BOOLEAN_ATTRIBUTE_MARKER: unique symbol = Symbol('bquery.booleanAttribute');
|
|
8
|
+
const BOOLEAN_ATTRIBUTE_NAME = /^[^\0-\x20"'/>=]+$/;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Public shape of a boolean HTML attribute created by {@link bool}.
|
|
12
|
+
*
|
|
13
|
+
* This type is returned from {@link bool} and can be interpolated into
|
|
14
|
+
* {@link html} / {@link safeHtml} templates to conditionally include or omit
|
|
15
|
+
* an attribute by name. The internal marker property used for runtime checks
|
|
16
|
+
* remains private and is not part of the public API.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```ts
|
|
20
|
+
* const disabled = bool('disabled', isDisabled);
|
|
21
|
+
* const button = html`<button ${disabled}>Click</button>`;
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export interface BooleanAttribute {
|
|
25
|
+
readonly enabled: boolean;
|
|
26
|
+
readonly name: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface BooleanAttributeValue extends BooleanAttribute {
|
|
30
|
+
readonly [BOOLEAN_ATTRIBUTE_MARKER]: true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const isBooleanAttributeValue = (value: unknown): value is BooleanAttributeValue => {
|
|
34
|
+
if (typeof value !== 'object' || value === null) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const candidate = value as Partial<BooleanAttributeValue>;
|
|
39
|
+
return (
|
|
40
|
+
candidate[BOOLEAN_ATTRIBUTE_MARKER] === true &&
|
|
41
|
+
typeof candidate.enabled === 'boolean' &&
|
|
42
|
+
typeof candidate.name === 'string'
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const stringifyTemplateValue = (value: unknown): string => {
|
|
47
|
+
if (isBooleanAttributeValue(value)) {
|
|
48
|
+
return value.enabled ? value.name : '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return String(value ?? '');
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const escapeMap: Record<string, string> = {
|
|
55
|
+
'&': '&',
|
|
56
|
+
'<': '<',
|
|
57
|
+
'>': '>',
|
|
58
|
+
'"': '"',
|
|
59
|
+
"'": ''',
|
|
60
|
+
'`': '`',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const escapeTemplateValue = (value: unknown): string => {
|
|
64
|
+
if (isBooleanAttributeValue(value)) {
|
|
65
|
+
return value.enabled ? value.name : '';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return stringifyTemplateValue(value).replace(/[&<>"'`]/g, (char) => escapeMap[char]);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Creates a boolean-attribute marker for the {@link html} and {@link safeHtml} template tags.
|
|
73
|
+
*
|
|
74
|
+
* When the condition is truthy, the attribute name is rendered without a value.
|
|
75
|
+
* When the condition is falsy, an empty string is rendered and any surrounding
|
|
76
|
+
* template-literal whitespace is preserved.
|
|
77
|
+
*
|
|
78
|
+
* @param name - HTML attribute name to emit
|
|
79
|
+
* @param enabled - Whether the boolean attribute should be present
|
|
80
|
+
* @returns Internal marker consumed by template tags
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```ts
|
|
84
|
+
* html`<button ${bool('disabled', isDisabled)}>Save</button>`;
|
|
85
|
+
* // Result when isDisabled = true: '<button disabled>Save</button>'
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
export const bool = (name: string, enabled: unknown): BooleanAttribute => {
|
|
89
|
+
if (!BOOLEAN_ATTRIBUTE_NAME.test(name)) {
|
|
90
|
+
throw new TypeError(`Invalid boolean attribute name: ${name}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const attribute: BooleanAttributeValue = {
|
|
94
|
+
[BOOLEAN_ATTRIBUTE_MARKER]: true,
|
|
95
|
+
enabled: Boolean(enabled),
|
|
96
|
+
name,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return Object.freeze(attribute);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Tagged template literal for creating HTML strings.
|
|
104
|
+
*
|
|
105
|
+
* This function handles interpolation of values into HTML templates,
|
|
106
|
+
* converting null/undefined to empty strings.
|
|
107
|
+
*
|
|
108
|
+
* @param strings - Template literal string parts
|
|
109
|
+
* @param values - Interpolated values
|
|
110
|
+
* @returns Combined HTML string
|
|
111
|
+
*
|
|
112
|
+
* @example
|
|
113
|
+
* ```ts
|
|
114
|
+
* const name = 'World';
|
|
115
|
+
* const greeting = html`<h1>Hello, ${name}!</h1>`;
|
|
116
|
+
* // Result: '<h1>Hello, World!</h1>'
|
|
117
|
+
* ```
|
|
118
|
+
*/
|
|
119
|
+
export const html = (strings: TemplateStringsArray, ...values: unknown[]): string => {
|
|
120
|
+
return strings.reduce((acc, part, index) => `${acc}${part}${stringifyTemplateValue(values[index])}`, '');
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Escapes HTML entities in interpolated values for XSS prevention.
|
|
125
|
+
* Use this when you need to safely embed user content in templates.
|
|
126
|
+
*
|
|
127
|
+
* @param strings - Template literal string parts
|
|
128
|
+
* @param values - Interpolated values to escape
|
|
129
|
+
* @returns Branded escaped HTML string safe for bQuery template composition
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* ```ts
|
|
133
|
+
* const userInput = '<script>alert("xss")</script>';
|
|
134
|
+
* const safe = safeHtml`<div>${userInput}</div>`;
|
|
135
|
+
* // Result: '<div><script>alert("xss")</script></div>'
|
|
136
|
+
* ```
|
|
137
|
+
*/
|
|
138
|
+
export const safeHtml = (
|
|
139
|
+
strings: TemplateStringsArray,
|
|
140
|
+
...values: unknown[]
|
|
141
|
+
): SanitizedHtml => {
|
|
142
|
+
const escape = (value: unknown): string => {
|
|
143
|
+
if (isTrustedHtml(value)) return unwrapTrustedHtml(value);
|
|
144
|
+
return escapeTemplateValue(value);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
return toSanitizedHtml(
|
|
148
|
+
strings.reduce(
|
|
149
|
+
(acc, part, index) => `${acc}${part}${index < values.length ? escape(values[index]) : ''}`,
|
|
150
|
+
''
|
|
151
|
+
)
|
|
152
|
+
);
|
|
153
|
+
};
|
package/src/component/index.ts
CHANGED
|
@@ -36,7 +36,15 @@
|
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
38
|
export { component, defineComponent } from './component';
|
|
39
|
-
export { html, safeHtml } from './html';
|
|
39
|
+
export { bool, html, safeHtml } from './html';
|
|
40
40
|
export { registerDefaultComponents } from './library';
|
|
41
41
|
export type { DefaultComponentLibraryOptions, RegisteredDefaultComponents } from './library';
|
|
42
|
-
export type {
|
|
42
|
+
export type {
|
|
43
|
+
AttributeChange,
|
|
44
|
+
ComponentDefinition,
|
|
45
|
+
ComponentRenderContext,
|
|
46
|
+
ComponentStateKey,
|
|
47
|
+
ComponentSignalLike,
|
|
48
|
+
ComponentSignals,
|
|
49
|
+
PropDefinition,
|
|
50
|
+
} from './types';
|