ngx-opalbytes-components 1.28.0 → 1.30.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 (43) hide show
  1. package/README.md +298 -0
  2. package/cli/cao-comp.cjs +10 -10
  3. package/fesm2022/ngx-opalbytes-components.mjs +394 -6
  4. package/fesm2022/ngx-opalbytes-components.mjs.map +1 -1
  5. package/package.json +1 -1
  6. package/src/lib/shared/components/avatar/avatar.constants.ts +17 -0
  7. package/src/lib/shared/components/avatar/avatar.css +44 -0
  8. package/src/lib/shared/components/avatar/avatar.html +27 -0
  9. package/src/lib/shared/components/avatar/avatar.model.ts +3 -0
  10. package/src/lib/shared/components/avatar/avatar.spec.ts +88 -0
  11. package/src/lib/shared/components/avatar/avatar.stories.ts +85 -0
  12. package/src/lib/shared/components/avatar/avatar.ts +83 -0
  13. package/src/lib/shared/components/avatar/index.ts +3 -0
  14. package/src/lib/shared/components/breadcrumb/breadcrumb.css +105 -0
  15. package/src/lib/shared/components/breadcrumb/breadcrumb.html +108 -0
  16. package/src/lib/shared/components/breadcrumb/breadcrumb.model.ts +54 -0
  17. package/src/lib/shared/components/breadcrumb/breadcrumb.spec.ts +93 -0
  18. package/src/lib/shared/components/breadcrumb/breadcrumb.stories.ts +122 -0
  19. package/src/lib/shared/components/breadcrumb/breadcrumb.ts +72 -0
  20. package/src/lib/shared/components/breadcrumb/index.ts +2 -0
  21. package/src/lib/shared/components/index.ts +5 -1
  22. package/src/lib/shared/components/popover/index.ts +3 -0
  23. package/src/lib/shared/components/popover/popover.css +149 -0
  24. package/src/lib/shared/components/popover/popover.html +44 -0
  25. package/src/lib/shared/components/popover/popover.model.ts +19 -0
  26. package/src/lib/shared/components/popover/popover.spec.ts +22 -0
  27. package/src/lib/shared/components/popover/popover.stories.ts +0 -0
  28. package/src/lib/shared/components/popover/popover.ts +312 -0
  29. package/src/lib/shared/components/progress-bar/index.ts +2 -0
  30. package/src/lib/shared/components/progress-bar/progress-bar.css +141 -0
  31. package/src/lib/shared/components/progress-bar/progress-bar.html +39 -0
  32. package/src/lib/shared/components/progress-bar/progress-bar.model.ts +8 -0
  33. package/src/lib/shared/components/progress-bar/progress-bar.spec.ts +94 -0
  34. package/src/lib/shared/components/progress-bar/progress-bar.stories.ts +53 -0
  35. package/src/lib/shared/components/progress-bar/progress-bar.ts +50 -0
  36. package/src/lib/shared/components/progress-spinner/index.ts +2 -0
  37. package/src/lib/shared/components/progress-spinner/progress-spinner.css +90 -0
  38. package/src/lib/shared/components/progress-spinner/progress-spinner.html +50 -0
  39. package/src/lib/shared/components/progress-spinner/progress-spinner.model.ts +8 -0
  40. package/src/lib/shared/components/progress-spinner/progress-spinner.spec.ts +85 -0
  41. package/src/lib/shared/components/progress-spinner/progress-spinner.stories.ts +73 -0
  42. package/src/lib/shared/components/progress-spinner/progress-spinner.ts +71 -0
  43. package/types/ngx-opalbytes-components.d.ts +262 -3
