ai-progress-controls 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +823 -0
  3. package/dist/ai-progress-controls.es.js +7191 -0
  4. package/dist/ai-progress-controls.es.js.map +1 -0
  5. package/dist/ai-progress-controls.umd.js +2 -0
  6. package/dist/ai-progress-controls.umd.js.map +1 -0
  7. package/dist/index.d.ts +2212 -0
  8. package/package.json +105 -0
  9. package/src/__tests__/setup.ts +93 -0
  10. package/src/core/base/AIControl.ts +230 -0
  11. package/src/core/base/index.ts +3 -0
  12. package/src/core/base/types.ts +77 -0
  13. package/src/core/base/utils.ts +168 -0
  14. package/src/core/batch-progress/BatchProgress.test.ts +458 -0
  15. package/src/core/batch-progress/BatchProgress.ts +760 -0
  16. package/src/core/batch-progress/index.ts +14 -0
  17. package/src/core/batch-progress/styles.ts +480 -0
  18. package/src/core/batch-progress/types.ts +169 -0
  19. package/src/core/model-loader/ModelLoader.test.ts +311 -0
  20. package/src/core/model-loader/ModelLoader.ts +673 -0
  21. package/src/core/model-loader/index.ts +2 -0
  22. package/src/core/model-loader/styles.ts +496 -0
  23. package/src/core/model-loader/types.ts +127 -0
  24. package/src/core/parameter-panel/ParameterPanel.test.ts +856 -0
  25. package/src/core/parameter-panel/ParameterPanel.ts +877 -0
  26. package/src/core/parameter-panel/index.ts +14 -0
  27. package/src/core/parameter-panel/styles.ts +323 -0
  28. package/src/core/parameter-panel/types.ts +278 -0
  29. package/src/core/parameter-slider/ParameterSlider.test.ts +299 -0
  30. package/src/core/parameter-slider/ParameterSlider.ts +653 -0
  31. package/src/core/parameter-slider/index.ts +8 -0
  32. package/src/core/parameter-slider/styles.ts +493 -0
  33. package/src/core/parameter-slider/types.ts +107 -0
  34. package/src/core/queue-progress/QueueProgress.test.ts +344 -0
  35. package/src/core/queue-progress/QueueProgress.ts +563 -0
  36. package/src/core/queue-progress/index.ts +5 -0
  37. package/src/core/queue-progress/styles.ts +469 -0
  38. package/src/core/queue-progress/types.ts +130 -0
  39. package/src/core/retry-progress/RetryProgress.test.ts +397 -0
  40. package/src/core/retry-progress/RetryProgress.ts +957 -0
  41. package/src/core/retry-progress/index.ts +6 -0
  42. package/src/core/retry-progress/styles.ts +530 -0
  43. package/src/core/retry-progress/types.ts +176 -0
  44. package/src/core/stream-progress/StreamProgress.test.ts +531 -0
  45. package/src/core/stream-progress/StreamProgress.ts +517 -0
  46. package/src/core/stream-progress/index.ts +2 -0
  47. package/src/core/stream-progress/styles.ts +349 -0
  48. package/src/core/stream-progress/types.ts +82 -0
  49. package/src/index.ts +19 -0
