@nymbalhq/web-components 0.0.0-stage → 0.2.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/dist/index.js ADDED
@@ -0,0 +1,1113 @@
1
+ // src/registry.ts
2
+ var CLIENT_KEY = /* @__PURE__ */ Symbol.for("nymbal.client");
3
+ function registerClient(client) {
4
+ globalThis[CLIENT_KEY] = client;
5
+ }
6
+ function getClient() {
7
+ const client = globalThis[CLIENT_KEY];
8
+ if (!client) {
9
+ throw new Error("NymbalClient not registered. Call registerClient() before using components.");
10
+ }
11
+ return client;
12
+ }
13
+
14
+ // src/base-element.ts
15
+ var HTMLElementBase = typeof HTMLElement !== "undefined" ? HTMLElement : class {
16
+ };
17
+ var NymbalElement = class extends HTMLElementBase {
18
+ _unsubs = [];
19
+ _renderQueued = false;
20
+ get client() {
21
+ return getClient();
22
+ }
23
+ subscribeToStore(store) {
24
+ this._unsubs.push(store.subscribe(() => this.requestRender()));
25
+ }
26
+ requestRender() {
27
+ if (this._renderQueued) return;
28
+ this._renderQueued = true;
29
+ queueMicrotask(() => {
30
+ this._renderQueued = false;
31
+ this.render();
32
+ });
33
+ }
34
+ connectedCallback() {
35
+ this.setup();
36
+ this.render();
37
+ }
38
+ disconnectedCallback() {
39
+ for (const fn of this._unsubs) fn();
40
+ this._unsubs = [];
41
+ }
42
+ };
43
+
44
+ // src/styles.ts
45
+ var injected = /* @__PURE__ */ new Set();
46
+ function injectStyles(tagName, css) {
47
+ if (injected.has(tagName)) return;
48
+ if (typeof document === "undefined") return;
49
+ const style = document.createElement("style");
50
+ style.setAttribute("data-nymbal", tagName);
51
+ style.textContent = css;
52
+ document.head.appendChild(style);
53
+ injected.add(tagName);
54
+ }
55
+
56
+ // src/components/add-to-cart.ts
57
+ var STYLES = `
58
+ nymbal-add-to-cart {
59
+ display: inline-block;
60
+ }
61
+ nymbal-add-to-cart button {
62
+ font-family: var(--nymbal-font-family);
63
+ font-size: var(--nymbal-font-size-base);
64
+ font-weight: var(--nymbal-font-weight-medium);
65
+ background: var(--nymbal-color-primary);
66
+ color: var(--nymbal-color-text-inverse);
67
+ border: none;
68
+ border-radius: var(--nymbal-radius-md);
69
+ padding: var(--nymbal-spacing-sm) var(--nymbal-spacing-lg);
70
+ cursor: pointer;
71
+ transition: background var(--nymbal-transition-fast);
72
+ min-width: 140px;
73
+ }
74
+ nymbal-add-to-cart button:hover:not(:disabled) {
75
+ background: var(--nymbal-color-primary-hover);
76
+ }
77
+ nymbal-add-to-cart button:disabled {
78
+ opacity: 0.5;
79
+ cursor: not-allowed;
80
+ }
81
+ `;
82
+ var NymbalAddToCart = class extends NymbalElement {
83
+ static get observedAttributes() {
84
+ return ["variant-id", "disabled"];
85
+ }
86
+ button;
87
+ setup() {
88
+ injectStyles("nymbal-add-to-cart", STYLES);
89
+ this.subscribeToStore(this.client.cart);
90
+ }
91
+ render() {
92
+ const variantId = this.getAttribute("variant-id");
93
+ const isDisabled = this.hasAttribute("disabled");
94
+ const cartState = this.client.cart.getState();
95
+ if (!this.button) {
96
+ this.button = document.createElement("button");
97
+ this.button.setAttribute("type", "button");
98
+ this.button.setAttribute("data-testid", "add-to-cart");
99
+ this.button.addEventListener("click", this.handleClick);
100
+ this.innerHTML = "";
101
+ this.appendChild(this.button);
102
+ }
103
+ this.button.disabled = isDisabled || cartState.loading || !variantId;
104
+ this.button.setAttribute("aria-busy", String(cartState.loading));
105
+ this.button.textContent = cartState.loading ? "Adding..." : "Add to Cart";
106
+ }
107
+ attributeChangedCallback() {
108
+ if (this.isConnected) this.requestRender();
109
+ }
110
+ handleClick = async () => {
111
+ const variantId = this.getAttribute("variant-id");
112
+ if (!variantId) return;
113
+ try {
114
+ await this.client.cart.addItem(variantId);
115
+ this.dispatchEvent(
116
+ new CustomEvent("nymbal:cart:item-added", {
117
+ bubbles: true,
118
+ composed: true,
119
+ detail: { variantId }
120
+ })
121
+ );
122
+ } catch {
123
+ }
124
+ };
125
+ };
126
+
127
+ // src/components/cart-drawer.ts
128
+ var STYLES2 = `
129
+ nymbal-cart-drawer {
130
+ display: block;
131
+ }
132
+ nymbal-cart-drawer .nymbal-drawer-overlay {
133
+ position: fixed;
134
+ inset: 0;
135
+ background: var(--nymbal-color-overlay);
136
+ z-index: var(--nymbal-z-drawer);
137
+ opacity: 0;
138
+ visibility: hidden;
139
+ transition: opacity var(--nymbal-transition-normal), visibility var(--nymbal-transition-normal);
140
+ }
141
+ nymbal-cart-drawer[open] .nymbal-drawer-overlay {
142
+ opacity: 1;
143
+ visibility: visible;
144
+ }
145
+ nymbal-cart-drawer .nymbal-drawer-panel {
146
+ position: fixed;
147
+ top: 0;
148
+ right: 0;
149
+ bottom: 0;
150
+ width: min(400px, 90vw);
151
+ background: var(--nymbal-color-surface);
152
+ z-index: var(--nymbal-z-drawer);
153
+ transform: translateX(100%);
154
+ transition: transform var(--nymbal-transition-normal);
155
+ display: flex;
156
+ flex-direction: column;
157
+ font-family: var(--nymbal-font-family);
158
+ }
159
+ nymbal-cart-drawer[open] .nymbal-drawer-panel {
160
+ transform: translateX(0);
161
+ }
162
+ nymbal-cart-drawer .nymbal-drawer-header {
163
+ display: flex;
164
+ justify-content: space-between;
165
+ align-items: center;
166
+ padding: var(--nymbal-spacing-md) var(--nymbal-spacing-lg);
167
+ border-bottom: 1px solid var(--nymbal-color-border);
168
+ }
169
+ nymbal-cart-drawer .nymbal-drawer-header h2 {
170
+ margin: 0;
171
+ font-size: var(--nymbal-font-size-lg);
172
+ font-weight: var(--nymbal-font-weight-bold);
173
+ }
174
+ nymbal-cart-drawer .nymbal-drawer-close {
175
+ background: none;
176
+ border: none;
177
+ font-size: var(--nymbal-font-size-xl);
178
+ cursor: pointer;
179
+ padding: var(--nymbal-spacing-xs);
180
+ color: var(--nymbal-color-text-muted);
181
+ }
182
+ nymbal-cart-drawer .nymbal-drawer-items {
183
+ flex: 1;
184
+ overflow-y: auto;
185
+ padding: var(--nymbal-spacing-md) var(--nymbal-spacing-lg);
186
+ }
187
+ nymbal-cart-drawer .nymbal-cart-item {
188
+ display: flex;
189
+ gap: var(--nymbal-spacing-md);
190
+ padding: var(--nymbal-spacing-md) 0;
191
+ border-bottom: 1px solid var(--nymbal-color-border);
192
+ }
193
+ nymbal-cart-drawer .nymbal-cart-item-info {
194
+ flex: 1;
195
+ }
196
+ nymbal-cart-drawer .nymbal-cart-item-name {
197
+ font-weight: var(--nymbal-font-weight-medium);
198
+ margin-bottom: var(--nymbal-spacing-xs);
199
+ }
200
+ nymbal-cart-drawer .nymbal-cart-item-variant {
201
+ font-size: var(--nymbal-font-size-sm);
202
+ color: var(--nymbal-color-text-muted);
203
+ }
204
+ nymbal-cart-drawer .nymbal-cart-item-actions {
205
+ display: flex;
206
+ align-items: center;
207
+ gap: var(--nymbal-spacing-sm);
208
+ }
209
+ nymbal-cart-drawer .nymbal-cart-item-remove {
210
+ background: none;
211
+ border: none;
212
+ color: var(--nymbal-color-error);
213
+ cursor: pointer;
214
+ font-size: var(--nymbal-font-size-sm);
215
+ padding: var(--nymbal-spacing-xs);
216
+ }
217
+ nymbal-cart-drawer .nymbal-drawer-footer {
218
+ padding: var(--nymbal-spacing-md) var(--nymbal-spacing-lg);
219
+ border-top: 1px solid var(--nymbal-color-border);
220
+ }
221
+ nymbal-cart-drawer .nymbal-cart-subtotal {
222
+ display: flex;
223
+ justify-content: space-between;
224
+ font-weight: var(--nymbal-font-weight-bold);
225
+ margin-bottom: var(--nymbal-spacing-md);
226
+ }
227
+ nymbal-cart-drawer .nymbal-checkout-btn {
228
+ width: 100%;
229
+ font-family: var(--nymbal-font-family);
230
+ font-size: var(--nymbal-font-size-base);
231
+ font-weight: var(--nymbal-font-weight-medium);
232
+ background: var(--nymbal-color-primary);
233
+ color: var(--nymbal-color-text-inverse);
234
+ border: none;
235
+ border-radius: var(--nymbal-radius-md);
236
+ padding: var(--nymbal-spacing-sm) var(--nymbal-spacing-lg);
237
+ cursor: pointer;
238
+ transition: background var(--nymbal-transition-fast);
239
+ }
240
+ nymbal-cart-drawer .nymbal-checkout-btn:hover {
241
+ background: var(--nymbal-color-primary-hover);
242
+ }
243
+ nymbal-cart-drawer .nymbal-drawer-empty {
244
+ text-align: center;
245
+ color: var(--nymbal-color-text-muted);
246
+ padding: var(--nymbal-spacing-xl) 0;
247
+ }
248
+ `;
249
+ var NymbalCartDrawer = class extends NymbalElement {
250
+ static get observedAttributes() {
251
+ return ["open"];
252
+ }
253
+ handleItemAdded = () => {
254
+ this.open();
255
+ };
256
+ handleMiniCartClicked = () => {
257
+ this.toggle();
258
+ };
259
+ setup() {
260
+ injectStyles("nymbal-cart-drawer", STYLES2);
261
+ this.subscribeToStore(this.client.cart);
262
+ document.addEventListener("nymbal:cart:item-added", this.handleItemAdded);
263
+ document.addEventListener("nymbal:mini-cart:clicked", this.handleMiniCartClicked);
264
+ }
265
+ disconnectedCallback() {
266
+ super.disconnectedCallback();
267
+ document.removeEventListener("nymbal:cart:item-added", this.handleItemAdded);
268
+ document.removeEventListener("nymbal:mini-cart:clicked", this.handleMiniCartClicked);
269
+ }
270
+ open() {
271
+ this.setAttribute("open", "");
272
+ window.dispatchEvent(new CustomEvent("nymbal:cart:open"));
273
+ }
274
+ close() {
275
+ this.removeAttribute("open");
276
+ window.dispatchEvent(new CustomEvent("nymbal:cart:close"));
277
+ }
278
+ toggle() {
279
+ if (this.hasAttribute("open")) {
280
+ this.close();
281
+ } else {
282
+ this.open();
283
+ }
284
+ }
285
+ render() {
286
+ const state = this.client.cart.getState();
287
+ const currency = state.currency;
288
+ const itemsHtml = state.items.length === 0 ? `<div class="nymbal-drawer-empty">Your cart is empty</div>` : state.items.map((item) => `
289
+ <div class="nymbal-cart-item" data-testid="cart-item-${item.variantId}">
290
+ <div class="nymbal-cart-item-info">
291
+ <div class="nymbal-cart-item-name">${this.esc(item.productName)}</div>
292
+ <div class="nymbal-cart-item-variant">${this.esc(item.variantName)}</div>
293
+ <div class="nymbal-cart-item-qty">Qty: ${item.qty}</div>
294
+ </div>
295
+ <div class="nymbal-cart-item-actions">
296
+ <span>${this.formatPrice(item.priceMinor * item.qty, currency)}</span>
297
+ <button type="button" class="nymbal-cart-item-remove" data-testid="remove-item-${item.variantId}" data-variant-id="${item.variantId}">Remove</button>
298
+ </div>
299
+ </div>
300
+ `).join("");
301
+ this.innerHTML = `
302
+ <div class="nymbal-drawer-overlay" data-testid="cart-drawer-overlay"></div>
303
+ <div class="nymbal-drawer-panel" role="dialog" aria-label="Shopping cart" aria-modal="true" data-testid="cart-drawer">
304
+ <div class="nymbal-drawer-header">
305
+ <h2>Cart (${state.itemCount})</h2>
306
+ <button type="button" class="nymbal-drawer-close" data-testid="cart-drawer-close" aria-label="Close cart">&times;</button>
307
+ </div>
308
+ <div class="nymbal-drawer-items">${itemsHtml}</div>
309
+ ${state.items.length > 0 ? `
310
+ <div class="nymbal-drawer-footer">
311
+ <div class="nymbal-cart-subtotal">
312
+ <span>Subtotal</span>
313
+ <span>${this.formatPrice(state.subtotalMinor, currency)}</span>
314
+ </div>
315
+ <button type="button" class="nymbal-checkout-btn" data-testid="cart-checkout-btn">Checkout</button>
316
+ </div>
317
+ ` : ""}
318
+ </div>
319
+ `;
320
+ this.querySelector(".nymbal-drawer-overlay")?.addEventListener("click", () => this.close());
321
+ this.querySelector(".nymbal-drawer-close")?.addEventListener("click", () => this.close());
322
+ this.querySelector(".nymbal-checkout-btn")?.addEventListener("click", () => {
323
+ this.dispatchEvent(new CustomEvent("nymbal:cart:checkout-clicked", { bubbles: true, composed: true }));
324
+ });
325
+ this.querySelectorAll(".nymbal-cart-item-remove").forEach((btn) => {
326
+ btn.addEventListener("click", () => {
327
+ const variantId = btn.dataset["variantId"];
328
+ if (variantId) this.client.cart.removeItem(variantId);
329
+ });
330
+ });
331
+ }
332
+ attributeChangedCallback() {
333
+ if (this.isConnected) this.requestRender();
334
+ }
335
+ esc(str) {
336
+ const div = document.createElement("div");
337
+ div.textContent = str;
338
+ return div.innerHTML;
339
+ }
340
+ formatPrice(minor, currency) {
341
+ return new Intl.NumberFormat(void 0, {
342
+ style: "currency",
343
+ currency
344
+ }).format(minor / 100);
345
+ }
346
+ };
347
+
348
+ // src/components/mini-cart.ts
349
+ var STYLES3 = `
350
+ nymbal-mini-cart {
351
+ display: inline-block;
352
+ position: relative;
353
+ }
354
+ nymbal-mini-cart button {
355
+ background: none;
356
+ border: none;
357
+ cursor: pointer;
358
+ position: relative;
359
+ padding: var(--nymbal-spacing-sm);
360
+ font-family: var(--nymbal-font-family);
361
+ color: var(--nymbal-color-text);
362
+ }
363
+ nymbal-mini-cart .nymbal-mini-cart-icon {
364
+ width: 24px;
365
+ height: 24px;
366
+ }
367
+ nymbal-mini-cart .nymbal-mini-cart-badge {
368
+ position: absolute;
369
+ top: 0;
370
+ right: 0;
371
+ background: var(--nymbal-color-accent);
372
+ color: var(--nymbal-color-text-inverse);
373
+ font-size: var(--nymbal-font-size-xs);
374
+ font-weight: var(--nymbal-font-weight-bold);
375
+ min-width: 18px;
376
+ height: 18px;
377
+ border-radius: var(--nymbal-radius-full);
378
+ display: flex;
379
+ align-items: center;
380
+ justify-content: center;
381
+ line-height: 1;
382
+ }
383
+ nymbal-mini-cart .nymbal-mini-cart-badge:empty {
384
+ display: none;
385
+ }
386
+ `;
387
+ var CART_SVG = `<svg class="nymbal-mini-cart-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/></svg>`;
388
+ var NymbalMiniCart = class extends NymbalElement {
389
+ setup() {
390
+ injectStyles("nymbal-mini-cart", STYLES3);
391
+ this.subscribeToStore(this.client.cart);
392
+ }
393
+ render() {
394
+ const { itemCount } = this.client.cart.getState();
395
+ this.innerHTML = `
396
+ <button type="button" data-testid="mini-cart" aria-label="Cart (${itemCount} items)">
397
+ ${CART_SVG}
398
+ <span class="nymbal-mini-cart-badge" data-testid="mini-cart-badge">${itemCount > 0 ? itemCount : ""}</span>
399
+ </button>
400
+ `;
401
+ this.querySelector("button")?.addEventListener("click", () => {
402
+ this.dispatchEvent(new CustomEvent("nymbal:mini-cart:clicked", { bubbles: true, composed: true }));
403
+ });
404
+ }
405
+ };
406
+
407
+ // src/components/variant-selector.ts
408
+ var STYLES4 = `
409
+ nymbal-variant-selector {
410
+ display: block;
411
+ font-family: var(--nymbal-font-family);
412
+ }
413
+ nymbal-variant-selector .nymbal-option-group {
414
+ margin-bottom: var(--nymbal-spacing-md);
415
+ }
416
+ nymbal-variant-selector .nymbal-option-label {
417
+ display: block;
418
+ font-size: var(--nymbal-font-size-sm);
419
+ font-weight: var(--nymbal-font-weight-medium);
420
+ margin-bottom: var(--nymbal-spacing-sm);
421
+ color: var(--nymbal-color-text);
422
+ }
423
+ nymbal-variant-selector .nymbal-option-buttons {
424
+ display: flex;
425
+ flex-wrap: wrap;
426
+ gap: var(--nymbal-spacing-sm);
427
+ }
428
+ nymbal-variant-selector .nymbal-option-btn {
429
+ font-family: var(--nymbal-font-family);
430
+ font-size: var(--nymbal-font-size-sm);
431
+ background: var(--nymbal-color-surface);
432
+ color: var(--nymbal-color-text);
433
+ border: 1px solid var(--nymbal-color-border);
434
+ border-radius: var(--nymbal-radius-md);
435
+ padding: var(--nymbal-spacing-xs) var(--nymbal-spacing-md);
436
+ cursor: pointer;
437
+ transition: border-color var(--nymbal-transition-fast), background var(--nymbal-transition-fast);
438
+ }
439
+ nymbal-variant-selector .nymbal-option-btn:hover {
440
+ border-color: var(--nymbal-color-primary);
441
+ }
442
+ nymbal-variant-selector .nymbal-option-btn[aria-pressed="true"] {
443
+ background: var(--nymbal-color-primary);
444
+ color: var(--nymbal-color-text-inverse);
445
+ border-color: var(--nymbal-color-primary);
446
+ }
447
+ nymbal-variant-selector .nymbal-option-btn:disabled {
448
+ opacity: 0.4;
449
+ cursor: not-allowed;
450
+ }
451
+ `;
452
+ var NymbalVariantSelector = class extends NymbalElement {
453
+ static get observedAttributes() {
454
+ return ["options"];
455
+ }
456
+ setup() {
457
+ injectStyles("nymbal-variant-selector", STYLES4);
458
+ this.subscribeToStore(this.client.product);
459
+ }
460
+ render() {
461
+ const state = this.client.product.getState();
462
+ const product = state.product;
463
+ if (!product) {
464
+ this.innerHTML = "";
465
+ return;
466
+ }
467
+ const optionGroups = this.getOptionGroups(product);
468
+ const selected = state.selectedVariant;
469
+ this.innerHTML = optionGroups.map((group) => `
470
+ <div class="nymbal-option-group">
471
+ <span class="nymbal-option-label">${this.esc(group.name)}</span>
472
+ <div class="nymbal-option-buttons">
473
+ ${group.values.map((value) => {
474
+ const isSelected = selected?.options.some(
475
+ (o) => o.name === group.name && o.value === value
476
+ ) ?? false;
477
+ return `<button
478
+ class="nymbal-option-btn"
479
+ data-testid="variant-option-${this.esc(group.name)}-${this.esc(value)}"
480
+ data-option-name="${this.esc(group.name)}"
481
+ data-option-value="${this.esc(value)}"
482
+ aria-pressed="${isSelected}"
483
+ >${this.esc(value)}</button>`;
484
+ }).join("")}
485
+ </div>
486
+ </div>
487
+ `).join("");
488
+ this.querySelectorAll(".nymbal-option-btn").forEach((btn) => {
489
+ btn.addEventListener("click", () => {
490
+ const el = btn;
491
+ const optionName = el.dataset["optionName"];
492
+ const optionValue = el.dataset["optionValue"];
493
+ if (!optionName || !optionValue || !product) return;
494
+ const currentSelections = /* @__PURE__ */ new Map();
495
+ if (selected) {
496
+ for (const opt of selected.options) {
497
+ currentSelections.set(opt.name, opt.value);
498
+ }
499
+ }
500
+ currentSelections.set(optionName, optionValue);
501
+ const match = product.variants.find((v) => {
502
+ const vRaw = v.options;
503
+ let vOpts = [];
504
+ if (typeof vRaw === "string") {
505
+ try {
506
+ vOpts = JSON.parse(vRaw);
507
+ } catch (e) {
508
+ console.warn("[variant-selector] malformed variant options JSON", e);
509
+ vOpts = [];
510
+ }
511
+ } else if (Array.isArray(vRaw)) {
512
+ vOpts = vRaw;
513
+ }
514
+ return vOpts.every((o) => currentSelections.get(o.name) === o.value);
515
+ });
516
+ if (match) {
517
+ this.client.product.selectVariant(match);
518
+ this.dispatchEvent(
519
+ new CustomEvent("nymbal:variant:selected", {
520
+ bubbles: true,
521
+ composed: true,
522
+ detail: { variant: match }
523
+ })
524
+ );
525
+ }
526
+ });
527
+ });
528
+ }
529
+ attributeChangedCallback() {
530
+ if (this.isConnected) this.requestRender();
531
+ }
532
+ getOptionGroups(product) {
533
+ const optionsAttr = this.getAttribute("options");
534
+ if (optionsAttr) {
535
+ try {
536
+ return JSON.parse(optionsAttr);
537
+ } catch (e) {
538
+ console.warn("[variant-selector] malformed options attribute, falling back to variant derivation", e);
539
+ }
540
+ }
541
+ const groups = /* @__PURE__ */ new Map();
542
+ for (const variant of product.variants) {
543
+ const raw = variant.options;
544
+ let opts = [];
545
+ if (typeof raw === "string") {
546
+ try {
547
+ opts = JSON.parse(raw);
548
+ } catch (e) {
549
+ console.warn("[variant-selector] malformed variant options in getOptionGroups", e);
550
+ opts = [];
551
+ }
552
+ } else if (Array.isArray(raw)) {
553
+ opts = raw;
554
+ }
555
+ for (const opt of opts) {
556
+ let values = groups.get(opt.name);
557
+ if (!values) {
558
+ values = /* @__PURE__ */ new Set();
559
+ groups.set(opt.name, values);
560
+ }
561
+ values.add(opt.value);
562
+ }
563
+ }
564
+ return Array.from(groups.entries()).map(([name, values]) => ({
565
+ name,
566
+ values: Array.from(values)
567
+ }));
568
+ }
569
+ esc(str) {
570
+ const div = document.createElement("div");
571
+ div.textContent = str;
572
+ return div.innerHTML;
573
+ }
574
+ };
575
+
576
+ // src/components/quantity-selector.ts
577
+ var STYLES5 = `
578
+ nymbal-quantity-selector {
579
+ display: inline-flex;
580
+ align-items: center;
581
+ font-family: var(--nymbal-font-family);
582
+ }
583
+ nymbal-quantity-selector button {
584
+ font-family: var(--nymbal-font-family);
585
+ font-size: var(--nymbal-font-size-base);
586
+ background: var(--nymbal-color-surface);
587
+ color: var(--nymbal-color-text);
588
+ border: 1px solid var(--nymbal-color-border);
589
+ width: 36px;
590
+ height: 36px;
591
+ cursor: pointer;
592
+ display: flex;
593
+ align-items: center;
594
+ justify-content: center;
595
+ transition: background var(--nymbal-transition-fast);
596
+ }
597
+ nymbal-quantity-selector button:hover:not(:disabled) {
598
+ background: var(--nymbal-color-secondary, #f4f4f5);
599
+ }
600
+ nymbal-quantity-selector button:disabled {
601
+ opacity: 0.4;
602
+ cursor: not-allowed;
603
+ }
604
+ nymbal-quantity-selector button:first-child {
605
+ border-radius: var(--nymbal-radius-md) 0 0 var(--nymbal-radius-md);
606
+ }
607
+ nymbal-quantity-selector button:last-child {
608
+ border-radius: 0 var(--nymbal-radius-md) var(--nymbal-radius-md) 0;
609
+ }
610
+ nymbal-quantity-selector input {
611
+ font-family: var(--nymbal-font-family);
612
+ font-size: var(--nymbal-font-size-base);
613
+ width: 48px;
614
+ height: 36px;
615
+ text-align: center;
616
+ border: 1px solid var(--nymbal-color-border);
617
+ border-left: none;
618
+ border-right: none;
619
+ -moz-appearance: textfield;
620
+ }
621
+ nymbal-quantity-selector input::-webkit-outer-spin-button,
622
+ nymbal-quantity-selector input::-webkit-inner-spin-button {
623
+ -webkit-appearance: none;
624
+ margin: 0;
625
+ }
626
+ `;
627
+ var NymbalQuantitySelector = class extends NymbalElement {
628
+ static get observedAttributes() {
629
+ return ["value", "min", "max"];
630
+ }
631
+ _value = 1;
632
+ setup() {
633
+ injectStyles("nymbal-quantity-selector", STYLES5);
634
+ this._value = Number(this.getAttribute("value")) || 1;
635
+ }
636
+ render() {
637
+ const min = Number(this.getAttribute("min")) || 1;
638
+ const max = Number(this.getAttribute("max")) || 99;
639
+ this.innerHTML = `
640
+ <button data-testid="qty-decrement" aria-label="Decrease quantity" ${this._value <= min ? "disabled" : ""}>&#8722;</button>
641
+ <input data-testid="qty-input" type="number" value="${this._value}" min="${min}" max="${max}" aria-label="Quantity">
642
+ <button data-testid="qty-increment" aria-label="Increase quantity" ${this._value >= max ? "disabled" : ""}>&#43;</button>
643
+ `;
644
+ this.querySelector('[data-testid="qty-decrement"]')?.addEventListener("click", () => {
645
+ if (this._value > min) {
646
+ this._value--;
647
+ this.emitChange();
648
+ this.render();
649
+ }
650
+ });
651
+ this.querySelector('[data-testid="qty-increment"]')?.addEventListener("click", () => {
652
+ if (this._value < max) {
653
+ this._value++;
654
+ this.emitChange();
655
+ this.render();
656
+ }
657
+ });
658
+ const input = this.querySelector("input");
659
+ input?.addEventListener("change", () => {
660
+ const val = Math.min(max, Math.max(min, Number(input.value) || min));
661
+ this._value = val;
662
+ this.emitChange();
663
+ this.render();
664
+ });
665
+ }
666
+ attributeChangedCallback(name) {
667
+ if (name === "value") {
668
+ this._value = Number(this.getAttribute("value")) || 1;
669
+ }
670
+ if (this.isConnected) this.requestRender();
671
+ }
672
+ emitChange() {
673
+ this.dispatchEvent(
674
+ new CustomEvent("nymbal:quantity:changed", {
675
+ bubbles: true,
676
+ composed: true,
677
+ detail: { value: this._value }
678
+ })
679
+ );
680
+ }
681
+ };
682
+
683
+ // src/components/search-bar.ts
684
+ var STYLES6 = `
685
+ nymbal-search-bar {
686
+ display: block;
687
+ position: relative;
688
+ font-family: var(--nymbal-font-family);
689
+ }
690
+ nymbal-search-bar input {
691
+ font-family: var(--nymbal-font-family);
692
+ font-size: var(--nymbal-font-size-base);
693
+ width: 100%;
694
+ padding: var(--nymbal-spacing-sm) var(--nymbal-spacing-md);
695
+ border: 1px solid var(--nymbal-color-border);
696
+ border-radius: var(--nymbal-radius-md);
697
+ outline: none;
698
+ transition: border-color var(--nymbal-transition-fast);
699
+ box-sizing: border-box;
700
+ }
701
+ nymbal-search-bar input:focus {
702
+ border-color: var(--nymbal-color-accent);
703
+ }
704
+ nymbal-search-bar .nymbal-search-results {
705
+ position: absolute;
706
+ top: 100%;
707
+ left: 0;
708
+ right: 0;
709
+ background: var(--nymbal-color-surface);
710
+ border: 1px solid var(--nymbal-color-border);
711
+ border-top: none;
712
+ border-radius: 0 0 var(--nymbal-radius-md) var(--nymbal-radius-md);
713
+ box-shadow: var(--nymbal-shadow-md);
714
+ z-index: var(--nymbal-z-dropdown);
715
+ max-height: 300px;
716
+ overflow-y: auto;
717
+ }
718
+ nymbal-search-bar .nymbal-search-result-item {
719
+ padding: var(--nymbal-spacing-sm) var(--nymbal-spacing-md);
720
+ cursor: pointer;
721
+ transition: background var(--nymbal-transition-fast);
722
+ }
723
+ nymbal-search-bar .nymbal-search-result-item:hover,
724
+ nymbal-search-bar .nymbal-search-result-item[aria-selected="true"] {
725
+ background: var(--nymbal-color-secondary, #f4f4f5);
726
+ }
727
+ nymbal-search-bar .nymbal-search-result-name {
728
+ font-weight: var(--nymbal-font-weight-medium);
729
+ }
730
+ nymbal-search-bar .nymbal-search-result-price {
731
+ font-size: var(--nymbal-font-size-sm);
732
+ color: var(--nymbal-color-text-muted);
733
+ }
734
+ nymbal-search-bar .nymbal-search-loading {
735
+ padding: var(--nymbal-spacing-md);
736
+ text-align: center;
737
+ color: var(--nymbal-color-text-muted);
738
+ font-size: var(--nymbal-font-size-sm);
739
+ }
740
+ `;
741
+ var NymbalSearchBar = class extends NymbalElement {
742
+ static get observedAttributes() {
743
+ return ["placeholder", "debounce-ms"];
744
+ }
745
+ debounceTimer = null;
746
+ selectedIndex = -1;
747
+ setup() {
748
+ injectStyles("nymbal-search-bar", STYLES6);
749
+ this.subscribeToStore(this.client.search);
750
+ }
751
+ render() {
752
+ const state = this.client.search.getState();
753
+ const placeholder = this.getAttribute("placeholder") ?? "Search products...";
754
+ let resultsHtml = "";
755
+ if (state.query) {
756
+ if (state.loading) {
757
+ resultsHtml = `<div class="nymbal-search-results" data-testid="search-results"><div class="nymbal-search-loading">Searching...</div></div>`;
758
+ } else if (state.results.length > 0) {
759
+ resultsHtml = `<div class="nymbal-search-results" data-testid="search-results">
760
+ ${state.results.map(
761
+ (product, i) => `
762
+ <div class="nymbal-search-result-item" data-testid="search-result-${this.esc(product.slug)}" data-slug="${this.esc(product.slug)}" aria-selected="${i === this.selectedIndex}">
763
+ <div class="nymbal-search-result-name">${this.esc(product.name)}</div>
764
+ ${product.priceMinor !== null ? `<div class="nymbal-search-result-price">${this.formatPrice(product.priceMinor, product.currency ?? "USD")}</div>` : ""}
765
+ </div>
766
+ `
767
+ ).join("")}
768
+ </div>`;
769
+ }
770
+ }
771
+ this.innerHTML = `
772
+ <input data-testid="search-input" type="search" placeholder="${this.esc(placeholder)}" value="${this.esc(state.query)}" autocomplete="off" role="combobox" aria-expanded="${state.results.length > 0}" aria-haspopup="listbox">
773
+ ${resultsHtml}
774
+ `;
775
+ this.querySelectorAll(".nymbal-search-result-item").forEach((item, i) => {
776
+ item.addEventListener("click", () => {
777
+ const product = state.results[i];
778
+ if (product) this.select(product);
779
+ });
780
+ });
781
+ const input = this.querySelector("input");
782
+ input.addEventListener("input", () => {
783
+ const query = input.value;
784
+ const debounceMs = Number(this.getAttribute("debounce-ms")) || 300;
785
+ if (this.debounceTimer !== null) clearTimeout(this.debounceTimer);
786
+ this.debounceTimer = setTimeout(() => {
787
+ this.selectedIndex = -1;
788
+ if (query.trim()) {
789
+ this.client.search.search(query);
790
+ } else {
791
+ this.client.search.clearSearch();
792
+ }
793
+ }, debounceMs);
794
+ });
795
+ input.addEventListener("keydown", (e) => {
796
+ const results = state.results;
797
+ if (e.key === "ArrowDown") {
798
+ e.preventDefault();
799
+ this.selectedIndex = Math.min(this.selectedIndex + 1, results.length - 1);
800
+ this.render();
801
+ } else if (e.key === "ArrowUp") {
802
+ e.preventDefault();
803
+ this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
804
+ this.render();
805
+ } else if (e.key === "Enter") {
806
+ e.preventDefault();
807
+ const selected = this.selectedIndex >= 0 ? results[this.selectedIndex] : void 0;
808
+ if (selected) {
809
+ this.select(selected);
810
+ } else {
811
+ const query = input.value.trim();
812
+ if (query) {
813
+ window.location.href = `/search?q=${encodeURIComponent(query)}`;
814
+ }
815
+ }
816
+ } else if (e.key === "Escape") {
817
+ this.client.search.clearSearch();
818
+ this.selectedIndex = -1;
819
+ }
820
+ });
821
+ }
822
+ select(product) {
823
+ this.dispatchEvent(
824
+ new CustomEvent("nymbal:search:selected", {
825
+ bubbles: true,
826
+ composed: true,
827
+ detail: { product, query: product.name }
828
+ })
829
+ );
830
+ this.client.search.clearSearch();
831
+ }
832
+ attributeChangedCallback() {
833
+ if (this.isConnected) this.requestRender();
834
+ }
835
+ disconnectedCallback() {
836
+ super.disconnectedCallback();
837
+ if (this.debounceTimer !== null) {
838
+ clearTimeout(this.debounceTimer);
839
+ this.debounceTimer = null;
840
+ }
841
+ }
842
+ /** Escapes a value for both text and quoted-attribute contexts. */
843
+ esc(str) {
844
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
845
+ }
846
+ formatPrice(minor, currency) {
847
+ return new Intl.NumberFormat(void 0, { style: "currency", currency }).format(minor / 100);
848
+ }
849
+ };
850
+
851
+ // src/components/product-filter.ts
852
+ var STYLES7 = `
853
+ nymbal-product-filter {
854
+ display: block;
855
+ font-family: var(--nymbal-font-family);
856
+ }
857
+ nymbal-product-filter .nymbal-filter-group {
858
+ margin-bottom: var(--nymbal-spacing-lg);
859
+ }
860
+ nymbal-product-filter .nymbal-filter-label {
861
+ display: block;
862
+ font-size: var(--nymbal-font-size-sm);
863
+ font-weight: var(--nymbal-font-weight-bold);
864
+ margin-bottom: var(--nymbal-spacing-sm);
865
+ color: var(--nymbal-color-text);
866
+ text-transform: uppercase;
867
+ letter-spacing: 0.05em;
868
+ }
869
+ nymbal-product-filter .nymbal-filter-option {
870
+ display: flex;
871
+ align-items: center;
872
+ gap: var(--nymbal-spacing-sm);
873
+ padding: var(--nymbal-spacing-xs) 0;
874
+ cursor: pointer;
875
+ font-size: var(--nymbal-font-size-sm);
876
+ color: var(--nymbal-color-text);
877
+ }
878
+ nymbal-product-filter .nymbal-filter-option input[type="checkbox"] {
879
+ accent-color: var(--nymbal-color-primary);
880
+ }
881
+ nymbal-product-filter .nymbal-filter-count {
882
+ color: var(--nymbal-color-text-muted);
883
+ font-size: var(--nymbal-font-size-xs);
884
+ }
885
+ `;
886
+ var NymbalProductFilter = class extends NymbalElement {
887
+ static get observedAttributes() {
888
+ return ["facets"];
889
+ }
890
+ setup() {
891
+ injectStyles("nymbal-product-filter", STYLES7);
892
+ this.subscribeToStore(this.client.productList);
893
+ }
894
+ render() {
895
+ const state = this.client.productList.getState();
896
+ const facets = this.getFacets();
897
+ if (facets.length === 0) {
898
+ this.innerHTML = "";
899
+ return;
900
+ }
901
+ this.innerHTML = facets.map((facet) => {
902
+ const activeFilter = state.filters[facet.field];
903
+ const activeValues = Array.isArray(activeFilter) ? activeFilter : activeFilter ? [activeFilter] : [];
904
+ return `
905
+ <div class="nymbal-filter-group" data-testid="filter-group-${facet.field}">
906
+ <span class="nymbal-filter-label">${this.esc(facet.label)}</span>
907
+ ${facet.values.map((v) => {
908
+ const checked = activeValues.includes(v.value);
909
+ return `
910
+ <label class="nymbal-filter-option" data-testid="filter-option-${v.value}">
911
+ <input type="checkbox" data-filter-name="${facet.field}" data-filter-value="${v.value}" ${checked ? "checked" : ""}>
912
+ <span>${this.esc(v.label ?? v.value)}</span>
913
+ ${v.count !== void 0 ? `<span class="nymbal-filter-count">(${v.count})</span>` : ""}
914
+ </label>
915
+ `;
916
+ }).join("")}
917
+ </div>
918
+ `;
919
+ }).join("");
920
+ this.querySelectorAll('input[type="checkbox"]').forEach((input) => {
921
+ input.addEventListener("change", () => {
922
+ const el = input;
923
+ const name = el.dataset["filterName"];
924
+ const value = el.dataset["filterValue"];
925
+ if (el.checked) {
926
+ const current = state.filters[name];
927
+ const values = Array.isArray(current) ? [...current, value] : current ? [current, value] : value;
928
+ this.client.productList.applyFilter(name, values);
929
+ } else {
930
+ const current = state.filters[name];
931
+ if (Array.isArray(current)) {
932
+ const remaining = current.filter((v) => v !== value);
933
+ if (remaining.length > 0) {
934
+ this.client.productList.applyFilter(name, remaining);
935
+ } else {
936
+ this.client.productList.removeFilter(name);
937
+ }
938
+ } else {
939
+ this.client.productList.removeFilter(name);
940
+ }
941
+ }
942
+ this.dispatchEvent(
943
+ new CustomEvent("nymbal:filter:changed", { bubbles: true, composed: true, detail: { name, value } })
944
+ );
945
+ });
946
+ });
947
+ }
948
+ attributeChangedCallback() {
949
+ if (this.isConnected) this.requestRender();
950
+ }
951
+ getFacets() {
952
+ const attr = this.getAttribute("facets");
953
+ if (!attr) return [];
954
+ try {
955
+ return JSON.parse(attr);
956
+ } catch (err) {
957
+ console.error("[nymbal-product-filter] failed to parse facets attribute", err);
958
+ return [];
959
+ }
960
+ }
961
+ esc(str) {
962
+ const div = document.createElement("div");
963
+ div.textContent = str;
964
+ return div.innerHTML;
965
+ }
966
+ };
967
+
968
+ // src/components/toast.ts
969
+ var STYLES8 = `
970
+ nymbal-toast {
971
+ position: fixed;
972
+ z-index: var(--nymbal-z-toast);
973
+ display: flex;
974
+ flex-direction: column;
975
+ gap: var(--nymbal-spacing-sm);
976
+ pointer-events: none;
977
+ font-family: var(--nymbal-font-family);
978
+ }
979
+ nymbal-toast[position="top-right"],
980
+ nymbal-toast:not([position]) {
981
+ top: var(--nymbal-spacing-lg);
982
+ right: var(--nymbal-spacing-lg);
983
+ }
984
+ nymbal-toast[position="top-left"] {
985
+ top: var(--nymbal-spacing-lg);
986
+ left: var(--nymbal-spacing-lg);
987
+ }
988
+ nymbal-toast[position="bottom-right"] {
989
+ bottom: var(--nymbal-spacing-lg);
990
+ right: var(--nymbal-spacing-lg);
991
+ }
992
+ nymbal-toast[position="bottom-left"] {
993
+ bottom: var(--nymbal-spacing-lg);
994
+ left: var(--nymbal-spacing-lg);
995
+ }
996
+ nymbal-toast .nymbal-toast-item {
997
+ pointer-events: auto;
998
+ display: flex;
999
+ align-items: center;
1000
+ gap: var(--nymbal-spacing-sm);
1001
+ padding: var(--nymbal-spacing-sm) var(--nymbal-spacing-md);
1002
+ border-radius: var(--nymbal-radius-md);
1003
+ box-shadow: var(--nymbal-shadow-lg);
1004
+ font-size: var(--nymbal-font-size-sm);
1005
+ background: var(--nymbal-color-surface);
1006
+ color: var(--nymbal-color-text);
1007
+ border: 1px solid var(--nymbal-color-border);
1008
+ animation: nymbal-toast-in var(--nymbal-transition-normal) forwards;
1009
+ max-width: 360px;
1010
+ }
1011
+ nymbal-toast .nymbal-toast-item[data-type="success"] {
1012
+ border-left: 3px solid var(--nymbal-color-success);
1013
+ }
1014
+ nymbal-toast .nymbal-toast-item[data-type="error"] {
1015
+ border-left: 3px solid var(--nymbal-color-error);
1016
+ }
1017
+ nymbal-toast .nymbal-toast-item[data-type="warning"] {
1018
+ border-left: 3px solid var(--nymbal-color-warning);
1019
+ }
1020
+ nymbal-toast .nymbal-toast-item[data-type="info"] {
1021
+ border-left: 3px solid var(--nymbal-color-accent);
1022
+ }
1023
+ nymbal-toast .nymbal-toast-close {
1024
+ background: none;
1025
+ border: none;
1026
+ cursor: pointer;
1027
+ color: var(--nymbal-color-text-muted);
1028
+ font-size: var(--nymbal-font-size-base);
1029
+ padding: 0;
1030
+ margin-left: auto;
1031
+ }
1032
+ @keyframes nymbal-toast-in {
1033
+ from { opacity: 0; transform: translateY(-8px); }
1034
+ to { opacity: 1; transform: translateY(0); }
1035
+ }
1036
+ `;
1037
+ var HTMLElementBase2 = typeof HTMLElement !== "undefined" ? HTMLElement : class {
1038
+ };
1039
+ var NymbalToast = class extends HTMLElementBase2 {
1040
+ static get observedAttributes() {
1041
+ return ["position", "duration-ms"];
1042
+ }
1043
+ connectedCallback() {
1044
+ injectStyles("nymbal-toast", STYLES8);
1045
+ this.setAttribute("data-testid", "toast");
1046
+ }
1047
+ show(message, type = "info") {
1048
+ const durationMs = Number(this.getAttribute("duration-ms")) || 4e3;
1049
+ const item = document.createElement("div");
1050
+ item.className = "nymbal-toast-item";
1051
+ item.setAttribute("data-type", type);
1052
+ item.setAttribute("role", "alert");
1053
+ const text = document.createElement("span");
1054
+ text.textContent = message;
1055
+ const close = document.createElement("button");
1056
+ close.className = "nymbal-toast-close";
1057
+ close.setAttribute("aria-label", "Dismiss");
1058
+ close.textContent = "\xD7";
1059
+ close.addEventListener("click", () => this.dismiss(item));
1060
+ item.appendChild(text);
1061
+ item.appendChild(close);
1062
+ this.appendChild(item);
1063
+ setTimeout(() => this.dismiss(item), durationMs);
1064
+ }
1065
+ dismiss(item) {
1066
+ if (item.parentNode === this) {
1067
+ this.removeChild(item);
1068
+ }
1069
+ }
1070
+ static showGlobal(message, type = "info") {
1071
+ let toastEl = document.querySelector("nymbal-toast");
1072
+ if (!toastEl) {
1073
+ toastEl = document.createElement("nymbal-toast");
1074
+ document.body.appendChild(toastEl);
1075
+ }
1076
+ toastEl.show(message, type);
1077
+ }
1078
+ };
1079
+
1080
+ // src/index.ts
1081
+ function registerComponents() {
1082
+ const components = [
1083
+ ["nymbal-add-to-cart", NymbalAddToCart],
1084
+ ["nymbal-cart-drawer", NymbalCartDrawer],
1085
+ ["nymbal-mini-cart", NymbalMiniCart],
1086
+ ["nymbal-variant-selector", NymbalVariantSelector],
1087
+ ["nymbal-quantity-selector", NymbalQuantitySelector],
1088
+ ["nymbal-search-bar", NymbalSearchBar],
1089
+ ["nymbal-product-filter", NymbalProductFilter],
1090
+ ["nymbal-toast", NymbalToast]
1091
+ ];
1092
+ for (const [name, ctor] of components) {
1093
+ if (!customElements.get(name)) {
1094
+ customElements.define(name, ctor);
1095
+ }
1096
+ }
1097
+ }
1098
+ export {
1099
+ NymbalAddToCart,
1100
+ NymbalCartDrawer,
1101
+ NymbalElement,
1102
+ NymbalMiniCart,
1103
+ NymbalProductFilter,
1104
+ NymbalQuantitySelector,
1105
+ NymbalSearchBar,
1106
+ NymbalToast,
1107
+ NymbalVariantSelector,
1108
+ getClient,
1109
+ injectStyles,
1110
+ registerClient,
1111
+ registerComponents
1112
+ };
1113
+ //# sourceMappingURL=index.js.map