@context-action/lit-ui 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1571 @@
1
+ import { LitElement, css, html, nothing } from "lit";
2
+ import { property, query, state } from "lit/decorators.js";
3
+ import { ContextConsumer, ContextProvider, createContext } from "@lit/context";
4
+ import * as React from "react";
5
+ import { ActionController, ControllerScope, StoreController } from "@context-action/lit";
6
+ // \0@oxc-project+runtime@0.155.0/helpers/esm/decorate.js
7
+ function __decorate(decorators, target, key, desc) {
8
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
9
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
10
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
11
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
12
+ }
13
+ // src/form-associated-element.ts
14
+ /**
15
+ * @fileoverview Abstract Form-Associated Custom Element (FACE) base class extending LitElement.
16
+ * Provides ElementInternals integration, standard constraint validation, form value management,
17
+ * and robust form reset lifecycle hooks across browser and headless (JSDOM/SSR) environments.
18
+ *
19
+ * @implements view-layer
20
+ * @implements web-component-primitive
21
+ * @memberof core-concepts
22
+ */
23
+ /**
24
+ * Synthesizes an immutable ValidityState object from a set of ValidityStateFlags.
25
+ * Used as a high-fidelity fallback when ElementInternals.validity is unavailable in the environment.
26
+ */
27
+ function createSyntheticValidityState(flags) {
28
+ const badInput = !!flags.badInput;
29
+ const customError = !!flags.customError;
30
+ const patternMismatch = !!flags.patternMismatch;
31
+ const rangeOverflow = !!flags.rangeOverflow;
32
+ const rangeUnderflow = !!flags.rangeUnderflow;
33
+ const stepMismatch = !!flags.stepMismatch;
34
+ const tooLong = !!flags.tooLong;
35
+ const tooShort = !!flags.tooShort;
36
+ const typeMismatch = !!flags.typeMismatch;
37
+ const valueMissing = !!flags.valueMissing;
38
+ return {
39
+ badInput,
40
+ customError,
41
+ patternMismatch,
42
+ rangeOverflow,
43
+ rangeUnderflow,
44
+ stepMismatch,
45
+ tooLong,
46
+ tooShort,
47
+ typeMismatch,
48
+ valid: !(badInput || customError || patternMismatch || rangeOverflow || rangeUnderflow || stepMismatch || tooLong || tooShort || typeMismatch || valueMissing),
49
+ valueMissing
50
+ };
51
+ }
52
+ /**
53
+ * Abstract Form-Associated Custom Element (FACE) base class extending LitElement.
54
+ *
55
+ * Provides:
56
+ * - Native form participation via `ElementInternals` and `static formAssociated = true`
57
+ * - Defensive fallbacks for server-side rendering (SSR) and headless test environments (JSDOM)
58
+ * - Complete Form value management (`setFormValue`, `getFormValue`, `formValue`)
59
+ * - Constraint validation APIs (`setValidity`, `validity`, `validationMessage`, `checkValidity`, `reportValidity`, `setCustomValidity`)
60
+ * - Lifecycle callbacks (`formResetCallback`, `formDisabledCallback`, `formStateRestoreCallback`, `formAssociatedCallback`)
61
+ * - Standard overridable hooks (`onFormReset`, `onFormDisabled`, `onFormStateRestore`, `onFormAssociated`)
62
+ * - Automatic form reset event synchronization and leak-free unbinding on disconnect
63
+ */
64
+ var FormAssociatedLitElement = class extends LitElement {
65
+ static {
66
+ this.formAssociated = true;
67
+ }
68
+ #formValue;
69
+ #formState;
70
+ #validityFlags;
71
+ #validationMessage;
72
+ #customError;
73
+ #isResetting;
74
+ #associatedForm;
75
+ constructor() {
76
+ super();
77
+ this.name = "";
78
+ this.type = "custom-field";
79
+ this.disabled = false;
80
+ this.required = false;
81
+ this.#formValue = null;
82
+ this.#formState = null;
83
+ this.#validityFlags = {};
84
+ this.#validationMessage = "";
85
+ this.#customError = false;
86
+ this.#isResetting = false;
87
+ this.#associatedForm = null;
88
+ this.#handleFormReset = (event) => {
89
+ if (this.#isResetting || event.defaultPrevented) return;
90
+ queueMicrotask(() => {
91
+ if (this.#isResetting || event.defaultPrevented) return;
92
+ this.formResetCallback();
93
+ });
94
+ };
95
+ if (typeof this.attachInternals === "function") try {
96
+ this.internals = this.attachInternals();
97
+ } catch {}
98
+ }
99
+ /**
100
+ * The associated HTMLFormElement, if any.
101
+ * Resolves first via ElementInternals.form, then checks the 'form' attribute by ID,
102
+ * and finally falls back to the closest ancestor <form>.
103
+ */
104
+ get form() {
105
+ if (this.internals && "form" in this.internals && this.internals.form !== void 0) return this.internals.form;
106
+ const formAttr = this.getAttribute("form");
107
+ if (formAttr && this.ownerDocument) {
108
+ const el = this.ownerDocument.getElementById(formAttr);
109
+ if (el instanceof HTMLFormElement) return el;
110
+ }
111
+ return this.closest("form");
112
+ }
113
+ /**
114
+ * Sets the element's form submission value and optional state restoration value.
115
+ *
116
+ * @param value The value to submit with the form: string, File, FormData, or null (clears submission).
117
+ * @param state Optional state object used for session restoration / autocomplete.
118
+ */
119
+ setFormValue(value, state) {
120
+ this.#formValue = value;
121
+ this.#formState = state;
122
+ if (this.internals && typeof this.internals.setFormValue === "function") try {
123
+ this.internals.setFormValue(value, state);
124
+ } catch {}
125
+ }
126
+ /**
127
+ * Returns the current form submission value.
128
+ */
129
+ getFormValue() {
130
+ return this.#formValue;
131
+ }
132
+ /**
133
+ * Getter alias for getFormValue() for uniform property access.
134
+ */
135
+ get formValue() {
136
+ return this.#formValue;
137
+ }
138
+ /**
139
+ * Returns the current state restoration object.
140
+ */
141
+ get formState() {
142
+ return this.#formState;
143
+ }
144
+ /**
145
+ * Sets the validity flags, validation error message, and optional anchor element.
146
+ *
147
+ * @param flags A map of ValidityStateFlags indicating constraint violations.
148
+ * @param message A human-readable validation error message (required if any flag is true).
149
+ * @param anchor An optional HTMLElement inside the element's ShadowRoot used to anchor browser tooltips.
150
+ */
151
+ setValidity(flags = {}, message, anchor) {
152
+ this.#validityFlags = { ...flags };
153
+ this.#validationMessage = message ?? "";
154
+ this.#customError = !!flags.customError;
155
+ if (this.internals && typeof this.internals.setValidity === "function") try {
156
+ this.internals.setValidity(flags, message, anchor);
157
+ } catch {}
158
+ }
159
+ /**
160
+ * Returns the current ValidityState object representing constraint validation status.
161
+ */
162
+ get validity() {
163
+ if (this.internals && "validity" in this.internals && this.internals.validity) return this.internals.validity;
164
+ return createSyntheticValidityState({
165
+ ...this.#validityFlags,
166
+ customError: this.#validityFlags.customError || this.#customError
167
+ });
168
+ }
169
+ /**
170
+ * Returns the current validation error message, or an empty string if valid.
171
+ */
172
+ get validationMessage() {
173
+ if (this.internals && "validationMessage" in this.internals && typeof this.internals.validationMessage === "string") return this.internals.validationMessage;
174
+ return this.validity.valid ? "" : this.#validationMessage;
175
+ }
176
+ /**
177
+ * Returns true if the element participates in constraint validation and is not disabled.
178
+ */
179
+ get willValidate() {
180
+ if (this.internals && "willValidate" in this.internals && typeof this.internals.willValidate === "boolean") return this.internals.willValidate;
181
+ return !this.disabled;
182
+ }
183
+ /**
184
+ * Checks whether the element satisfies all validation constraints.
185
+ * If invalid, fires a cancelable 'invalid' event on the element and returns false.
186
+ * If valid (or validation is disabled), returns true.
187
+ */
188
+ checkValidity() {
189
+ if (this.internals && typeof this.internals.checkValidity === "function") try {
190
+ return this.internals.checkValidity();
191
+ } catch {}
192
+ if (!this.willValidate) return true;
193
+ if (!this.validity.valid) {
194
+ const event = new Event("invalid", {
195
+ bubbles: false,
196
+ cancelable: true
197
+ });
198
+ this.dispatchEvent(event);
199
+ return false;
200
+ }
201
+ return true;
202
+ }
203
+ /**
204
+ * Checks validity, reports errors to the user (e.g. browser popup), and returns validity status.
205
+ */
206
+ reportValidity() {
207
+ if (this.internals && typeof this.internals.reportValidity === "function") try {
208
+ return this.internals.reportValidity();
209
+ } catch {}
210
+ return this.checkValidity();
211
+ }
212
+ /**
213
+ * Sets a custom validation error message. If message is non-empty, sets customError to true.
214
+ * If message is empty, clears custom validity errors.
215
+ */
216
+ setCustomValidity(message) {
217
+ if (message) this.setValidity({ customError: true }, message);
218
+ else this.setValidity({});
219
+ }
220
+ /**
221
+ * Native FACE callback invoked by the browser engine when the associated form is reset.
222
+ * Dispatches to the overridable onFormReset() hook.
223
+ */
224
+ formResetCallback() {
225
+ if (this.#isResetting) return;
226
+ this.#isResetting = true;
227
+ try {
228
+ this.onFormReset();
229
+ } finally {
230
+ queueMicrotask(() => {
231
+ this.#isResetting = false;
232
+ });
233
+ }
234
+ }
235
+ /**
236
+ * Overridable lifecycle hook called when the associated form is reset.
237
+ * Subclasses should restore default values, re-evaluate validation, and request updates.
238
+ */
239
+ onFormReset() {}
240
+ /**
241
+ * Native FACE callback invoked when the element or parent fieldset is disabled/enabled.
242
+ */
243
+ formDisabledCallback(disabled) {
244
+ this.disabled = disabled;
245
+ this.onFormDisabled(disabled);
246
+ this.requestUpdate();
247
+ }
248
+ /**
249
+ * Overridable lifecycle hook called when the disabled state changes.
250
+ */
251
+ onFormDisabled(_disabled) {}
252
+ /**
253
+ * Native FACE callback invoked when the browser restores form state (history navigation / autofill).
254
+ */
255
+ formStateRestoreCallback(state, mode) {
256
+ this.#formState = state;
257
+ this.onFormStateRestore(state, mode);
258
+ }
259
+ /**
260
+ * Overridable lifecycle hook called when the browser restores form state.
261
+ */
262
+ onFormStateRestore(_state, _mode) {}
263
+ /**
264
+ * Native FACE callback invoked when the element is associated or disassociated with a form.
265
+ */
266
+ formAssociatedCallback(form) {
267
+ this.#syncFormListener(form);
268
+ this.onFormAssociated(form);
269
+ }
270
+ /**
271
+ * Overridable lifecycle hook called when the element's form association changes.
272
+ */
273
+ onFormAssociated(_form) {}
274
+ #handleFormReset;
275
+ #syncFormListener(newForm) {
276
+ if (this.#associatedForm === newForm) return;
277
+ if (this.#associatedForm) this.#associatedForm.removeEventListener("reset", this.#handleFormReset);
278
+ this.#associatedForm = newForm;
279
+ if (this.#associatedForm) this.#associatedForm.addEventListener("reset", this.#handleFormReset);
280
+ }
281
+ connectedCallback() {
282
+ super.connectedCallback();
283
+ this.#syncFormListener(this.form);
284
+ }
285
+ disconnectedCallback() {
286
+ this.#syncFormListener(null);
287
+ super.disconnectedCallback();
288
+ }
289
+ };
290
+ __decorate([property({
291
+ type: String,
292
+ reflect: true
293
+ })], FormAssociatedLitElement.prototype, "name", void 0);
294
+ __decorate([property({
295
+ type: String,
296
+ reflect: true
297
+ })], FormAssociatedLitElement.prototype, "type", void 0);
298
+ __decorate([property({
299
+ type: Boolean,
300
+ reflect: true
301
+ })], FormAssociatedLitElement.prototype, "disabled", void 0);
302
+ __decorate([property({
303
+ type: Boolean,
304
+ reflect: true
305
+ })], FormAssociatedLitElement.prototype, "required", void 0);
306
+ // src/context.ts
307
+ /**
308
+ * @fileoverview W3C Context Protocol (@lit/context) tokens and dependency injection utilities
309
+ * for Context-Action stores, action registers, and dispatchers across Shadow DOM boundaries.
310
+ *
311
+ * @implements context-protocol
312
+ * @implements dependency-injection
313
+ * @implements store-integration-pattern
314
+ * @implements action-pipeline-system
315
+ * @memberof core-concepts
316
+ */
317
+ /**
318
+ * Standard W3C Context token for an ActionRegister.
319
+ * Uses Symbol.for to ensure singleton identity across independent bundle chunks and micro-frontends.
320
+ *
321
+ * @implements action-pipeline-system
322
+ * @memberof api-terms
323
+ */
324
+ const actionRegisterContext = createContext(Symbol.for("context-action.register"));
325
+ /**
326
+ * Standard W3C Context token for an ActionDispatcher function.
327
+ * Uses Symbol.for to ensure singleton identity across independent bundle chunks.
328
+ *
329
+ * @implements action-pipeline-system
330
+ * @memberof api-terms
331
+ */
332
+ const actionDispatcherContext = createContext(Symbol.for("context-action.dispatcher"));
333
+ /**
334
+ * Factory function creating a strongly-typed W3C Context token for a named Context-Action store.
335
+ *
336
+ * Distinct store names yield isolated contexts. Identical store names across different
337
+ * modules or micro-frontend bundles share the same underlying global Symbol.
338
+ *
339
+ * @template T State type managed by the store.
340
+ * @param name Unique name of the store domain (e.g. 'cart', 'auth', 'theme').
341
+ * @returns A Context token for the named ReadableStore.
342
+ *
343
+ * @example
344
+ * ```typescript
345
+ * export const cartStoreContext = createStoreContext<CartState>('cart');
346
+ * ```
347
+ *
348
+ * @implements store-integration-pattern
349
+ * @memberof api-terms
350
+ */
351
+ function createStoreContext(name) {
352
+ if (!name || typeof name !== "string") throw new TypeError("createStoreContext: \"name\" must be a non-empty string.");
353
+ return createContext(Symbol.for(`context-action.store.${name}`));
354
+ }
355
+ /**
356
+ * Factory function creating a strongly-typed ActionRegister context token for a specific action map.
357
+ *
358
+ * @template A Action payload map.
359
+ * @param name Optional domain qualifier. Defaults to the root action register.
360
+ * @returns A typed Context token for ActionRegister<A>.
361
+ */
362
+ function createActionRegisterContext(name = "default") {
363
+ return createContext(Symbol.for(`context-action.register.${name}`));
364
+ }
365
+ /**
366
+ * Factory function creating a strongly-typed ActionDispatcher context token for a specific action map.
367
+ *
368
+ * @template A Action payload map.
369
+ * @param name Optional domain qualifier. Defaults to the root action dispatcher.
370
+ * @returns A typed Context token for ActionDispatcher<A>.
371
+ */
372
+ function createActionDispatcherContext(name = "default") {
373
+ return createContext(Symbol.for(`context-action.dispatcher.${name}`));
374
+ }
375
+ /**
376
+ * Imperatively attaches an ActionRegister ContextProvider to a host element.
377
+ *
378
+ * @template A Action payload map.
379
+ * @param host The hosting LitElement or ReactiveElement.
380
+ * @param register The ActionRegister instance to provide.
381
+ * @param context Optional custom context token (defaults to actionRegisterContext).
382
+ * @returns The initialized ContextProvider instance.
383
+ */
384
+ function provideActionRegister(host, register, context = actionRegisterContext) {
385
+ return new ContextProvider(host, {
386
+ context,
387
+ initialValue: register
388
+ });
389
+ }
390
+ /**
391
+ * Imperatively attaches an ActionDispatcher ContextProvider to a host element.
392
+ *
393
+ * @template A Action payload map.
394
+ * @param host The hosting LitElement or ReactiveElement.
395
+ * @param dispatcher The ActionDispatcher function to provide.
396
+ * @param context Optional custom context token (defaults to actionDispatcherContext).
397
+ * @returns The initialized ContextProvider instance.
398
+ */
399
+ function provideActionDispatcher(host, dispatcher, context = actionDispatcherContext) {
400
+ return new ContextProvider(host, {
401
+ context,
402
+ initialValue: dispatcher
403
+ });
404
+ }
405
+ /**
406
+ * Imperatively attaches a ReadableStore ContextProvider to a host element.
407
+ *
408
+ * @template T Store state type.
409
+ * @param host The hosting LitElement or ReactiveElement.
410
+ * @param context The store Context token created via `createStoreContext`.
411
+ * @param store The ReadableStore instance to provide.
412
+ * @returns The initialized ContextProvider instance.
413
+ */
414
+ function provideStore(host, context, store) {
415
+ return new ContextProvider(host, {
416
+ context,
417
+ initialValue: store
418
+ });
419
+ }
420
+ /**
421
+ * ReactiveController that consumes a Context-Action ReadableStore from the W3C Context
422
+ * hierarchy across Shadow DOM boundaries, subscribes to state updates, and triggers host re-renders.
423
+ *
424
+ * Key guarantees:
425
+ * 1. Attaches to host via `host.addController(this)`.
426
+ * 2. Uses `@lit/context` ContextConsumer to dynamically resolve store instances across Shadow DOM boundaries.
427
+ * 3. Supports selective projections with memoization (`equalityFn`).
428
+ * 4. Ensures ZERO memory leaks: completely terminates store subscription when `hostDisconnected()` runs.
429
+ * 5. Re-evaluates store state and resubscribes on `hostConnected()`.
430
+ *
431
+ * @template T Source store state type.
432
+ * @template Selected Derived slice type.
433
+ *
434
+ * @implements store-integration-pattern
435
+ * @implements reactive-state-management
436
+ * @memberof core-concepts
437
+ */
438
+ var ContextStoreController = class {
439
+ #host;
440
+ #selector;
441
+ #equalityFn;
442
+ #currentStore = void 0;
443
+ #unsubscribeStore = null;
444
+ #selectedValue = void 0;
445
+ #isResolved = false;
446
+ constructor(host, options) {
447
+ if (!host || typeof host.addController !== "function") throw new TypeError("ContextStoreController requires a valid host implementing ReactiveControllerHost and HTMLElement.");
448
+ if (!options?.context) throw new TypeError("ContextStoreController requires a valid Context token.");
449
+ this.#host = host;
450
+ this.#selector = options.selector ?? ((s) => s);
451
+ this.#equalityFn = options.equalityFn ?? Object.is;
452
+ new ContextConsumer(host, {
453
+ context: options.context,
454
+ callback: (store) => this.#handleStoreResolved(store),
455
+ subscribe: true
456
+ });
457
+ host.addController(this);
458
+ }
459
+ /** Current projected state derived from the injected store, or undefined if not yet provided. */
460
+ get value() {
461
+ return this.#selectedValue;
462
+ }
463
+ /** The resolved ReadableStore instance currently bound to this controller. */
464
+ get store() {
465
+ return this.#currentStore;
466
+ }
467
+ /** Indicates whether the context request has been satisfied by an ancestor provider. */
468
+ get isResolved() {
469
+ return this.#isResolved;
470
+ }
471
+ #extractRawValue(store) {
472
+ if (typeof store.getValue === "function") return store.getValue();
473
+ if (typeof store.getSnapshot === "function") return store.getSnapshot();
474
+ throw new TypeError("ContextStoreController: Provided store must implement getValue() or getSnapshot().");
475
+ }
476
+ #handleStoreResolved(nextStore) {
477
+ if (this.#currentStore === nextStore && this.#isResolved) return;
478
+ this.#cleanupStoreSubscription();
479
+ this.#currentStore = nextStore;
480
+ if (!nextStore) {
481
+ this.#isResolved = false;
482
+ this.#selectedValue = void 0;
483
+ this.#host.requestUpdate();
484
+ return;
485
+ }
486
+ this.#isResolved = true;
487
+ this.#syncStateAndSubscribe();
488
+ this.#host.requestUpdate();
489
+ }
490
+ #syncStateAndSubscribe() {
491
+ if (!this.#currentStore) return;
492
+ const raw = this.#extractRawValue(this.#currentStore);
493
+ this.#selectedValue = this.#selector(raw);
494
+ this.#unsubscribeStore = this.#currentStore.subscribe(() => {
495
+ if (!this.#currentStore) return;
496
+ const nextRaw = this.#extractRawValue(this.#currentStore);
497
+ const nextSelected = this.#selector(nextRaw);
498
+ if (!this.#equalityFn(this.#selectedValue, nextSelected)) {
499
+ this.#selectedValue = nextSelected;
500
+ this.#host.requestUpdate();
501
+ }
502
+ });
503
+ }
504
+ #cleanupStoreSubscription() {
505
+ if (this.#unsubscribeStore) {
506
+ this.#unsubscribeStore();
507
+ this.#unsubscribeStore = null;
508
+ }
509
+ }
510
+ hostConnected() {
511
+ if (this.#currentStore && !this.#unsubscribeStore) {
512
+ this.#syncStateAndSubscribe();
513
+ this.#host.requestUpdate();
514
+ }
515
+ }
516
+ hostDisconnected() {
517
+ this.#cleanupStoreSubscription();
518
+ }
519
+ };
520
+ /**
521
+ * ReactiveController that consumes an ActionRegister from the W3C Context hierarchy
522
+ * across Shadow DOM boundaries and exposes typed dispatch methods.
523
+ *
524
+ * @template A Action payload map.
525
+ *
526
+ * @implements action-pipeline-system
527
+ * @implements action-controller
528
+ * @memberof core-concepts
529
+ */
530
+ var ContextActionController = class {
531
+ #host;
532
+ #register = void 0;
533
+ #isPending = false;
534
+ #lastError = void 0;
535
+ constructor(host, options) {
536
+ if (!host || typeof host.addController !== "function") throw new TypeError("ContextActionController requires a valid host implementing ReactiveControllerHost and HTMLElement.");
537
+ this.#host = host;
538
+ const ctx = options?.context ?? actionRegisterContext;
539
+ new ContextConsumer(host, {
540
+ context: ctx,
541
+ callback: (reg) => {
542
+ this.#register = reg;
543
+ this.#host.requestUpdate();
544
+ },
545
+ subscribe: true
546
+ });
547
+ host.addController(this);
548
+ }
549
+ /** The resolved ActionRegister instance, or undefined if no provider is present. */
550
+ get register() {
551
+ return this.#register;
552
+ }
553
+ /** Whether an action dispatch is currently in progress. */
554
+ get isPending() {
555
+ return this.#isPending;
556
+ }
557
+ /** The last error thrown during an action dispatch, if any. */
558
+ get lastError() {
559
+ return this.#lastError;
560
+ }
561
+ /**
562
+ * Dispatches an action through the context-injected ActionRegister pipeline.
563
+ *
564
+ * @template K Action name.
565
+ * @param action Name of the registered action.
566
+ * @param args Action payload arguments.
567
+ */
568
+ async dispatch(action, ...args) {
569
+ if (!this.#register) throw new Error(`ContextActionController: Cannot dispatch "${String(action)}". No ActionRegister found in context hierarchy.`);
570
+ this.#isPending = true;
571
+ this.#lastError = void 0;
572
+ this.#host.requestUpdate();
573
+ try {
574
+ await this.#register.dispatch(action, ...args);
575
+ } catch (err) {
576
+ const errorObj = err instanceof Error ? err : new Error(String(err));
577
+ this.#lastError = errorObj;
578
+ throw errorObj;
579
+ } finally {
580
+ this.#isPending = false;
581
+ this.#host.requestUpdate();
582
+ }
583
+ }
584
+ /**
585
+ * Dispatches an action and returns the pipeline execution result.
586
+ *
587
+ * @template K Action name.
588
+ * @template R Expected return value.
589
+ * @param action Name of the registered action.
590
+ * @param args Action payload arguments.
591
+ * @returns Pipeline result promise.
592
+ */
593
+ async dispatchWithResult(action, ...args) {
594
+ if (!this.#register) throw new Error(`ContextActionController: Cannot dispatch "${String(action)}". No ActionRegister found in context hierarchy.`);
595
+ this.#isPending = true;
596
+ this.#lastError = void 0;
597
+ this.#host.requestUpdate();
598
+ try {
599
+ if (typeof this.#register.dispatchWithResult === "function") return await this.#register.dispatchWithResult(action, ...args);
600
+ return await this.#register.dispatch(action, ...args);
601
+ } catch (err) {
602
+ const errorObj = err instanceof Error ? err : new Error(String(err));
603
+ this.#lastError = errorObj;
604
+ throw errorObj;
605
+ } finally {
606
+ this.#isPending = false;
607
+ this.#host.requestUpdate();
608
+ }
609
+ }
610
+ hostConnected() {}
611
+ hostDisconnected() {}
612
+ };
613
+ // src/facade.ts
614
+ function findPropertyDescriptor(target, key) {
615
+ let current = target;
616
+ while (current) {
617
+ const descriptor = Object.getOwnPropertyDescriptor(current, key);
618
+ if (descriptor) return descriptor;
619
+ current = Object.getPrototypeOf(current);
620
+ }
621
+ }
622
+ /**
623
+ * Creates a reusable host-agnostic handle factory that exposes only the specified
624
+ * properties and methods from a Custom Element, hiding raw `HTMLElement` APIs.
625
+ * Can be used directly with Vanilla DOM, Lit, or
626
+ * `createLitElementBridge({ createHandle })`.
627
+ *
628
+ * Methods are bound to the current element and cached per element, so repeated
629
+ * reads return the same function identity until the element or the underlying
630
+ * method changes. The cache is keyed weakly and never retains a detached element.
631
+ *
632
+ * @example
633
+ * ```ts
634
+ * const TimelineElement = createLitElementBridge<TimelineProps, TimelineElement, TimelineHandle>({
635
+ * tagName: 'st-timeline',
636
+ * properties: ['input', 'set'],
637
+ * createHandle: createElementFacade(['focusItem', 'scrollTo', 'commitSelection']),
638
+ * });
639
+ * ```
640
+ */
641
+ function createElementFacade(keys) {
642
+ const uniqueKeys = Array.from(new Set(keys));
643
+ return (getElement) => {
644
+ const requireElement = () => {
645
+ const element = getElement();
646
+ if (!element) throw new Error("Custom element handle is not mounted");
647
+ return element;
648
+ };
649
+ const boundMethods = /* @__PURE__ */ new WeakMap();
650
+ const facade = {};
651
+ for (const key of uniqueKeys) Object.defineProperty(facade, key, {
652
+ configurable: false,
653
+ enumerable: true,
654
+ get() {
655
+ const element = requireElement();
656
+ const value = Reflect.get(element, key, element);
657
+ if (typeof value !== "function") return value;
658
+ let cache = boundMethods.get(element);
659
+ if (!cache) {
660
+ cache = /* @__PURE__ */ new Map();
661
+ boundMethods.set(element, cache);
662
+ }
663
+ const cached = cache.get(key);
664
+ if (cached && cached.source === value) return cached.bound;
665
+ const bound = value.bind(element);
666
+ cache.set(key, {
667
+ source: value,
668
+ bound
669
+ });
670
+ return bound;
671
+ },
672
+ set(nextValue) {
673
+ const element = requireElement();
674
+ const descriptor = findPropertyDescriptor(element, key);
675
+ if (descriptor && !descriptor.set && !descriptor.writable) throw new TypeError(`Cannot assign to read-only property '${String(key)}'`);
676
+ if (!Reflect.set(element, key, nextValue, element)) throw new TypeError(`Cannot assign to read-only property '${String(key)}'`);
677
+ }
678
+ });
679
+ return Object.freeze(facade);
680
+ };
681
+ }
682
+ // src/react-bridge.ts
683
+ /**
684
+ * @fileoverview React 18/19 bridge helper for consuming Lit Custom Elements.
685
+ * Provides direct property assignment to bypass attribute stringification,
686
+ * stable custom event listener mapping with deterministic cleanup, and
687
+ * OOP Facade handle forwarding via `createHandle`.
688
+ *
689
+ * @implements view-layer
690
+ * @implements web-component-primitive
691
+ * @memberof core-concepts
692
+ */
693
+ const useIsomorphicLayoutEffect = typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;
694
+ function isEventHandler(value) {
695
+ return typeof value === "function" || typeof value === "object" && value !== null && typeof value.handleEvent === "function";
696
+ }
697
+ /**
698
+ * Creates a type-safe React bridge component for consuming any Lit Custom Element
699
+ * with seamless property synchronization, custom event listeners, and ref forwarding.
700
+ *
701
+ * Solves standard React ↔ Web Component pain points:
702
+ * 1. Direct Property Assignment: Directly assigns objects, arrays, and stores to the DOM
703
+ * instance without stringifying to `"[object Object]"` in React 18/19.
704
+ * 2. Property Removal: Clears keys removed from `properties` on later renders.
705
+ * 3. Stable Custom Events: Attaches one listener per event and reads the latest
706
+ * handler, so ordinary re-renders never re-register listeners.
707
+ * 4. Ref Forwarding: Forwards an OOP Facade handle (`createHandle`) or the raw
708
+ * Custom Element instance when no factory is provided.
709
+ */
710
+ function createLitElementBridge(tagOrOptions, maybeConfig) {
711
+ const { tagName, properties = [], events = {}, createHandle } = typeof tagOrOptions === "string" ? {
712
+ tagName: tagOrOptions,
713
+ ...maybeConfig
714
+ } : tagOrOptions;
715
+ const propertyKeys = Array.from(properties);
716
+ const propertyKeySet = new Set(propertyKeys);
717
+ const eventEntries = Object.entries(events);
718
+ const Bridge = React.forwardRef(function LitCustomElementBridge(props, forwardedRef) {
719
+ const elementRef = React.useRef(null);
720
+ const [element, setElement] = React.useState(null);
721
+ const appliedPropertiesRef = React.useRef(/* @__PURE__ */ new Set());
722
+ const appliedElementRef = React.useRef(null);
723
+ const eventBindingsRef = React.useRef([]);
724
+ const handleRef = React.useCallback((node) => {
725
+ elementRef.current = node;
726
+ setElement(node);
727
+ }, []);
728
+ React.useImperativeHandle(forwardedRef, () => {
729
+ if (createHandle) return createHandle(() => elementRef.current);
730
+ return elementRef.current;
731
+ }, [createHandle]);
732
+ useIsomorphicLayoutEffect(() => {
733
+ if (!element) return;
734
+ if (appliedElementRef.current !== element) {
735
+ appliedElementRef.current = element;
736
+ appliedPropertiesRef.current = /* @__PURE__ */ new Set();
737
+ }
738
+ const host = element;
739
+ const propsRecord = props;
740
+ const appliedProperties = appliedPropertiesRef.current;
741
+ for (const propKey of propertyKeys) if (propKey in propsRecord) {
742
+ appliedProperties.add(propKey);
743
+ try {
744
+ host[propKey] = propsRecord[propKey];
745
+ } catch {}
746
+ } else if (appliedProperties.has(propKey)) try {
747
+ host[propKey] = void 0;
748
+ } catch {} finally {
749
+ appliedProperties.delete(propKey);
750
+ }
751
+ }, [
752
+ element,
753
+ props,
754
+ propertyKeys
755
+ ]);
756
+ React.useEffect(() => {
757
+ if (!element) return;
758
+ const previousBindings = eventBindingsRef.current;
759
+ const nextBindings = [];
760
+ const propsRecord = props;
761
+ for (let index = 0; index < eventEntries.length; index++) {
762
+ const [propHandlerName, customEventName] = eventEntries[index];
763
+ const handler = propsRecord[propHandlerName];
764
+ const previous = previousBindings[index];
765
+ if (previous && previous.element === element && previous.eventName === customEventName && previous.handler === handler) {
766
+ nextBindings.push(previous);
767
+ continue;
768
+ }
769
+ if (previous?.listener) previous.element.removeEventListener(previous.eventName, previous.listener);
770
+ if (isEventHandler(handler)) {
771
+ const listener = (event) => {
772
+ if (typeof handler === "function") handler(event);
773
+ else handler.handleEvent(event);
774
+ };
775
+ element.addEventListener(customEventName, listener);
776
+ nextBindings.push({
777
+ element,
778
+ eventName: customEventName,
779
+ handler,
780
+ listener
781
+ });
782
+ } else nextBindings.push({
783
+ element,
784
+ eventName: customEventName,
785
+ handler
786
+ });
787
+ }
788
+ for (let index = eventEntries.length; index < previousBindings.length; index++) {
789
+ const stale = previousBindings[index];
790
+ if (stale.listener) stale.element.removeEventListener(stale.eventName, stale.listener);
791
+ }
792
+ eventBindingsRef.current = nextBindings;
793
+ });
794
+ React.useEffect(() => {
795
+ return () => {
796
+ for (const binding of eventBindingsRef.current) if (binding.listener) binding.element.removeEventListener(binding.eventName, binding.listener);
797
+ eventBindingsRef.current = [];
798
+ };
799
+ }, []);
800
+ const domProps = { ref: handleRef };
801
+ for (const [key, value] of Object.entries(props)) if (key === "children") domProps[key] = value;
802
+ else if (key === "className") domProps.class = value;
803
+ else if (key === "htmlFor") domProps.for = value;
804
+ else if (!propertyKeySet.has(key) && !(key in events)) domProps[key] = value;
805
+ return React.createElement(tagName, domProps);
806
+ });
807
+ Bridge.displayName = `LitBridge(${tagName})`;
808
+ return Bridge;
809
+ }
810
+ // src/define-once.ts
811
+ /**
812
+ * @fileoverview Idempotent custom element registration for the reference
813
+ * components. ESM and CJS builds can both be loaded in one process, so an
814
+ * unconditional `customElements.define()` would throw on the second load.
815
+ *
816
+ * @implements web-component-primitive
817
+ * @memberof core-concepts
818
+ */
819
+ function defineCustomElement(tagName, elementConstructor) {
820
+ if (typeof customElements === "undefined") return;
821
+ if (customElements.get(tagName)) return;
822
+ customElements.define(tagName, elementConstructor);
823
+ }
824
+ // src/components/quantity-stepper.ts
825
+ /**
826
+ * @fileoverview Reference implementation of <lit-quantity-stepper> custom element.
827
+ * Extends FormAssociatedLitElement for full native <form> and FormData participation,
828
+ * constraint validation, form reset lifecycle, and Shadow DOM encapsulation.
829
+ *
830
+ * @implements view-layer
831
+ * @implements web-component-primitive
832
+ * @memberof core-concepts
833
+ */
834
+ /**
835
+ * Reference Form-Associated Custom Element providing a numerical stepper control.
836
+ *
837
+ * Features:
838
+ * - Native <form> submission and `new FormData()` integration via `ElementInternals`.
839
+ * - HTML5 constraint validation (`rangeUnderflow`, `rangeOverflow`, `stepMismatch`, `badInput`).
840
+ * - Form reset lifecycle restoration via `onFormReset()`.
841
+ * - Disabled and readonly state propagation.
842
+ * - Increment / Decrement methods with step precision and boundary clamping.
843
+ * - Standard CustomEvent dispatching (`change`, `input`, `quantity-change`) with `{ bubbles: true, composed: true }`.
844
+ * - Fully encapsulated Shadow DOM styling with `::part(button)` and `::part(input)` styling hooks.
845
+ */
846
+ var LitQuantityStepper = class extends FormAssociatedLitElement {
847
+ constructor(..._args) {
848
+ super(..._args);
849
+ this.value = 1;
850
+ this.min = 1;
851
+ this.max = 99;
852
+ this.step = 1;
853
+ this.readonly = false;
854
+ this.focused = false;
855
+ this.#initialValue = 1;
856
+ this.#handleInput = (e) => {
857
+ e.stopPropagation();
858
+ const target = e.target;
859
+ const parsed = parseFloat(target.value);
860
+ this.value = Number.isNaN(parsed) ? target.value : parsed;
861
+ this.#syncFormState();
862
+ this.#emitEvents("input");
863
+ };
864
+ this.#handleChange = (e) => {
865
+ e.stopPropagation();
866
+ const target = e.target;
867
+ const parsed = parseFloat(target.value);
868
+ this.value = Number.isNaN(parsed) ? target.value : parsed;
869
+ this.#syncFormState();
870
+ this.#emitEvents("change");
871
+ };
872
+ }
873
+ static {
874
+ this.styles = css`
875
+ :host {
876
+ display: inline-flex;
877
+ vertical-align: middle;
878
+ font-family: var(
879
+ --stepper-font-family,
880
+ system-ui,
881
+ -apple-system,
882
+ BlinkMacSystemFont,
883
+ 'Segoe UI',
884
+ Roboto,
885
+ sans-serif
886
+ );
887
+ --_border-color: var(--stepper-border-color, #cbd5e1);
888
+ --_focus-border-color: var(--stepper-focus-border-color, #3b82f6);
889
+ --_invalid-border-color: var(--stepper-invalid-border-color, #ef4444);
890
+ --_bg: var(--stepper-bg, #ffffff);
891
+ --_btn-bg: var(--stepper-button-bg, #f8fafc);
892
+ --_btn-hover-bg: var(--stepper-button-hover-bg, #e2e8f0);
893
+ --_btn-active-bg: var(--stepper-button-active-bg, #cbd5e1);
894
+ --_btn-color: var(--stepper-button-color, #334155);
895
+ --_radius: var(--stepper-radius, 8px);
896
+ --_font-size: var(--stepper-font-size, 14px);
897
+ --_height: var(--stepper-height, 36px);
898
+ }
899
+
900
+ :host([disabled]) {
901
+ opacity: 0.5;
902
+ pointer-events: none;
903
+ cursor: not-allowed;
904
+ }
905
+
906
+ :host([hidden]) {
907
+ display: none !important;
908
+ }
909
+
910
+ .stepper {
911
+ display: inline-flex;
912
+ align-items: stretch;
913
+ height: var(--_height);
914
+ background: var(--_bg);
915
+ border: 1px solid var(--_border-color);
916
+ border-radius: var(--_radius);
917
+ overflow: hidden;
918
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
919
+ box-sizing: border-box;
920
+ }
921
+
922
+ .stepper.focused {
923
+ border-color: var(--_focus-border-color);
924
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
925
+ }
926
+
927
+ :host(:invalid) .stepper,
928
+ .stepper.invalid {
929
+ border-color: var(--_invalid-border-color);
930
+ }
931
+
932
+ :host(:invalid) .stepper.focused,
933
+ .stepper.invalid.focused {
934
+ box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2);
935
+ }
936
+
937
+ button {
938
+ display: inline-flex;
939
+ align-items: center;
940
+ justify-content: center;
941
+ width: var(--_height);
942
+ padding: 0;
943
+ margin: 0;
944
+ border: none;
945
+ background: var(--_btn-bg);
946
+ color: var(--_btn-color);
947
+ font-size: calc(var(--_font-size) + 2px);
948
+ font-weight: 600;
949
+ cursor: pointer;
950
+ user-select: none;
951
+ transition: background-color 0.15s ease, color 0.15s ease;
952
+ box-sizing: border-box;
953
+ }
954
+
955
+ button:hover:not(:disabled) {
956
+ background: var(--_btn-hover-bg);
957
+ }
958
+
959
+ button:active:not(:disabled) {
960
+ background: var(--_btn-active-bg);
961
+ }
962
+
963
+ button:disabled {
964
+ cursor: not-allowed;
965
+ opacity: 0.4;
966
+ }
967
+
968
+ .btn-decrement {
969
+ border-right: 1px solid var(--_border-color);
970
+ }
971
+
972
+ .btn-increment {
973
+ border-left: 1px solid var(--_border-color);
974
+ }
975
+
976
+ input {
977
+ width: 48px;
978
+ padding: 0 6px;
979
+ margin: 0;
980
+ border: none;
981
+ background: transparent;
982
+ text-align: center;
983
+ font-size: var(--_font-size);
984
+ font-weight: 600;
985
+ color: var(--stepper-text-color, #0f172a);
986
+ font-family: inherit;
987
+ box-sizing: border-box;
988
+ -moz-appearance: textfield;
989
+ }
990
+
991
+ input::-webkit-outer-spin-button,
992
+ input::-webkit-inner-spin-button {
993
+ -webkit-appearance: none;
994
+ margin: 0;
995
+ }
996
+
997
+ input:focus {
998
+ outline: none;
999
+ }
1000
+ `;
1001
+ }
1002
+ #initialValue;
1003
+ connectedCallback() {
1004
+ super.connectedCallback();
1005
+ this.#initialValue = this.defaultValue ?? this.value;
1006
+ this.#syncFormState();
1007
+ }
1008
+ updated(changedProperties) {
1009
+ super.updated(changedProperties);
1010
+ if (changedProperties.has("defaultValue") && this.defaultValue !== void 0) this.#initialValue = this.defaultValue;
1011
+ if (changedProperties.has("value") || changedProperties.has("min") || changedProperties.has("max") || changedProperties.has("step") || changedProperties.has("disabled")) this.#syncFormState();
1012
+ }
1013
+ /**
1014
+ * Evaluates step alignment using floating-point safe division.
1015
+ */
1016
+ #checkStepMismatch(val, min, step) {
1017
+ if (step <= 0 || Number.isNaN(val)) return false;
1018
+ const quotient = (val - min) / step;
1019
+ return Math.abs(quotient - Math.round(quotient)) > 1e-7;
1020
+ }
1021
+ /**
1022
+ * Formats a value for form submission and HTML input display,
1023
+ * preventing scientific notation drift.
1024
+ */
1025
+ #formatValue(val) {
1026
+ if (typeof val === "number") {
1027
+ if (Number.isNaN(val)) return "NaN";
1028
+ if (Math.abs(val) < 1e-9) return "0";
1029
+ const str = String(val);
1030
+ if (str.includes("e-")) return val.toFixed(10).replace(/\.?0+$/, "");
1031
+ return str;
1032
+ }
1033
+ return String(val ?? "");
1034
+ }
1035
+ /**
1036
+ * Derives decimal precision from numbers to avoid IEEE 754 precision drift.
1037
+ */
1038
+ #getPrecision(...numbers) {
1039
+ let maxDecimals = 0;
1040
+ for (const n of numbers) {
1041
+ if (!Number.isFinite(n)) continue;
1042
+ const str = n.toString();
1043
+ const dot = str.indexOf(".");
1044
+ if (dot !== -1) maxDecimals = Math.max(maxDecimals, str.length - dot - 1);
1045
+ }
1046
+ return Math.min(12, maxDecimals);
1047
+ }
1048
+ /**
1049
+ * Synchronizes internal value with ElementInternals form value and constraint validation.
1050
+ */
1051
+ #syncFormState() {
1052
+ this.setFormValue(this.#formatValue(this.value));
1053
+ const flags = {};
1054
+ let message = "";
1055
+ const rawVal = this.value;
1056
+ const isBlank = typeof rawVal === "string" && rawVal.trim() === "";
1057
+ const num = isBlank ? NaN : Number(this.value);
1058
+ if (isBlank || Number.isNaN(num)) {
1059
+ flags.badInput = true;
1060
+ message = "Please enter a valid number.";
1061
+ } else if (num < this.min) {
1062
+ flags.rangeUnderflow = true;
1063
+ message = `Value must be greater than or equal to ${this.min}.`;
1064
+ } else if (num > this.max) {
1065
+ flags.rangeOverflow = true;
1066
+ message = `Value must be less than or equal to ${this.max}.`;
1067
+ } else if (this.#checkStepMismatch(num, this.min, this.step)) {
1068
+ flags.stepMismatch = true;
1069
+ message = `Value must be an increment of ${this.step} from ${this.min}.`;
1070
+ }
1071
+ const anchor = this.inputEl ?? this.renderRoot?.querySelector("input");
1072
+ this.setValidity(flags, message, anchor);
1073
+ }
1074
+ /**
1075
+ * Emits input, change, and quantity-change CustomEvents across Shadow DOM boundaries.
1076
+ */
1077
+ #emitEvents(triggerType) {
1078
+ const detail = { value: this.value };
1079
+ if (triggerType === "input" || triggerType === "both") this.dispatchEvent(new CustomEvent("input", {
1080
+ bubbles: true,
1081
+ composed: true,
1082
+ detail
1083
+ }));
1084
+ if (triggerType === "change" || triggerType === "both") {
1085
+ this.dispatchEvent(new CustomEvent("change", {
1086
+ bubbles: true,
1087
+ composed: true,
1088
+ detail
1089
+ }));
1090
+ this.dispatchEvent(new CustomEvent("quantity-change", {
1091
+ bubbles: true,
1092
+ composed: true,
1093
+ detail
1094
+ }));
1095
+ }
1096
+ }
1097
+ /**
1098
+ * Increments the current value by the configured step.
1099
+ * If at or above max, or if disabled/readonly, this operation is a no-op.
1100
+ */
1101
+ increment() {
1102
+ if (this.disabled || this.readonly) return;
1103
+ const current = Number(this.value);
1104
+ if (Number.isNaN(current)) return;
1105
+ const precision = this.#getPrecision(this.step, current, this.max);
1106
+ const next = Number((current + this.step).toFixed(precision));
1107
+ if (next > this.max + 1e-9) return;
1108
+ const clamped = Math.abs(next - this.max) < 1e-9 ? this.max : Math.min(next, this.max);
1109
+ this.value = Math.abs(clamped) < 1e-9 ? 0 : clamped;
1110
+ this.#syncFormState();
1111
+ this.requestUpdate();
1112
+ this.#emitEvents("both");
1113
+ }
1114
+ /**
1115
+ * Decrements the current value by the configured step.
1116
+ * If at or below min, or if disabled/readonly, this operation is a no-op.
1117
+ */
1118
+ decrement() {
1119
+ if (this.disabled || this.readonly) return;
1120
+ const current = Number(this.value);
1121
+ if (Number.isNaN(current)) return;
1122
+ const precision = this.#getPrecision(this.step, current, this.min);
1123
+ const next = Number((current - this.step).toFixed(precision));
1124
+ if (next < this.min - 1e-9) return;
1125
+ const clamped = Math.abs(next - this.min) < 1e-9 ? this.min : Math.max(next, this.min);
1126
+ this.value = Math.abs(clamped) < 1e-9 ? 0 : clamped;
1127
+ this.#syncFormState();
1128
+ this.requestUpdate();
1129
+ this.#emitEvents("both");
1130
+ }
1131
+ /**
1132
+ * Sets the value imperatively with optional event dispatching.
1133
+ *
1134
+ * @param val New numerical value.
1135
+ * @param options.dispatchEvents If true, emits `input` and `change` events.
1136
+ */
1137
+ setValue(val, options) {
1138
+ const num = typeof val === "number" ? val : Number(val);
1139
+ this.value = num;
1140
+ this.#syncFormState();
1141
+ this.requestUpdate();
1142
+ if (options?.dispatchEvents) this.#emitEvents("both");
1143
+ }
1144
+ /**
1145
+ * Handles user input from the internal number input field.
1146
+ */
1147
+ #handleInput;
1148
+ /**
1149
+ * Handles commit / change from the internal number input field.
1150
+ */
1151
+ #handleChange;
1152
+ /**
1153
+ * Invoked by FormAssociatedLitElement when the parent form executes a reset.
1154
+ * Restores initial value, clears validation, and synchronizes form value.
1155
+ */
1156
+ onFormReset() {
1157
+ this.value = this.defaultValue ?? this.#initialValue;
1158
+ this.#syncFormState();
1159
+ this.requestUpdate();
1160
+ }
1161
+ render() {
1162
+ const isInvalid = !this.validity.valid;
1163
+ const current = Number(this.value);
1164
+ const isDecDisabled = this.disabled || this.readonly || Number.isNaN(current) || current <= this.min + 1e-9;
1165
+ const isIncDisabled = this.disabled || this.readonly || Number.isNaN(current) || current >= this.max - 1e-9;
1166
+ return html`
1167
+ <div
1168
+ class="stepper ${this.focused ? "focused" : ""} ${isInvalid ? "invalid" : ""}"
1169
+ role="group"
1170
+ aria-label=${this.getAttribute("aria-label") || "Quantity Stepper"}
1171
+ >
1172
+ <button
1173
+ type="button"
1174
+ part="button button-decrement"
1175
+ class="btn-decrement"
1176
+ ?disabled=${isDecDisabled}
1177
+ @click=${this.decrement}
1178
+ tabindex="-1"
1179
+ aria-label="Decrease quantity"
1180
+ >−</button>
1181
+ <input
1182
+ type="number"
1183
+ part="input"
1184
+ .value=${this.#formatValue(this.value)}
1185
+ min=${this.min}
1186
+ max=${this.max}
1187
+ step=${this.step}
1188
+ ?disabled=${this.disabled}
1189
+ ?readonly=${this.readonly}
1190
+ @input=${this.#handleInput}
1191
+ @change=${this.#handleChange}
1192
+ @focus=${() => {
1193
+ this.focused = true;
1194
+ }}
1195
+ @blur=${() => {
1196
+ this.focused = false;
1197
+ }}
1198
+ aria-label="Quantity"
1199
+ />
1200
+ <button
1201
+ type="button"
1202
+ part="button button-increment"
1203
+ class="btn-increment"
1204
+ ?disabled=${isIncDisabled}
1205
+ @click=${this.increment}
1206
+ tabindex="-1"
1207
+ aria-label="Increase quantity"
1208
+ >+</button>
1209
+ </div>
1210
+ `;
1211
+ }
1212
+ };
1213
+ __decorate([property({ type: Number })], LitQuantityStepper.prototype, "value", void 0);
1214
+ __decorate([property({ type: Number })], LitQuantityStepper.prototype, "min", void 0);
1215
+ __decorate([property({ type: Number })], LitQuantityStepper.prototype, "max", void 0);
1216
+ __decorate([property({ type: Number })], LitQuantityStepper.prototype, "step", void 0);
1217
+ __decorate([property({
1218
+ type: Boolean,
1219
+ reflect: true
1220
+ })], LitQuantityStepper.prototype, "readonly", void 0);
1221
+ __decorate([property({
1222
+ type: Number,
1223
+ attribute: "default-value"
1224
+ })], LitQuantityStepper.prototype, "defaultValue", void 0);
1225
+ __decorate([state()], LitQuantityStepper.prototype, "focused", void 0);
1226
+ __decorate([query("input")], LitQuantityStepper.prototype, "inputEl", void 0);
1227
+ defineCustomElement("lit-quantity-stepper", LitQuantityStepper);
1228
+ // src/components/cart-badge.ts
1229
+ /**
1230
+ * @fileoverview Reference implementation of <lit-cart-badge> custom element.
1231
+ * Demonstrates dual-mode store subscription (direct property vs W3C Context Protocol),
1232
+ * selective projection for total count, coalesced pending state tracking,
1233
+ * and zero-memory-leak unmounting.
1234
+ *
1235
+ * @implements view-layer
1236
+ * @implements store-integration-pattern
1237
+ * @implements action-pipeline-system
1238
+ * @memberof core-concepts
1239
+ */
1240
+ /**
1241
+ * Standard W3C Context token for the shopping cart domain store.
1242
+ * Uses Symbol.for to guarantee singleton identity across independent bundle chunks.
1243
+ */
1244
+ const cartStoreContext = createStoreContext("cart");
1245
+ /**
1246
+ * Default projection selector that extracts total quantity from diverse cart store state structures:
1247
+ * - Primitive numbers (e.g. `5`)
1248
+ * - State objects with `totalCount`, `count`, or `total` numerical fields
1249
+ * - State objects with `items` arrays containing quantity numbers or objects (`{ quantity: n }`)
1250
+ */
1251
+ function defaultCartCountSelector(state) {
1252
+ if (typeof state === "number") return Number.isFinite(state) ? Math.max(0, Math.floor(state)) : 0;
1253
+ if (state && typeof state === "object") {
1254
+ const obj = state;
1255
+ if (typeof obj.totalCount === "number" && Number.isFinite(obj.totalCount)) return Math.max(0, Math.floor(obj.totalCount));
1256
+ if (typeof obj.count === "number" && Number.isFinite(obj.count)) return Math.max(0, Math.floor(obj.count));
1257
+ if (typeof obj.total === "number" && Number.isFinite(obj.total)) return Math.max(0, Math.floor(obj.total));
1258
+ if (Array.isArray(obj.items)) return obj.items.reduce((sum, item) => {
1259
+ const qty = typeof item === "number" ? item : typeof item?.quantity === "number" ? item.quantity : 1;
1260
+ return sum + (Number.isFinite(qty) ? Math.max(0, qty) : 0);
1261
+ }, 0);
1262
+ }
1263
+ return 0;
1264
+ }
1265
+ /**
1266
+ * Reference Custom Element for displaying a dynamic shopping cart badge.
1267
+ *
1268
+ * Supports two consumption paradigms:
1269
+ * 1. Direct property assignment: `.store=${cartStore}` and `.register=${actionRegister}`.
1270
+ * 2. W3C Context Protocol: automatically injects `cartStoreContext` and `actionRegisterContext`
1271
+ * from ancestor providers across Shadow DOM boundaries.
1272
+ */
1273
+ var LitCartBadge = class extends LitElement {
1274
+ static {
1275
+ this.styles = css`
1276
+ :host {
1277
+ display: inline-flex;
1278
+ vertical-align: middle;
1279
+ font-family: var(
1280
+ --cart-badge-font-family,
1281
+ system-ui,
1282
+ -apple-system,
1283
+ BlinkMacSystemFont,
1284
+ 'Segoe UI',
1285
+ Roboto,
1286
+ sans-serif
1287
+ );
1288
+ --_bg: var(--cart-badge-bg, #2563eb);
1289
+ --_color: var(--cart-badge-color, #ffffff);
1290
+ --_hover-bg: var(--cart-badge-hover-bg, #1d4ed8);
1291
+ --_active-bg: var(--cart-badge-active-bg, #1e40af);
1292
+ --_radius: var(--cart-badge-radius, 9999px);
1293
+ --_font-size: var(--cart-badge-font-size, 0.875rem);
1294
+ --_font-weight: var(--cart-badge-font-weight, 600);
1295
+ --_padding: var(--cart-badge-padding, 4px 10px);
1296
+ --_pulse-scale: var(--cart-badge-pulse-scale, 1.2);
1297
+ }
1298
+
1299
+ :host([variant='secondary']) {
1300
+ --_bg: var(--cart-badge-bg, #475569);
1301
+ --_hover-bg: var(--cart-badge-hover-bg, #334155);
1302
+ --_active-bg: var(--cart-badge-active-bg, #1e293b);
1303
+ }
1304
+
1305
+ :host([variant='accent']) {
1306
+ --_bg: var(--cart-badge-bg, #7c3aed);
1307
+ --_hover-bg: var(--cart-badge-hover-bg, #6d28d9);
1308
+ --_active-bg: var(--cart-badge-active-bg, #5b21b6);
1309
+ }
1310
+
1311
+ :host([variant='danger']) {
1312
+ --_bg: var(--cart-badge-bg, #dc2626);
1313
+ --_hover-bg: var(--cart-badge-hover-bg, #b91c1c);
1314
+ --_active-bg: var(--cart-badge-active-bg, #991b1b);
1315
+ }
1316
+
1317
+ :host([hidden]) {
1318
+ display: none !important;
1319
+ }
1320
+
1321
+ :host([hide-zero][count-zero]) {
1322
+ display: none !important;
1323
+ }
1324
+
1325
+ :host([disabled]) {
1326
+ opacity: 0.6;
1327
+ pointer-events: none;
1328
+ cursor: not-allowed;
1329
+ }
1330
+
1331
+ .badge {
1332
+ display: inline-flex;
1333
+ align-items: center;
1334
+ gap: 6px;
1335
+ padding: var(--_padding);
1336
+ background-color: var(--_bg);
1337
+ color: var(--_color);
1338
+ border-radius: var(--_radius);
1339
+ font-size: var(--_font-size);
1340
+ font-weight: var(--_font-weight);
1341
+ border: none;
1342
+ cursor: pointer;
1343
+ user-select: none;
1344
+ outline: none;
1345
+ transition: background-color 0.15s ease, transform 0.15s ease;
1346
+ box-sizing: border-box;
1347
+ line-height: 1;
1348
+ }
1349
+
1350
+ .badge:hover:not(:disabled) {
1351
+ background-color: var(--_hover-bg);
1352
+ }
1353
+
1354
+ .badge:active:not(:disabled) {
1355
+ background-color: var(--_active-bg);
1356
+ }
1357
+
1358
+ .badge:focus-visible {
1359
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.35);
1360
+ }
1361
+
1362
+ .badge:disabled {
1363
+ cursor: not-allowed;
1364
+ opacity: 0.6;
1365
+ }
1366
+
1367
+ .count {
1368
+ display: inline-flex;
1369
+ align-items: center;
1370
+ justify-content: center;
1371
+ min-width: 1.25em;
1372
+ font-variant-numeric: tabular-nums;
1373
+ }
1374
+
1375
+ .pulse {
1376
+ animation: cart-badge-pulse 300ms cubic-bezier(0.4, 0, 0.2, 1);
1377
+ }
1378
+
1379
+ @keyframes cart-badge-pulse {
1380
+ 0% {
1381
+ transform: scale(1);
1382
+ }
1383
+ 50% {
1384
+ transform: scale(var(--_pulse-scale));
1385
+ }
1386
+ 100% {
1387
+ transform: scale(1);
1388
+ }
1389
+ }
1390
+
1391
+ .spinner {
1392
+ display: inline-block;
1393
+ width: 12px;
1394
+ height: 12px;
1395
+ border: 2px solid currentColor;
1396
+ border-top-color: transparent;
1397
+ border-radius: 50%;
1398
+ animation: cart-badge-spin 0.8s linear infinite;
1399
+ box-sizing: border-box;
1400
+ }
1401
+
1402
+ @keyframes cart-badge-spin {
1403
+ to {
1404
+ transform: rotate(360deg);
1405
+ }
1406
+ }
1407
+ `;
1408
+ }
1409
+ #contextStoreController;
1410
+ #contextActionController;
1411
+ #directStoreController;
1412
+ #directActionController;
1413
+ #previousCount;
1414
+ constructor() {
1415
+ super();
1416
+ this.value = void 0;
1417
+ this.maxCount = 99;
1418
+ this.hideZero = false;
1419
+ this.label = "Cart";
1420
+ this.disabled = false;
1421
+ this.variant = "primary";
1422
+ this.isPulsing = false;
1423
+ this.#directStoreController = null;
1424
+ this.#directActionController = null;
1425
+ this.#handleClick = async () => {
1426
+ if (this.disabled || this.isPending) return;
1427
+ this.dispatchEvent(new CustomEvent("cart-badge-click", {
1428
+ bubbles: true,
1429
+ composed: true,
1430
+ detail: {
1431
+ count: this.count,
1432
+ isPending: this.isPending
1433
+ }
1434
+ }));
1435
+ if (this.actionName) try {
1436
+ if (this.#directActionController) await this.#directActionController.dispatch(this.actionName, this.actionPayload);
1437
+ else if (this.#contextActionController.register) await this.#contextActionController.dispatch(this.actionName, this.actionPayload);
1438
+ } catch (error) {
1439
+ this.dispatchEvent(new CustomEvent("cart-action-error", {
1440
+ bubbles: true,
1441
+ composed: true,
1442
+ detail: { error }
1443
+ }));
1444
+ }
1445
+ };
1446
+ this.#contextStoreController = new ContextStoreController(this, {
1447
+ context: cartStoreContext,
1448
+ selector: (state) => this.selector ? this.selector(state) : defaultCartCountSelector(state),
1449
+ equalityFn: (a, b) => this.equalityFn ? this.equalityFn(a, b) : Object.is(a, b)
1450
+ });
1451
+ this.#contextActionController = new ContextActionController(this, { context: actionRegisterContext });
1452
+ }
1453
+ willUpdate(changedProperties) {
1454
+ super.willUpdate(changedProperties);
1455
+ if (changedProperties.has("store") || changedProperties.has("selector") || changedProperties.has("equalityFn")) {
1456
+ if (this.#directStoreController) {
1457
+ this.#directStoreController.dispose();
1458
+ this.#directStoreController = null;
1459
+ }
1460
+ if (this.store) this.#directStoreController = new StoreController(this, this.store, {
1461
+ selector: (state) => this.selector ? this.selector(state) : defaultCartCountSelector(state),
1462
+ equalityFn: this.equalityFn ?? Object.is
1463
+ });
1464
+ }
1465
+ if (changedProperties.has("register")) {
1466
+ if (this.register) this.#directActionController = new ActionController(this, this.register);
1467
+ else this.#directActionController = null;
1468
+ }
1469
+ const currentCount = this.count;
1470
+ if (this.#previousCount !== void 0 && currentCount > this.#previousCount) this.isPulsing = true;
1471
+ this.#previousCount = currentCount;
1472
+ }
1473
+ /**
1474
+ * Derives the current total count from direct override, direct store, or context store.
1475
+ */
1476
+ get count() {
1477
+ if (this.value !== void 0) return this.value;
1478
+ if (this.#directStoreController) return this.#directStoreController.value;
1479
+ if (this.store) {
1480
+ const raw = typeof this.store.getValue === "function" ? this.store.getValue() : typeof this.store.getSnapshot === "function" ? this.store.getSnapshot() : void 0;
1481
+ return this.selector ? this.selector(raw) : defaultCartCountSelector(raw);
1482
+ }
1483
+ if (this.#contextStoreController.isResolved && this.#contextStoreController.value !== void 0) return this.#contextStoreController.value;
1484
+ return 0;
1485
+ }
1486
+ set count(val) {
1487
+ const oldVal = this.count;
1488
+ if (val === void 0 || val === null) this.value = void 0;
1489
+ else this.value = Number.isFinite(val) ? Math.max(0, Math.floor(val)) : 0;
1490
+ this.requestUpdate("count", oldVal);
1491
+ }
1492
+ /**
1493
+ * Indicates whether an action dispatch is currently in flight.
1494
+ */
1495
+ get isPending() {
1496
+ if (this.#directActionController) return this.#directActionController.isPending;
1497
+ return this.#contextActionController.isPending;
1498
+ }
1499
+ updated(changedProperties) {
1500
+ super.updated(changedProperties);
1501
+ const currentCount = this.count;
1502
+ this.toggleAttribute("has-items", currentCount > 0);
1503
+ this.toggleAttribute("count-zero", currentCount === 0);
1504
+ }
1505
+ #handleClick;
1506
+ render() {
1507
+ const current = this.count;
1508
+ if (this.hideZero && current <= 0) return nothing;
1509
+ const pending = this.isPending;
1510
+ const displayCount = current > this.maxCount ? `${this.maxCount}+` : String(current);
1511
+ return html`
1512
+ <button
1513
+ type="button"
1514
+ part="button badge"
1515
+ class="badge ${this.isPulsing ? "pulse" : ""}"
1516
+ ?disabled=${this.disabled || pending}
1517
+ @click=${this.#handleClick}
1518
+ @animationend=${() => {
1519
+ this.isPulsing = false;
1520
+ }}
1521
+ aria-label=${this.getAttribute("aria-label") || `${this.label}: ${displayCount} items`}
1522
+ >
1523
+ <slot name="icon">
1524
+ <span part="icon" class="icon" aria-hidden="true">🛒</span>
1525
+ </slot>
1526
+ <span part="label" class="label"><slot>${this.label}</slot></span>
1527
+ <span part="count" class="count">${displayCount}</span>
1528
+ ${pending ? html`<span
1529
+ part="spinner"
1530
+ class="spinner"
1531
+ aria-label="Loading"
1532
+ ></span>` : nothing}
1533
+ </button>
1534
+ `;
1535
+ }
1536
+ };
1537
+ __decorate([property({ attribute: false })], LitCartBadge.prototype, "store", void 0);
1538
+ __decorate([property({ attribute: false })], LitCartBadge.prototype, "register", void 0);
1539
+ __decorate([property({ attribute: false })], LitCartBadge.prototype, "selector", void 0);
1540
+ __decorate([property({ attribute: false })], LitCartBadge.prototype, "equalityFn", void 0);
1541
+ __decorate([property({ attribute: false })], LitCartBadge.prototype, "storeContext", void 0);
1542
+ __decorate([property({ attribute: false })], LitCartBadge.prototype, "actionContext", void 0);
1543
+ __decorate([property({ type: Number })], LitCartBadge.prototype, "value", void 0);
1544
+ __decorate([property({
1545
+ type: Number,
1546
+ attribute: "max-count"
1547
+ })], LitCartBadge.prototype, "maxCount", void 0);
1548
+ __decorate([property({
1549
+ type: Boolean,
1550
+ reflect: true,
1551
+ attribute: "hide-zero"
1552
+ })], LitCartBadge.prototype, "hideZero", void 0);
1553
+ __decorate([property({ type: String })], LitCartBadge.prototype, "label", void 0);
1554
+ __decorate([property({
1555
+ type: String,
1556
+ attribute: "action-name"
1557
+ })], LitCartBadge.prototype, "actionName", void 0);
1558
+ __decorate([property({ attribute: false })], LitCartBadge.prototype, "actionPayload", void 0);
1559
+ __decorate([property({
1560
+ type: Boolean,
1561
+ reflect: true
1562
+ })], LitCartBadge.prototype, "disabled", void 0);
1563
+ __decorate([property({
1564
+ type: String,
1565
+ reflect: true
1566
+ })], LitCartBadge.prototype, "variant", void 0);
1567
+ __decorate([state()], LitCartBadge.prototype, "isPulsing", void 0);
1568
+ defineCustomElement("lit-cart-badge", LitCartBadge);
1569
+ export { ContextActionController, ContextConsumer, ContextProvider, ContextStoreController, ControllerScope, FormAssociatedLitElement, LitCartBadge, LitQuantityStepper, actionDispatcherContext, actionRegisterContext, cartStoreContext, createActionDispatcherContext, createActionRegisterContext, createContext, createLitElementBridge as createCustomElementBridge, createLitElementBridge as createLitBridge, createLitElementBridge, createElementFacade, createStoreContext, defaultCartCountSelector, provideActionDispatcher, provideActionRegister, provideStore };
1570
+
1571
+ //# sourceMappingURL=index.js.map