@@ -0,0 +1,957 @@
1
+ import { AIControl } from '../base/AIControl';
2
+ import { formatTime } from '../base/utils';
3
+ import type {
4
+ RetryProgressConfig,
5
+ RetryProgressState,
6
+ RetryAttemptUpdate,
7
+ RetryStatus,
8
+ } from './types';
9
+ import { styles } from './styles';
10
+
11
+ /**
12
+ * RetryProgress Component
13
+ *
14
+ * Displays retry progress with exponential backoff, attempt tracking, and error handling.
15
+ * Perfect for handling transient failures in API calls, network requests, or AI operations.
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * // Create the component
20
+ * const retry = new RetryProgress({
21
+ * maxAttempts: 5,
22
+ * initialDelay: 1000,
23
+ * strategy: 'exponential',
24
+ * allowManualRetry: true,
25
+ * });
26
+ *
27
+ * document.body.appendChild(retry);
28
+ *
29
+ * // Start first attempt
30
+ * retry.attempt('Connecting to API...');
31
+ *
32
+ * // If it fails, start waiting for retry
33
+ * retry.waitForRetry({
34
+ * attempt: 2,
35
+ * error: new Error('Connection timeout'),
36
+ * });
37
+ *
38
+ * // On success
39
+ * retry.success('Connected successfully!');
40
+ *
41
+ * // Listen to events
42
+ * retry.addEventListener('retryattempt', (e) => {
43
+ * console.log('Attempting:', e.detail);
44
+ * });
45
+ * ```
46
+ *
47
+ * @fires retryattempt - Fired when a retry attempt starts
48
+ * @fires retrywaiting - Fired when waiting for next retry
49
+ * @fires retrysuccess - Fired when operation succeeds
50
+ * @fires retryfailure - Fired when max attempts reached
51
+ * @fires retrycancel - Fired when retry is cancelled
52
+ * @fires manualretry - Fired when user manually triggers retry
53
+ */
54
+ export class RetryProgress extends AIControl {
55
+ protected override config: Required<RetryProgressConfig>;
56
+ private state: RetryProgressState;
57
+ private waitTimer?: ReturnType<typeof setTimeout>;
58
+ private elapsedTimer?: ReturnType<typeof setInterval>;
59
+ private progressTimer?: ReturnType<typeof setInterval>;
60
+
61
+ constructor(config: RetryProgressConfig = {}) {
62
+ super(config);
63
+
64
+ this.config = {
65
+ attempt: config.attempt ?? 1,
66
+ maxAttempts: config.maxAttempts ?? 3,
67
+ initialDelay: config.initialDelay ?? 1000,
68
+ maxDelay: config.maxDelay ?? 30000,
69
+ backoffMultiplier: config.backoffMultiplier ?? 2,
70
+ strategy: config.strategy ?? 'exponential',
71
+ message: config.message ?? 'Retrying operation...',
72
+ showAttemptCount: config.showAttemptCount ?? true,
73
+ showNextRetry: config.showNextRetry ?? true,
74
+ showProgressBar: config.showProgressBar ?? true,
75
+ showElapsedTime: config.showElapsedTime ?? true,
76
+ allowManualRetry: config.allowManualRetry ?? false,
77
+ allowCancel: config.allowCancel ?? true,
78
+ animate: config.animate ?? true,
79
+ className: config.className ?? '',
80
+ ariaLabel: config.ariaLabel ?? 'Retry Progress',
81
+ cursorFeedback: config.cursorFeedback ?? true,
82
+ debug: config.debug ?? false,
83
+ disabled: config.disabled ?? false,
84
+ size: config.size ?? 'default',
85
+ variant: config.variant ?? 'default',
86
+ animation: config.animation ?? 'none',
87
+ };
88
+
89
+ this.state = {
90
+ status: 'idle',
91
+ attempt: this.config.attempt,
92
+ maxAttempts: this.config.maxAttempts,
93
+ currentDelay: this.config.initialDelay,
94
+ nextRetryTime: 0,
95
+ startTime: 0,
96
+ elapsedTime: 0,
97
+ message: this.config.message,
98
+ };
99
+
100
+ this.attachShadow({ mode: 'open' });
101
+ }
102
+
103
+ /**
104
+ * Read max-attempts attribute
105
+ */
106
+ private _readMaxAttemptsAttribute(): void {
107
+ if (!this.hasAttribute('max-attempts')) return;
108
+
109
+ const val = Number.parseInt(this.getAttribute('max-attempts') || '', 10);
110
+ if (!Number.isNaN(val)) {
111
+ this.config.maxAttempts = val;
112
+ this.state.maxAttempts = val;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Read initial-delay attribute
118
+ */
119
+ private _readInitialDelayAttribute(): void {
120
+ if (!this.hasAttribute('initial-delay')) return;
121
+
122
+ const val = Number.parseInt(this.getAttribute('initial-delay') || '', 10);
123
+ if (!Number.isNaN(val)) {
124
+ this.config.initialDelay = val;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Read max-delay attribute
130
+ */
131
+ private _readMaxDelayAttribute(): void {
132
+ if (!this.hasAttribute('max-delay')) return;
133
+
134
+ const val = Number.parseInt(this.getAttribute('max-delay') || '', 10);
135
+ if (!Number.isNaN(val)) {
136
+ this.config.maxDelay = val;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Read backoff-multiplier attribute
142
+ */
143
+ private _readBackoffMultiplierAttribute(): void {
144
+ if (!this.hasAttribute('backoff-multiplier')) return;
145
+
146
+ const val = Number.parseFloat(this.getAttribute('backoff-multiplier') || '');
147
+ if (!Number.isNaN(val)) {
148
+ this.config.backoffMultiplier = val;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Read strategy attribute
154
+ */
155
+ private _readStrategyAttribute(): void {
156
+ if (!this.hasAttribute('strategy')) return;
157
+
158
+ const strategy = this.getAttribute('strategy') as RetryStatus;
159
+ if (['exponential', 'linear', 'fixed', 'fibonacci'].includes(strategy)) {
160
+ this.config.strategy = strategy as any;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Read boolean attributes
166
+ */
167
+ private _readBooleanAttributes(): void {
168
+ if (this.hasAttribute('allow-manual-retry')) {
169
+ this.config.allowManualRetry = this.getAttribute('allow-manual-retry') === 'true';
170
+ }
171
+ if (this.hasAttribute('allow-cancel')) {
172
+ this.config.allowCancel = this.getAttribute('allow-cancel') === 'true';
173
+ }
174
+ if (this.hasAttribute('show-attempt-count')) {
175
+ this.config.showAttemptCount = this.getAttribute('show-attempt-count') === 'true';
176
+ }
177
+ if (this.hasAttribute('show-progress-bar')) {
178
+ this.config.showProgressBar = this.getAttribute('show-progress-bar') === 'true';
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Connected callback - read initial attributes
184
+ */
185
+ override connectedCallback(): void {
186
+ super.connectedCallback();
187
+
188
+ this._readMaxAttemptsAttribute();
189
+ this._readInitialDelayAttribute();
190
+ this._readMaxDelayAttribute();
191
+ this._readBackoffMultiplierAttribute();
192
+ this._readStrategyAttribute();
193
+ this._readBooleanAttributes();
194
+
195
+ this.render();
196
+ }
197
+
198
+ /**
199
+ * Calculate delay for retry attempt based on strategy
200
+ */
201
+ private calculateDelay(attempt: number): number {
202
+ const { strategy, initialDelay, backoffMultiplier, maxDelay } = this.config;
203
+
204
+ let delay: number;
205
+
206
+ switch (strategy) {
207
+ case 'exponential':
208
+ delay = initialDelay * Math.pow(backoffMultiplier, attempt - 1);
209
+ break;
210
+
211
+ case 'linear':
212
+ delay = initialDelay * attempt;
213
+ break;
214
+
215
+ case 'fibonacci':
216
+ delay = initialDelay * this.fibonacci(attempt);
217
+ break;
218
+
219
+ case 'fixed':
220
+ default:
221
+ delay = initialDelay;
222
+ break;
223
+ }
224
+
225
+ return Math.min(delay, maxDelay);
226
+ }
227
+
228
+ /**
229
+ * Calculate fibonacci number
230
+ */
231
+ private fibonacci(n: number): number {
232
+ if (n <= 1) return 1;
233
+ let a = 1,
234
+ b = 1;
235
+ for (let i = 2; i < n; i++) {
236
+ [a, b] = [b, a + b];
237
+ }
238
+ return b;
239
+ }
240
+
241
+ /**
242
+ * Start a retry attempt
243
+ */
244
+ public attempt(message?: string): void {
245
+ this.stopTimers();
246
+
247
+ const attemptMessage = message || `Attempt ${this.state.attempt} of ${this.state.maxAttempts}`;
248
+
249
+ this.setState({
250
+ status: 'attempting',
251
+ message: attemptMessage,
252
+ startTime: this.state.startTime || Date.now(),
253
+ });
254
+
255
+ this.startElapsedTimer();
256
+
257
+ this.dispatchEvent(
258
+ new CustomEvent('retryattempt', {
259
+ detail: {
260
+ attempt: this.state.attempt,
261
+ maxAttempts: this.state.maxAttempts,
262
+ message: attemptMessage,
263
+ timestamp: Date.now(),
264
+ },
265
+ })
266
+ );
267
+
268
+ this.log(`Retry attempt ${this.state.attempt}/${this.state.maxAttempts}: ${attemptMessage}`);
269
+ }
270
+
271
+ /**
272
+ * Wait for next retry with optional error
273
+ */
274
+ public waitForRetry(update: RetryAttemptUpdate = {}): void {
275
+ const nextAttempt = update.attempt ?? this.state.attempt + 1;
276
+
277
+ if (nextAttempt > this.state.maxAttempts) {
278
+ this.failure(update.error);
279
+ return;
280
+ }
281
+
282
+ const delay = update.delay ?? this.calculateDelay(nextAttempt);
283
+ const nextRetryTime = Date.now() + delay;
284
+
285
+ this.setState({
286
+ status: 'waiting',
287
+ attempt: nextAttempt,
288
+ currentDelay: delay,
289
+ nextRetryTime,
290
+ message: update.message || `Retrying in ${formatTime(Math.ceil(delay / 1000))}...`,
291
+ errorMessage: update.error?.message,
292
+ lastError: update.error,
293
+ });
294
+
295
+ this.startWaitTimer(delay);
296
+ this.startProgressTimer(delay);
297
+
298
+ this.dispatchEvent(
299
+ new CustomEvent('retrywaiting', {
300
+ detail: {
301
+ attempt: nextAttempt,
302
+ delay,
303
+ nextRetryTime,
304
+ strategy: this.config.strategy,
305
+ timestamp: Date.now(),
306
+ },
307
+ })
308
+ );
309
+
310
+ this.log(`Waiting ${delay}ms before retry ${nextAttempt}/${this.state.maxAttempts}`);
311
+ }
312
+
313
+ /**
314
+ * Mark operation as successful
315
+ */
316
+ public success(message?: string): void {
317
+ this.stopTimers();
318
+
319
+ const successMessage = message || 'Operation successful!';
320
+
321
+ this.setState({
322
+ status: 'success',
323
+ message: successMessage,
324
+ });
325
+
326
+ this.dispatchEvent(
327
+ new CustomEvent('retrysuccess', {
328
+ detail: {
329
+ attempt: this.state.attempt,
330
+ totalAttempts: this.state.attempt,
331
+ elapsedTime: this.state.elapsedTime,
332
+ message: successMessage,
333
+ timestamp: Date.now(),
334
+ },
335
+ })
336
+ );
337
+
338
+ this.log(`Success after ${this.state.attempt} attempts (${this.state.elapsedTime}ms)`);
339
+ }
340
+
341
+ /**
342
+ * Mark operation as failed (max attempts reached)
343
+ */
344
+ public failure(error?: Error): void {
345
+ this.stopTimers();
346
+
347
+ this.setState({
348
+ status: 'failed',
349
+ message: 'Maximum retry attempts reached',
350
+ errorMessage: error?.message || 'Operation failed',
351
+ lastError: error,
352
+ });
353
+
354
+ this.dispatchEvent(
355
+ new CustomEvent('retryfailure', {
356
+ detail: {
357
+ totalAttempts: this.state.attempt,
358
+ lastError: error,
359
+ elapsedTime: this.state.elapsedTime,
360
+ timestamp: Date.now(),
361
+ },
362
+ })
363
+ );
364
+
365
+ this.logError('Retry failed', error || new Error('Maximum attempts reached'));
366
+ }
367
+
368
+ /**
369
+ * Cancel retry operation
370
+ */
371
+ public cancel(reason?: string): void {
372
+ this.stopTimers();
373
+
374
+ this.setState({
375
+ status: 'cancelled',
376
+ message: reason || 'Operation cancelled',
377
+ });
378
+
379
+ this.dispatchEvent(
380
+ new CustomEvent('retrycancel', {
381
+ detail: {
382
+ attempt: this.state.attempt,
383
+ reason,
384
+ timestamp: Date.now(),
385
+ },
386
+ })
387
+ );
388
+
389
+ this.log(`Retry cancelled: ${reason || 'User cancelled'}`);
390
+ }
391
+
392
+ /**
393
+ * Reset to initial state
394
+ */
395
+ public reset(): void {
396
+ this.stopTimers();
397
+
398
+ this.state = {
399
+ status: 'idle',
400
+ attempt: this.config.attempt,
401
+ maxAttempts: this.config.maxAttempts,
402
+ currentDelay: this.config.initialDelay,
403
+ nextRetryTime: 0,
404
+ startTime: 0,
405
+ elapsedTime: 0,
406
+ message: this.config.message,
407
+ };
408
+
409
+ this.render();
410
+ this.log('Reset to initial state');
411
+ }
412
+
413
+ /**
414
+ * Get current attempt number
415
+ */
416
+ public getAttempt(): number {
417
+ return this.state.attempt;
418
+ }
419
+
420
+ /**
421
+ * Get current status
422
+ */
423
+ public getStatus(): RetryStatus {
424
+ return this.state.status;
425
+ }
426
+
427
+ /**
428
+ * Get time until next retry (ms)
429
+ */
430
+ public getTimeUntilRetry(): number {
431
+ if (this.state.status !== 'waiting') return 0;
432
+ return Math.max(0, this.state.nextRetryTime - Date.now());
433
+ }
434
+
435
+ /**
436
+ * Start elapsed time timer
437
+ */
438
+ private startElapsedTimer(): void {
439
+ this.elapsedTimer = globalThis.setInterval(() => {
440
+ if (this.state.startTime > 0) {
441
+ this.state.elapsedTime = Date.now() - this.state.startTime;
442
+ this.render();
443
+ }
444
+ }, 1000);
445
+ }
446
+
447
+ /**
448
+ * Start wait timer for automatic retry
449
+ */
450
+ private startWaitTimer(delay: number): void {
451
+ this.waitTimer = globalThis.setTimeout(() => {
452
+ if (this.state.status === 'waiting') {
453
+ this.attempt();
454
+ }
455
+ }, delay);
456
+ }
457
+
458
+ /**
459
+ * Start progress bar timer
460
+ */
461
+ private startProgressTimer(_totalDelay: number): void {
462
+ this.progressTimer = globalThis.setInterval(() => {
463
+ if (this.state.status === 'waiting') {
464
+ const remaining = Math.max(0, this.state.nextRetryTime - Date.now());
465
+ this.state.message = `Retrying in ${formatTime(Math.ceil(remaining / 1000))}...`;
466
+ this.render();
467
+ }
468
+ }, 100);
469
+ }
470
+
471
+ /**
472
+ * Stop all timers
473
+ */
474
+ private stopTimers(): void {
475
+ if (this.waitTimer) {
476
+ globalThis.clearTimeout(this.waitTimer);
477
+ this.waitTimer = undefined;
478
+ }
479
+ if (this.elapsedTimer) {
480
+ globalThis.clearInterval(this.elapsedTimer);
481
+ this.elapsedTimer = undefined;
482
+ }
483
+ if (this.progressTimer) {
484
+ globalThis.clearInterval(this.progressTimer);
485
+ this.progressTimer = undefined;
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Handle manual retry button click
491
+ */
492
+ private handleManualRetry(): void {
493
+ if (this.state.status !== 'waiting' && this.state.status !== 'failed') return;
494
+
495
+ this.dispatchEvent(
496
+ new CustomEvent('manualretry', {
497
+ detail: {
498
+ attempt: this.state.attempt,
499
+ timestamp: Date.now(),
500
+ },
501
+ })
502
+ );
503
+
504
+ this.attempt();
505
+ }
506
+
507
+ /**
508
+ * Handle cancel button click
509
+ */
510
+ private handleCancel(): void {
511
+ this.cancel('User cancelled operation');
512
+ }
513
+
514
+ /**
515
+ * Update state and re-render
516
+ */
517
+ private setState(update: Partial<RetryProgressState>): void {
518
+ Object.assign(this.state, update);
519
+ this.render();
520
+ this.updateCursor();
521
+
522
+ // Update ARIA attributes
523
+ const progress = ((this.state.attempt / this.state.maxAttempts) * 100).toFixed(0);
524
+ this.setAttribute('aria-valuenow', this.state.attempt.toString());
525
+ this.setAttribute('aria-valuemax', this.state.maxAttempts.toString());
526
+ this.setAttribute(
527
+ 'aria-valuetext',
528
+ `Attempt ${this.state.attempt} of ${this.state.maxAttempts}, ${progress}% complete`
529
+ );
530
+ }
531
+
532
+ /**
533
+ * Update cursor based on retry state
534
+ */
535
+ private updateCursor(): void {
536
+ if (!this.config.cursorFeedback) return;
537
+
538
+ if (this.state.status === 'attempting') {
539
+ this.style.cursor = 'progress';
540
+ } else if (this.state.status === 'waiting') {
541
+ this.style.cursor = 'wait';
542
+ } else if (this.state.status === 'failed' || this.state.status === 'cancelled') {
543
+ this.style.cursor = 'not-allowed';
544
+ } else {
545
+ this.style.cursor = 'default';
546
+ }
547
+ }
548
+
549
+ /**
550
+ * Get status icon
551
+ */
552
+ private getStatusIcon(): string {
553
+ switch (this.state.status) {
554
+ case 'attempting':
555
+ return '🔄';
556
+ case 'waiting':
557
+ return '⏳';
558
+ case 'success':
559
+ return '✅';
560
+ case 'failed':
561
+ return '❌';
562
+ case 'cancelled':
563
+ return '🚫';
564
+ default:
565
+ return '⏸️';
566
+ }
567
+ }
568
+
569
+ /**
570
+ * Get status text
571
+ */
572
+ private getStatusText(): string {
573
+ switch (this.state.status) {
574
+ case 'attempting':
575
+ return 'Attempting';
576
+ case 'waiting':
577
+ return 'Waiting';
578
+ case 'success':
579
+ return 'Success';
580
+ case 'failed':
581
+ return 'Failed';
582
+ case 'cancelled':
583
+ return 'Cancelled';
584
+ default:
585
+ return 'Idle';
586
+ }
587
+ }
588
+
589
+ /**
590
+ * Calculate progress percentage
591
+ */
592
+ private getProgressPercentage(): number {
593
+ if (this.state.status !== 'waiting') return 0;
594
+
595
+ const elapsed = Date.now() - (this.state.nextRetryTime - this.state.currentDelay);
596
+ return Math.min(100, (elapsed / this.state.currentDelay) * 100);
597
+ }
598
+
599
+ /**
600
+ * Render success message
601
+ */
602
+ private renderSuccessMessage(status: RetryStatus, attempt: number): string {
603
+ if (status !== 'success') return '';
604
+
605
+ const attemptText = attempt > 1 ? 's' : '';
606
+ return `
607
+ <div class="success-message">
608
+ <div class="success-icon">🎉</div>
609
+ <div class="success-text">Success!</div>
610
+ <div class="success-details">Completed in ${attempt} attempt${attemptText}</div>
611
+ </div>
612
+ `;
613
+ }
614
+
615
+ /**
616
+ * Render action buttons
617
+ */
618
+ private renderActions(
619
+ status: RetryStatus,
620
+ allowManualRetry: boolean,
621
+ allowCancel: boolean,
622
+ disabled: boolean
623
+ ): string {
624
+ if (status === 'success' || (!allowManualRetry && !allowCancel)) return '';
625
+
626
+ const showRetryButton = allowManualRetry && (status === 'waiting' || status === 'failed');
627
+ const showCancelButton = allowCancel && status !== 'cancelled' && status !== 'failed';
628
+
629
+ if (!showRetryButton && !showCancelButton) return '';
630
+
631
+ const retryButtonText = status === 'failed' ? 'Try Again' : 'Retry Now';
632
+ const disabledAttr = disabled ? 'disabled' : '';
633
+
634
+ let buttonsHtml = '';
635
+ if (showRetryButton) {
636
+ buttonsHtml += `
637
+ <button class="retry-button" id="manual-retry" ${disabledAttr}>
638
+ ${retryButtonText}
639
+ </button>
640
+ `;
641
+ }
642
+ if (showCancelButton) {
643
+ buttonsHtml += `
644
+ <button class="cancel-button" id="cancel-btn" ${disabledAttr}>
645
+ Cancel
646
+ </button>
647
+ `;
648
+ }
649
+
650
+ return `
651
+ <div class="actions">
652
+ ${buttonsHtml}
653
+ </div>
654
+ `;
655
+ }
656
+
657
+ /**
658
+ * Sync config attributes to host element
659
+ */
660
+ private _syncAttributes(): void {
661
+ if (this.config.size && this.getAttribute('size') !== this.config.size) {
662
+ this.setAttribute('size', this.config.size);
663
+ }
664
+ if (this.config.variant && this.getAttribute('variant') !== this.config.variant) {
665
+ this.setAttribute('variant', this.config.variant);
666
+ }
667
+ if (this.config.animation && this.getAttribute('animation') !== this.config.animation) {
668
+ this.setAttribute('animation', this.config.animation);
669
+ }
670
+ }
671
+
672
+ /**
673
+ * Get attempt counter HTML
674
+ */
675
+ private _getAttemptCounterHtml(
676
+ showAttemptCount: boolean,
677
+ attempt: number,
678
+ maxAttempts: number,
679
+ status: RetryStatus
680
+ ): string {
681
+ if (!showAttemptCount) {
682
+ return '';
683
+ }
684
+ return `
685
+ <div class="attempt-counter">
686
+ <div class="attempt-number ${status}">${attempt}</div>
687
+ <div class="attempt-label">of ${maxAttempts} attempts</div>
688
+ </div>
689
+ `;
690
+ }
691
+
692
+ /**
693
+ * Get metrics grid HTML
694
+ */
695
+ private _getMetricsGridHtml(
696
+ showNextRetry: boolean,
697
+ showElapsedTime: boolean,
698
+ status: RetryStatus,
699
+ remainingTime: number,
700
+ elapsedTime: number,
701
+ strategy: string
702
+ ): string {
703
+ const nextRetryHtml =
704
+ showNextRetry && status === 'waiting'
705
+ ? `
706
+ <div class="metric">
707
+ <div class="metric-value">${formatTime(Math.ceil(remainingTime / 1000))}</div>
708
+ <div class="metric-label">Next Retry</div>
709
+ </div>
710
+ `
711
+ : '';
712
+
713
+ const elapsedHtml =
714
+ showElapsedTime && elapsedTime > 0
715
+ ? `
716
+ <div class="metric">
717
+ <div class="metric-value">${formatTime(Math.ceil(elapsedTime / 1000))}</div>
718
+ <div class="metric-label">Elapsed</div>
719
+ </div>
720
+ `
721
+ : '';
722
+
723
+ return `
724
+ <div class="metrics-grid">
725
+ ${nextRetryHtml}
726
+ ${elapsedHtml}
727
+ <div class="metric">
728
+ <div class="metric-value">${strategy}</div>
729
+ <div class="metric-label">Strategy</div>
730
+ </div>
731
+ </div>
732
+ `;
733
+ }
734
+
735
+ /**
736
+ * Get progress bar HTML
737
+ */
738
+ private _getProgressBarHtml(
739
+ showProgressBar: boolean,
740
+ status: RetryStatus,
741
+ progressPercentage: number
742
+ ): string {
743
+ if (!showProgressBar || status !== 'waiting') {
744
+ return '';
745
+ }
746
+ return `
747
+ <div class="progress-bar-container">
748
+ <div class="progress-label">Time until next attempt</div>
749
+ <div class="progress-bar">
750
+ <div class="progress-fill" style="width: ${progressPercentage}%"></div>
751
+ </div>
752
+ </div>
753
+ `;
754
+ }
755
+
756
+ /**
757
+ * Get error display HTML
758
+ */
759
+ private _getErrorDisplayHtml(errorMessage: string | undefined, status: RetryStatus): string {
760
+ if (!errorMessage || (status !== 'waiting' && status !== 'failed')) {
761
+ return '';
762
+ }
763
+ return `
764
+ <div class="error-display">
765
+ <div class="error-title">Last Error</div>
766
+ <div class="error-message">${errorMessage}</div>
767
+ </div>
768
+ `;
769
+ }
770
+
771
+ /**
772
+ * Attach event listeners to rendered elements
773
+ */
774
+ private _attachEventListeners(allowManualRetry: boolean, allowCancel: boolean): void {
775
+ if (allowManualRetry) {
776
+ const retryBtn = this.shadowRoot?.getElementById('manual-retry');
777
+ retryBtn?.addEventListener('click', () => this.handleManualRetry());
778
+ }
779
+
780
+ if (allowCancel) {
781
+ const cancelBtn = this.shadowRoot?.getElementById('cancel-btn');
782
+ cancelBtn?.addEventListener('click', () => this.handleCancel());
783
+ }
784
+ }
785
+
786
+ /**
787
+ * Render component
788
+ */
789
+ protected override render(): void {
790
+ if (!this.shadowRoot) return;
791
+
792
+ this._syncAttributes();
793
+
794
+ const { status, attempt, maxAttempts, message, errorMessage, elapsedTime } = this.state;
795
+ const {
796
+ showAttemptCount,
797
+ showNextRetry,
798
+ showProgressBar,
799
+ showElapsedTime,
800
+ allowManualRetry,
801
+ allowCancel,
802
+ disabled,
803
+ } = this.config;
804
+
805
+ const remainingTime = this.getTimeUntilRetry();
806
+ const progressPercentage = this.getProgressPercentage();
807
+
808
+ const attemptCounterHtml = this._getAttemptCounterHtml(
809
+ showAttemptCount,
810
+ attempt,
811
+ maxAttempts,
812
+ status
813
+ );
814
+ const metricsGridHtml = this._getMetricsGridHtml(
815
+ showNextRetry,
816
+ showElapsedTime,
817
+ status,
818
+ remainingTime,
819
+ elapsedTime,
820
+ this.config.strategy
821
+ );
822
+ const progressBarHtml = this._getProgressBarHtml(showProgressBar, status, progressPercentage);
823
+ const errorDisplayHtml = this._getErrorDisplayHtml(errorMessage, status);
824
+ const successMessageHtml = this.renderSuccessMessage(status, attempt);
825
+ const actionsHtml = this.renderActions(status, allowManualRetry, allowCancel, disabled);
826
+
827
+ this.shadowRoot.innerHTML = `
828
+ <style>${styles}</style>
829
+ <div class="retry-container ${disabled ? 'disabled' : ''}" role="status">
830
+ <div class="retry-header">
831
+ <div class="retry-icon ${status}">${this.getStatusIcon()}</div>
832
+ <div class="retry-info">
833
+ <h3 class="retry-title">${this.getStatusText()}</h3>
834
+ <p class="retry-message">${message}</p>
835
+ </div>
836
+ <span class="status-badge ${status}">${this.getStatusText()}</span>
837
+ </div>
838
+
839
+ ${attemptCounterHtml}
840
+ ${metricsGridHtml}
841
+ ${progressBarHtml}
842
+ ${errorDisplayHtml}
843
+ ${successMessageHtml}
844
+ ${actionsHtml}
845
+ </div>
846
+ `;
847
+
848
+ this._attachEventListeners(allowManualRetry, allowCancel);
849
+ }
850
+
851
+ /**
852
+ * Cleanup on disconnect
853
+ */
854
+ override disconnectedCallback(): void {
855
+ super.disconnectedCallback();
856
+ this.stopTimers();
857
+ }
858
+
859
+ /**
860
+ * Observed attributes
861
+ */
862
+ static get observedAttributes(): string[] {
863
+ return [
864
+ 'attempt',
865
+ 'max-attempts',
866
+ 'initial-delay',
867
+ 'max-delay',
868
+ 'backoff-multiplier',
869
+ 'strategy',
870
+ 'allow-manual-retry',
871
+ 'allow-cancel',
872
+ 'show-attempt-count',
873
+ 'show-progress-bar',
874
+ 'disabled',
875
+ 'size',
876
+ 'variant',
877
+ 'animation',
878
+ ];
879
+ }
880
+
881
+ /**
882
+ * Handle attribute changes
883
+ */
884
+ protected override handleAttributeChange(
885
+ name: string,
886
+ _oldValue: string,
887
+ newValue: string
888
+ ): void {
889
+ switch (name) {
890
+ case 'attempt': {
891
+ this.state.attempt = Number.parseInt(newValue, 10) || 1;
892
+ break;
893
+ }
894
+ case 'max-attempts': {
895
+ const maxAttempts = Number.parseInt(newValue, 10) || 3;
896
+ this.state.maxAttempts = maxAttempts;
897
+ this.config.maxAttempts = maxAttempts;
898
+ break;
899
+ }
900
+ case 'initial-delay': {
901
+ const initialDelay = Number.parseInt(newValue, 10);
902
+ if (Number.isNaN(initialDelay) === false) this.config.initialDelay = initialDelay;
903
+ break;
904
+ }
905
+ case 'max-delay': {
906
+ const maxDelay = Number.parseInt(newValue, 10);
907
+ if (Number.isNaN(maxDelay) === false) this.config.maxDelay = maxDelay;
908
+ break;
909
+ }
910
+ case 'backoff-multiplier': {
911
+ const multiplier = Number.parseFloat(newValue);
912
+ if (Number.isNaN(multiplier) === false) this.config.backoffMultiplier = multiplier;
913
+ break;
914
+ }
915
+ case 'strategy': {
916
+ if (['exponential', 'linear', 'fixed', 'fibonacci'].includes(newValue)) {
917
+ this.config.strategy = newValue as any;
918
+ }
919
+ break;
920
+ }
921
+ case 'allow-manual-retry': {
922
+ this.config.allowManualRetry = newValue === 'true';
923
+ break;
924
+ }
925
+ case 'allow-cancel': {
926
+ this.config.allowCancel = newValue === 'true';
927
+ break;
928
+ }
929
+ case 'show-attempt-count':
930
+ this.config.showAttemptCount = newValue === 'true';
931
+ break;
932
+ case 'show-progress-bar':
933
+ this.config.showProgressBar = newValue === 'true';
934
+ break;
935
+ case 'variant':
936
+ this.config.variant = newValue as any;
937
+ this.render();
938
+ break;
939
+ case 'animation':
940
+ this.config.animation = newValue as any;
941
+ this.render();
942
+ break;
943
+ case 'disabled':
944
+ this._disabled = newValue !== null;
945
+ break;
946
+ case 'size':
947
+ this.config.size = newValue as any;
948
+ break;
949
+ }
950
+ this.render();
951
+ }
952
+ }
953
+
954
+ // Register custom element
955
+ if (!customElements.get('retry-progress')) {
956
+ customElements.define('retry-progress', RetryProgress);
957
+ }