@@ -0,0 +1,312 @@
1
+ import { ConnectedOverlayPositionChange, Overlay, OverlayRef, ConnectedPosition } from '@angular/cdk/overlay';
2
+ import { TemplatePortal } from '@angular/cdk/portal';
3
+ import { CommonModule } from '@angular/common';
4
+ import {
5
+ ChangeDetectionStrategy,
6
+ Component,
7
+ DestroyRef,
8
+ ElementRef,
9
+ TemplateRef,
10
+ ViewContainerRef,
11
+ booleanAttribute,
12
+ inject,
13
+ input,
14
+ numberAttribute,
15
+ output,
16
+ signal,
17
+ viewChild,
18
+ } from '@angular/core';
19
+
20
+ import { Subscription } from 'rxjs';
21
+
22
+ import { CaoPopoverAlign, CaoPopoverPosition, CaoPopoverTarget, ICaoPopoverShowOptions } from './popover.model';
23
+
24
+ export const CAO_POPOVER_POSITION_PREFIX = 'cao-popover-pos-';
25
+
26
+ const OPPOSITE: Record<CaoPopoverPosition, CaoPopoverPosition> = {
27
+ top: 'bottom',
28
+ bottom: 'top',
29
+ left: 'right',
30
+ right: 'left',
31
+ };
32
+
33
+ const PERPENDICULAR: Record<CaoPopoverPosition, CaoPopoverPosition[]> = {
34
+ top: ['right', 'left'],
35
+ bottom: ['right', 'left'],
36
+ left: ['bottom', 'top'],
37
+ right: ['bottom', 'top'],
38
+ };
39
+
40
+ function toConnectedPosition(position: CaoPopoverPosition, align: CaoPopoverAlign, offset: number): ConnectedPosition {
41
+ const panelClass = `${CAO_POPOVER_POSITION_PREFIX}${position}`;
42
+
43
+ if (position === 'top' || position === 'bottom') {
44
+ return {
45
+ originX: align,
46
+ originY: position === 'bottom' ? 'bottom' : 'top',
47
+ overlayX: align,
48
+ overlayY: position === 'bottom' ? 'top' : 'bottom',
49
+ offsetY: position === 'bottom' ? offset : -offset,
50
+ panelClass,
51
+ };
52
+ }
53
+
54
+ const vertical = align === 'start' ? 'top' : align === 'end' ? 'bottom' : 'center';
55
+ return {
56
+ originX: position === 'right' ? 'end' : 'start',
57
+ originY: vertical,
58
+ overlayX: position === 'right' ? 'start' : 'end',
59
+ overlayY: vertical,
60
+ offsetX: position === 'right' ? offset : -offset,
61
+ panelClass,
62
+ };
63
+ }
64
+
65
+ /** Posição preferida seguida das alternativas (oposta e perpendiculares) usadas quando não há espaço. */
66
+ export function buildPopoverPositions(
67
+ position: CaoPopoverPosition,
68
+ align: CaoPopoverAlign,
69
+ offset: number,
70
+ ): ConnectedPosition[] {
71
+ const order = [position, OPPOSITE[position], ...PERPENDICULAR[position]];
72
+ return order.map((item) => toConnectedPosition(item, align, offset));
73
+ }
74
+
75
+ const FOCUSABLE_SELECTOR =
76
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
77
+
78
+ let nextId = 0;
79
+
80
+ @Component({
81
+ selector: 'cao-popover',
82
+ templateUrl: './popover.html',
83
+ imports: [CommonModule],
84
+ styleUrl: './popover.css',
85
+ changeDetection: ChangeDetectionStrategy.OnPush,
86
+ exportAs: 'caoPopover',
87
+ })
88
+ export class CaoPopoverComponent {
89
+ private readonly overlay = inject(Overlay);
90
+ private readonly viewContainerRef = inject(ViewContainerRef);
91
+ private readonly destroyRef = inject(DestroyRef);
92
+ private readonly panelTemplate = viewChild.required<TemplateRef<unknown>>('panelTemplate');
93
+
94
+ private overlayRef: OverlayRef | null = null;
95
+ private origin: HTMLElement | null = null;
96
+ private subscriptions = new Subscription();
97
+ private focusMoved = false;
98
+ private readonly openState = signal(false);
99
+
100
+ protected readonly panelId = `cao-popover-${nextId++}`;
101
+ protected readonly placement = signal<CaoPopoverPosition>('bottom');
102
+
103
+ /** Lado do elemento de origem em que o popover aparece. Se não houver espaço, o CDK escolhe outro lado. */
104
+ readonly position = input<CaoPopoverPosition>('bottom');
105
+ /** Alinhamento em relação ao elemento de origem. */
106
+ readonly align = input<CaoPopoverAlign>('center');
107
+ /** Distância (px) entre a origem e o popover. */
108
+ readonly offset = input(10, { transform: numberAttribute });
109
+ /** Exibe a seta apontando para a origem. */
110
+ readonly showArrow = input(true, { transform: booleanAttribute });
111
+ /** Fecha ao clicar fora do popover. */
112
+ readonly dismissable = input(true, { transform: booleanAttribute });
113
+ /** Fecha ao pressionar `Esc`. */
114
+ readonly closeOnEscape = input(true, { transform: booleanAttribute });
115
+ /** Exibe o botão de fechar no canto superior direito. */
116
+ readonly showCloseIcon = input(false, { transform: booleanAttribute });
117
+ /** Move o foco para o primeiro elemento focável do popover ao abrir (e devolve à origem ao fechar). */
118
+ readonly focusOnShow = input(true, { transform: booleanAttribute });
119
+
120
+ /** Largura fixa do popover (ex.: `'320px'`). */
121
+ readonly width = input<string>('');
122
+ /** Largura máxima. */
123
+ readonly maxWidth = input<string>('360px');
124
+ readonly bgColor = input<string>('');
125
+ readonly textColor = input<string>('');
126
+ readonly borderColor = input<string>('');
127
+ readonly borderRadius = input<string>('');
128
+ readonly padding = input<string>('');
129
+ /** Classe(s) CSS adicionais aplicadas ao painel do popover. */
130
+ readonly styleClass = input<string>('');
131
+ /** Classe(s) CSS adicionais aplicadas ao painel do overlay (CDK). */
132
+ readonly panelClass = input<string | string[]>('');
133
+
134
+ readonly role = input<string>('dialog');
135
+ readonly ariaLabel = input<string>('');
136
+ readonly ariaLabelledBy = input<string>('');
137
+ readonly closeAriaLabel = input<string>('Fechar');
138
+ readonly dataCy = input<string>('');
139
+
140
+ /** Emitido após o popover ser aberto. */
141
+ readonly shown = output<void>();
142
+ /** Emitido após o popover ser fechado. */
143
+ readonly hidden = output<void>();
144
+ /** Emitido quando o ponteiro entra/sai do painel (usado pelo gatilho `hover`). */
145
+ readonly panelHoverChange = output<boolean>();
146
+
147
+ /** `true` enquanto o popover está aberto. */
148
+ readonly isOpen = this.openState.asReadonly();
149
+
150
+ constructor() {
151
+ this.destroyRef.onDestroy(() => this.dispose());
152
+ }
153
+
154
+ /** Abre o popover se estiver fechado (ou aberto em outra origem); fecha se já estiver aberto na mesma origem. */
155
+ toggle(event?: Event | null, target?: CaoPopoverTarget, options: ICaoPopoverShowOptions = {}): void {
156
+ const origin = this.resolveOrigin(event, target);
157
+ if (!origin) {
158
+ return;
159
+ }
160
+ if (this.openState() && this.origin === origin) {
161
+ this.hide();
162
+ return;
163
+ }
164
+ this.show(event, origin, options);
165
+ }
166
+
167
+ /** Abre o popover ancorado em `target` (ou em `event.currentTarget`). */
168
+ show(event?: Event | null, target?: CaoPopoverTarget, options: ICaoPopoverShowOptions = {}): void {
169
+ const origin = this.resolveOrigin(event, target);
170
+ if (!origin) {
171
+ return;
172
+ }
173
+ if (this.openState()) {
174
+ if (this.origin === origin) {
175
+ return;
176
+ }
177
+ this.close(false);
178
+ }
179
+
180
+ this.origin = origin;
181
+ this.placement.set(this.position());
182
+
183
+ const positionStrategy = this.overlay
184
+ .position()
185
+ .flexibleConnectedTo(origin)
186
+ .withPositions(buildPopoverPositions(this.position(), this.align(), this.offset()))
187
+ .withFlexibleDimensions(false)
188
+ .withPush(false)
189
+ .withViewportMargin(8);
190
+
191
+ const overlayRef = this.overlay.create({
192
+ positionStrategy,
193
+ scrollStrategy: this.overlay.scrollStrategies.reposition(),
194
+ panelClass: this.panelClass(),
195
+ });
196
+ this.overlayRef = overlayRef;
197
+
198
+ this.subscriptions.add(positionStrategy.positionChanges.subscribe((change) => this.onPositionChange(change)));
199
+ this.subscriptions.add(
200
+ overlayRef.outsidePointerEvents().subscribe((event) => {
201
+ if (this.dismissable() && !this.origin?.contains(event.target as Node)) {
202
+ this.close(false);
203
+ }
204
+ }),
205
+ );
206
+ this.subscriptions.add(
207
+ overlayRef.keydownEvents().subscribe((event) => {
208
+ if (event.key === 'Escape' && this.closeOnEscape()) {
209
+ event.preventDefault();
210
+ this.close(true);
211
+ }
212
+ }),
213
+ );
214
+
215
+ overlayRef.attach(new TemplatePortal(this.panelTemplate(), this.viewContainerRef));
216
+ this.openState.set(true);
217
+ this.setOriginAria(origin, true);
218
+
219
+ if (options.focus ?? this.focusOnShow()) {
220
+ this.focusPanel(overlayRef.overlayElement);
221
+ }
222
+ this.shown.emit();
223
+ }
224
+
225
+ /** Fecha o popover. */
226
+ hide(): void {
227
+ this.close(true);
228
+ }
229
+
230
+ /** Recalcula a posição (útil se a origem mudou de lugar ou o conteúdo mudou de tamanho). */
231
+ reposition(): void {
232
+ this.overlayRef?.updatePosition();
233
+ }
234
+
235
+ /** Indica se o nó está dentro do painel do popover aberto. */
236
+ containsNode(node: Node | null | undefined): boolean {
237
+ return !!node && !!this.overlayRef?.overlayElement.contains(node);
238
+ }
239
+
240
+ protected onCloseClick(): void {
241
+ this.close(true);
242
+ }
243
+
244
+ private close(restoreFocus: boolean): void {
245
+ const overlayRef = this.overlayRef;
246
+ if (!overlayRef) {
247
+ return;
248
+ }
249
+ const origin = this.origin;
250
+ const shouldRestoreFocus = restoreFocus && this.focusMoved;
251
+
252
+ this.overlayRef = null;
253
+ this.origin = null;
254
+ this.focusMoved = false;
255
+ this.subscriptions.unsubscribe();
256
+ this.subscriptions = new Subscription();
257
+ overlayRef.dispose();
258
+
259
+ this.openState.set(false);
260
+ this.setOriginAria(origin, false);
261
+ if (shouldRestoreFocus) {
262
+ origin?.focus({ preventScroll: true });
263
+ }
264
+ this.hidden.emit();
265
+ }
266
+
267
+ private dispose(): void {
268
+ this.subscriptions.unsubscribe();
269
+ this.overlayRef?.dispose();
270
+ this.overlayRef = null;
271
+ this.origin = null;
272
+ }
273
+
274
+ private onPositionChange(change: ConnectedOverlayPositionChange): void {
275
+ const panelClass = change.connectionPair.panelClass;
276
+ const value = Array.isArray(panelClass) ? panelClass[0] : panelClass;
277
+ if (typeof value === 'string' && value.startsWith(CAO_POPOVER_POSITION_PREFIX)) {
278
+ this.placement.set(value.slice(CAO_POPOVER_POSITION_PREFIX.length) as CaoPopoverPosition);
279
+ }
280
+ }
281
+
282
+ private resolveOrigin(event?: Event | null, target?: CaoPopoverTarget): HTMLElement | null {
283
+ if (target) {
284
+ return target instanceof ElementRef ? target.nativeElement : target;
285
+ }
286
+ return (event?.currentTarget ?? event?.target ?? null) as HTMLElement | null;
287
+ }
288
+
289
+ private focusPanel(overlayElement: HTMLElement): void {
290
+ const panel = overlayElement.querySelector<HTMLElement>('.cao-popover');
291
+ const target = panel?.querySelector<HTMLElement>(`.cao-popover__content ${FOCUSABLE_SELECTOR}`) ?? panel;
292
+ if (target) {
293
+ target.focus({ preventScroll: true });
294
+ this.focusMoved = true;
295
+ }
296
+ }
297
+
298
+ private setOriginAria(origin: HTMLElement | null, open: boolean): void {
299
+ if (!origin) {
300
+ return;
301
+ }
302
+ origin.setAttribute('aria-expanded', String(open));
303
+ if (open) {
304
+ if (this.role() === 'dialog') {
305
+ origin.setAttribute('aria-haspopup', 'dialog');
306
+ }
307
+ origin.setAttribute('aria-controls', this.panelId);
308
+ } else {
309
+ origin.removeAttribute('aria-controls');
310
+ }
311
+ }
312
+ }
@@ -0,0 +1,2 @@
1
+ export * from './progress-bar';
2
+ export * from './progress-bar.model';
@@ -0,0 +1,141 @@
1
+ :host {
2
+ display: block;
3
+ width: 100%;
4
+ }
5
+
6
+ .cao-progress-bar {
7
+ --_color: var(--cao-progress-bar-color, #4f46e5);
8
+ --_track: var(--cao-progress-bar-track-color, #e5e7eb);
9
+ --_buffer: var(--cao-progress-bar-buffer-color, color-mix(in srgb, var(--_color) 35%, transparent));
10
+ --_duration: var(--cao-progress-bar-duration, 2s);
11
+ --_text: var(--cao-progress-bar-text-color, #374151);
12
+
13
+ display: flex;
14
+ align-items: center;
15
+ gap: 8px;
16
+ }
17
+
18
+ .cao-progress-bar__track {
19
+ position: relative;
20
+ flex: 1 1 auto;
21
+ min-width: 0;
22
+ overflow: hidden;
23
+ border-radius: var(--cao-progress-bar-radius, 999px);
24
+ background-color: var(--_track);
25
+ }
26
+
27
+ .cao-progress-bar__fill,
28
+ .cao-progress-bar__buffer {
29
+ position: absolute;
30
+ top: 0;
31
+ bottom: 0;
32
+ left: 0;
33
+ border-radius: inherit;
34
+ transition: width 250ms ease;
35
+ }
36
+
37
+ .cao-progress-bar__fill {
38
+ z-index: 1;
39
+ background-color: var(--_color);
40
+ }
41
+
42
+ .cao-progress-bar__buffer {
43
+ background-color: var(--_buffer);
44
+ }
45
+
46
+ .cao-progress-bar__dots {
47
+ position: absolute;
48
+ top: 0;
49
+ right: 0;
50
+ bottom: 0;
51
+ background-image: radial-gradient(circle closest-side, var(--_buffer) 0 70%, transparent 75%);
52
+ background-size: 8px 100%;
53
+ animation: cao-progress-bar-dots 600ms linear infinite;
54
+ }
55
+
56
+ .cao-progress-bar__inner {
57
+ position: absolute;
58
+ inset: 0;
59
+ }
60
+
61
+ .cao-progress-bar__inner--query {
62
+ transform: scaleX(-1);
63
+ }
64
+
65
+ .cao-progress-bar__bar {
66
+ position: absolute;
67
+ top: 0;
68
+ bottom: 0;
69
+ left: 0;
70
+ width: 100%;
71
+ background-color: var(--_color);
72
+ transform-origin: left center;
73
+ will-change: transform;
74
+ }
75
+
76
+ .cao-progress-bar__bar--primary {
77
+ animation: cao-progress-bar-primary var(--_duration) cubic-bezier(0.4, 0, 0.2, 1) infinite;
78
+ }
79
+
80
+ .cao-progress-bar__bar--secondary {
81
+ animation: cao-progress-bar-secondary var(--_duration) cubic-bezier(0.4, 0, 0.2, 1) infinite;
82
+ }
83
+
84
+ .cao-progress-bar__value {
85
+ flex: 0 0 auto;
86
+ min-width: 2.75em;
87
+ color: var(--_text);
88
+ font-size: 0.75rem;
89
+ font-variant-numeric: tabular-nums;
90
+ line-height: 1;
91
+ text-align: right;
92
+ }
93
+
94
+ @keyframes cao-progress-bar-primary {
95
+ 0% {
96
+ transform: translateX(-100%) scaleX(0.2);
97
+ }
98
+ 40% {
99
+ transform: translateX(10%) scaleX(0.6);
100
+ }
101
+ 100% {
102
+ transform: translateX(100%) scaleX(0.2);
103
+ }
104
+ }
105
+
106
+ @keyframes cao-progress-bar-secondary {
107
+ 0% {
108
+ transform: translateX(-100%) scaleX(0.1);
109
+ }
110
+ 50% {
111
+ transform: translateX(-100%) scaleX(0.1);
112
+ }
113
+ 80% {
114
+ transform: translateX(40%) scaleX(0.4);
115
+ }
116
+ 100% {
117
+ transform: translateX(100%) scaleX(0.1);
118
+ }
119
+ }
120
+
121
+ @keyframes cao-progress-bar-dots {
122
+ to {
123
+ background-position-x: -8px;
124
+ }
125
+ }
126
+
127
+ @media (prefers-reduced-motion: reduce) {
128
+ .cao-progress-bar__fill,
129
+ .cao-progress-bar__buffer {
130
+ transition: none;
131
+ }
132
+
133
+ .cao-progress-bar__bar--primary,
134
+ .cao-progress-bar__bar--secondary {
135
+ animation-duration: calc(var(--_duration) * 3);
136
+ }
137
+
138
+ .cao-progress-bar__dots {
139
+ animation: none;
140
+ }
141
+ }
@@ -0,0 +1,39 @@
1
+ <div
2
+ class="cao-progress-bar"
3
+ [style.--cao-progress-bar-color]="color() || null"
4
+ [style.--cao-progress-bar-track-color]="trackColor() || null"
5
+ [style.--cao-progress-bar-buffer-color]="bufferColor() || null"
6
+ [style.--cao-progress-bar-duration]="animationDuration() || null">
7
+ <div
8
+ class="cao-progress-bar__track"
9
+ role="progressbar"
10
+ aria-valuemin="0"
11
+ aria-valuemax="100"
12
+ [attr.aria-valuenow]="ariaValueNow()"
13
+ [attr.aria-label]="ariaLabel() || null"
14
+ [attr.aria-labelledby]="ariaLabelledBy() || null"
15
+ [attr.data-cy]="dataCy() || null"
16
+ [style.height]="height()"
17
+ [style.border-radius]="borderRadius() || null">
18
+ @switch (mode()) {
19
+ @case ('determinate') {
20
+ <div class="cao-progress-bar__fill" [style.width.%]="clampedValue()"></div>
21
+ }
22
+ @case ('buffer') {
23
+ <div class="cao-progress-bar__dots" [style.left.%]="clampedBuffer()"></div>
24
+ <div class="cao-progress-bar__buffer" [style.width.%]="clampedBuffer()"></div>
25
+ <div class="cao-progress-bar__fill" [style.width.%]="clampedValue()"></div>
26
+ }
27
+ @default {
28
+ <div class="cao-progress-bar__inner" [class.cao-progress-bar__inner--query]="mode() === 'query'">
29
+ <div class="cao-progress-bar__bar cao-progress-bar__bar--primary"></div>
30
+ <div class="cao-progress-bar__bar cao-progress-bar__bar--secondary"></div>
31
+ </div>
32
+ }
33
+ }
34
+ </div>
35
+
36
+ @if (showValue() && hasValue()) {
37
+ <span class="cao-progress-bar__value" aria-hidden="true">{{ valueLabel() }}</span>
38
+ }
39
+ </div>
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Modo da barra de progresso (mesmos modos do Angular Material).
3
+ * - `determinate`: progresso conhecido, controlado por `value`.
4
+ * - `indeterminate`: progresso desconhecido, animação contínua.
5
+ * - `buffer`: `value` + `bufferValue` (ex.: carregamento de mídia).
6
+ * - `query`: indeterminado com animação invertida (ex.: aguardando resposta antes de um determinate).
7
+ */
8
+ export type CaoProgressBarMode = 'determinate' | 'indeterminate' | 'buffer' | 'query';
@@ -0,0 +1,94 @@
1
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
2
+
3
+ import { CaoProgressBarComponent } from './progress-bar';
4
+
5
+ describe('CaoProgressBarComponent', () => {
6
+ let fixture: ComponentFixture<CaoProgressBarComponent>;
7
+ let el: HTMLElement;
8
+
9
+ const track = () => el.querySelector<HTMLElement>('[role="progressbar"]') as HTMLElement;
10
+ const fill = () => el.querySelector<HTMLElement>('.cao-progress-bar__fill') as HTMLElement;
11
+
12
+ beforeEach(async () => {
13
+ await TestBed.configureTestingModule({ imports: [CaoProgressBarComponent] }).compileComponents();
14
+ fixture = TestBed.createComponent(CaoProgressBarComponent);
15
+ el = fixture.nativeElement;
16
+ fixture.detectChanges();
17
+ });
18
+
19
+ it('inicia no modo determinate com valor 0', () => {
20
+ expect(track().getAttribute('aria-valuenow')).toBe('0');
21
+ expect(fill().style.width).toBe('0%');
22
+ });
23
+
24
+ it('aplica o valor e os atributos ARIA', () => {
25
+ fixture.componentRef.setInput('value', 40);
26
+ fixture.detectChanges();
27
+ expect(fill().style.width).toBe('40%');
28
+ expect(track().getAttribute('aria-valuenow')).toBe('40');
29
+ expect(track().getAttribute('aria-valuemin')).toBe('0');
30
+ expect(track().getAttribute('aria-valuemax')).toBe('100');
31
+ });
32
+
33
+ it('limita o valor entre 0 e 100', () => {
34
+ fixture.componentRef.setInput('value', 150);
35
+ fixture.detectChanges();
36
+ expect(fill().style.width).toBe('100%');
37
+
38
+ fixture.componentRef.setInput('value', -10);
39
+ fixture.detectChanges();
40
+ expect(fill().style.width).toBe('0%');
41
+ });
42
+
43
+ it('aceita valor como string (numberAttribute)', () => {
44
+ fixture.componentRef.setInput('value', '25');
45
+ fixture.detectChanges();
46
+ expect(fill().style.width).toBe('25%');
47
+ });
48
+
49
+ it('modo indeterminate não define aria-valuenow e renderiza as duas barras', () => {
50
+ fixture.componentRef.setInput('mode', 'indeterminate');
51
+ fixture.detectChanges();
52
+ expect(track().hasAttribute('aria-valuenow')).toBe(false);
53
+ expect(el.querySelectorAll('.cao-progress-bar__bar').length).toBe(2);
54
+ expect(fill()).toBeNull();
55
+ });
56
+
57
+ it('modo query inverte a animação', () => {
58
+ fixture.componentRef.setInput('mode', 'query');
59
+ fixture.detectChanges();
60
+ expect(el.querySelector('.cao-progress-bar__inner--query')).not.toBeNull();
61
+ });
62
+
63
+ it('modo buffer renderiza buffer, pontos e progresso', () => {
64
+ fixture.componentRef.setInput('mode', 'buffer');
65
+ fixture.componentRef.setInput('value', 30);
66
+ fixture.componentRef.setInput('bufferValue', 60);
67
+ fixture.detectChanges();
68
+ expect(fill().style.width).toBe('30%');
69
+ expect(el.querySelector<HTMLElement>('.cao-progress-bar__buffer')?.style.width).toBe('60%');
70
+ expect(el.querySelector('.cao-progress-bar__dots')).not.toBeNull();
71
+ expect(track().getAttribute('aria-valuenow')).toBe('30');
72
+ });
73
+
74
+ it('exibe o percentual somente em modos com valor', () => {
75
+ fixture.componentRef.setInput('value', 60);
76
+ fixture.componentRef.setInput('showValue', true);
77
+ fixture.detectChanges();
78
+ expect(el.querySelector('.cao-progress-bar__value')?.textContent?.trim()).toBe('60%');
79
+
80
+ fixture.componentRef.setInput('mode', 'indeterminate');
81
+ fixture.detectChanges();
82
+ expect(el.querySelector('.cao-progress-bar__value')).toBeNull();
83
+ });
84
+
85
+ it('aplica aria-label, data-cy e altura', () => {
86
+ fixture.componentRef.setInput('ariaLabel', 'Carregando arquivo');
87
+ fixture.componentRef.setInput('dataCy', 'upload-progress');
88
+ fixture.componentRef.setInput('height', '10px');
89
+ fixture.detectChanges();
90
+ expect(track().getAttribute('aria-label')).toBe('Carregando arquivo');
91
+ expect(track().getAttribute('data-cy')).toBe('upload-progress');
92
+ expect(track().style.height).toBe('10px');
93
+ });
94
+ });
@@ -0,0 +1,53 @@
1
+ import { argsToTemplate, Meta, StoryObj } from '@storybook/angular-vite';
2
+
3
+ import { CaoProgressBarComponent } from './progress-bar';
4
+
5
+ const meta: Meta<CaoProgressBarComponent> = {
6
+ title: 'Componentes/Progress Bar',
7
+ component: CaoProgressBarComponent,
8
+ tags: ['autodocs'],
9
+ render: (args) => ({ props: args, template: `<cao-progress-bar ${argsToTemplate(args)} />` }),
10
+ argTypes: {
11
+ mode: { control: 'inline-radio', options: ['determinate', 'indeterminate', 'buffer', 'query'] },
12
+ value: { control: { type: 'range', min: 0, max: 100, step: 1 } },
13
+ bufferValue: { control: { type: 'range', min: 0, max: 100, step: 1 } },
14
+ color: { control: 'color' },
15
+ trackColor: { control: 'color' },
16
+ bufferColor: { control: 'color' },
17
+ height: { control: 'text' },
18
+ borderRadius: { control: 'text' },
19
+ animationDuration: { control: 'text' },
20
+ showValue: { control: 'boolean' },
21
+ ariaLabel: { control: 'text' },
22
+ },
23
+ args: { mode: 'determinate', value: 40, bufferValue: 70, height: '4px', showValue: false, ariaLabel: 'Progresso' },
24
+ };
25
+ export default meta;
26
+
27
+ type Story = StoryObj<CaoProgressBarComponent>;
28
+
29
+ export const Determinate: Story = {};
30
+
31
+ export const Indeterminate: Story = { args: { mode: 'indeterminate' } };
32
+
33
+ export const Buffer: Story = { args: { mode: 'buffer', value: 30, bufferValue: 65 } };
34
+
35
+ export const Query: Story = { args: { mode: 'query' } };
36
+
37
+ export const WithValue: Story = { args: { value: 72, showValue: true, height: '8px' } };
38
+
39
+ export const CustomColors: Story = {
40
+ args: { value: 60, color: '#16a34a', trackColor: '#dcfce7', height: '10px', showValue: true },
41
+ };
42
+
43
+ export const Heights: Story = {
44
+ render: () => ({
45
+ template: `
46
+ <div style="display:flex;flex-direction:column;gap:16px;max-width:480px">
47
+ <cao-progress-bar [value]="60" height="2px" ariaLabel="Fino" />
48
+ <cao-progress-bar [value]="60" height="6px" ariaLabel="Médio" />
49
+ <cao-progress-bar [value]="60" height="14px" ariaLabel="Grosso" />
50
+ <cao-progress-bar [value]="60" height="14px" borderRadius="2px" ariaLabel="Quadrado" />
51
+ </div>`,
52
+ }),
53
+ };