ngx-opalbytes-components 1.29.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 (27) hide show
  1. package/README.md +138 -0
  2. package/fesm2022/ngx-opalbytes-components.mjs +338 -5
  3. package/fesm2022/ngx-opalbytes-components.mjs.map +1 -1
  4. package/package.json +1 -1
  5. package/src/lib/shared/components/index.ts +4 -1
  6. package/src/lib/shared/components/popover/index.ts +3 -0
  7. package/src/lib/shared/components/popover/popover.css +149 -0
  8. package/src/lib/shared/components/popover/popover.html +44 -0
  9. package/src/lib/shared/components/popover/popover.model.ts +19 -0
  10. package/src/lib/shared/components/popover/popover.spec.ts +22 -0
  11. package/src/lib/shared/components/popover/popover.stories.ts +0 -0
  12. package/src/lib/shared/components/popover/popover.ts +312 -0
  13. package/src/lib/shared/components/progress-bar/index.ts +2 -0
  14. package/src/lib/shared/components/progress-bar/progress-bar.css +141 -0
  15. package/src/lib/shared/components/progress-bar/progress-bar.html +39 -0
  16. package/src/lib/shared/components/progress-bar/progress-bar.model.ts +8 -0
  17. package/src/lib/shared/components/progress-bar/progress-bar.spec.ts +94 -0
  18. package/src/lib/shared/components/progress-bar/progress-bar.stories.ts +53 -0
  19. package/src/lib/shared/components/progress-bar/progress-bar.ts +50 -0
  20. package/src/lib/shared/components/progress-spinner/index.ts +2 -0
  21. package/src/lib/shared/components/progress-spinner/progress-spinner.css +90 -0
  22. package/src/lib/shared/components/progress-spinner/progress-spinner.html +50 -0
  23. package/src/lib/shared/components/progress-spinner/progress-spinner.model.ts +8 -0
  24. package/src/lib/shared/components/progress-spinner/progress-spinner.spec.ts +85 -0
  25. package/src/lib/shared/components/progress-spinner/progress-spinner.stories.ts +73 -0
  26. package/src/lib/shared/components/progress-spinner/progress-spinner.ts +71 -0
  27. package/types/ngx-opalbytes-components.d.ts +181 -2
@@ -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
+ };
@@ -0,0 +1,50 @@
1
+ import { ChangeDetectionStrategy, Component, computed, input, numberAttribute } from '@angular/core';
2
+
3
+ import { CaoProgressBarMode } from './progress-bar.model';
4
+
5
+ const clampPercent = (value: number): number => Math.min(100, Math.max(0, Number.isFinite(value) ? value : 0));
6
+
7
+ @Component({
8
+ selector: 'cao-progress-bar',
9
+ templateUrl: './progress-bar.html',
10
+ styleUrl: './progress-bar.css',
11
+ changeDetection: ChangeDetectionStrategy.OnPush,
12
+ })
13
+ export class CaoProgressBarComponent {
14
+ /** Modo de exibição. Padrão: `'determinate'`. */
15
+ readonly mode = input<CaoProgressBarMode>('determinate');
16
+ /** Progresso de 0 a 100 (modos `determinate` e `buffer`). */
17
+ readonly value = input(0, { transform: numberAttribute });
18
+ /** Valor do buffer de 0 a 100 (modo `buffer`). */
19
+ readonly bufferValue = input(0, { transform: numberAttribute });
20
+
21
+ /** Cor da barra de progresso (qualquer cor CSS). */
22
+ readonly color = input<string>('');
23
+ /** Cor do trilho. */
24
+ readonly trackColor = input<string>('');
25
+ /** Cor do buffer (modo `buffer`). */
26
+ readonly bufferColor = input<string>('');
27
+ /** Altura da barra. */
28
+ readonly height = input<string>('4px');
29
+ /** Raio da borda. Padrão: totalmente arredondada. */
30
+ readonly borderRadius = input<string>('');
31
+ /** Duração do ciclo da animação nos modos indeterminado/query/buffer. */
32
+ readonly animationDuration = input<string>('');
33
+ /** Exibe o percentual ao lado da barra (modos `determinate` e `buffer`). */
34
+ readonly showValue = input<boolean>(false);
35
+
36
+ readonly ariaLabel = input<string>('');
37
+ readonly ariaLabelledBy = input<string>('');
38
+ readonly dataCy = input<string>('');
39
+
40
+ protected readonly clampedValue = computed<number>(() => clampPercent(this.value()));
41
+ protected readonly clampedBuffer = computed<number>(() => clampPercent(this.bufferValue()));
42
+
43
+ protected readonly hasValue = computed<boolean>(() => this.mode() === 'determinate' || this.mode() === 'buffer');
44
+
45
+ protected readonly ariaValueNow = computed<number | null>(() =>
46
+ this.hasValue() ? Math.round(this.clampedValue()) : null,
47
+ );
48
+
49
+ protected readonly valueLabel = computed<string>(() => `${Math.round(this.clampedValue())}%`);
50
+ }
@@ -0,0 +1,2 @@
1
+ export * from './progress-spinner';
2
+ export * from './progress-spinner.model';
@@ -0,0 +1,90 @@
1
+ :host {
2
+ display: inline-block;
3
+ line-height: 0;
4
+ }
5
+
6
+ .cao-progress-spinner {
7
+ --_color: var(--cao-progress-spinner-color, #4f46e5);
8
+ --_track: var(--cao-progress-spinner-track-color, transparent);
9
+ --_duration: var(--cao-progress-spinner-duration, 1.4s);
10
+ --_text: var(--cao-progress-spinner-text-color, #111827);
11
+ --_circ: var(--cao-progress-spinner-circumference, 0px);
12
+
13
+ position: relative;
14
+ display: inline-flex;
15
+ align-items: center;
16
+ justify-content: center;
17
+ }
18
+
19
+ .cao-progress-spinner__svg {
20
+ display: block;
21
+ width: 100%;
22
+ height: 100%;
23
+ }
24
+
25
+ .cao-progress-spinner__track {
26
+ stroke: var(--_track);
27
+ }
28
+
29
+ .cao-progress-spinner__indicator {
30
+ stroke: var(--_color);
31
+ transform: rotate(-90deg);
32
+ transform-origin: 50% 50%;
33
+ transition: stroke-dashoffset 300ms ease;
34
+ }
35
+
36
+ .cao-progress-spinner__value {
37
+ position: absolute;
38
+ inset: 0;
39
+ display: flex;
40
+ align-items: center;
41
+ justify-content: center;
42
+ color: var(--_text);
43
+ font-variant-numeric: tabular-nums;
44
+ font-weight: 600;
45
+ line-height: 1;
46
+ }
47
+
48
+ .cao-progress-spinner--indeterminate .cao-progress-spinner__svg {
49
+ animation: cao-progress-spinner-rotate var(--_duration) linear infinite;
50
+ }
51
+
52
+ .cao-progress-spinner--indeterminate .cao-progress-spinner__indicator {
53
+ animation: cao-progress-spinner-dash calc(var(--_duration) * 1.4) ease-in-out infinite;
54
+ transition: none;
55
+ }
56
+
57
+ @keyframes cao-progress-spinner-rotate {
58
+ to {
59
+ transform: rotate(360deg);
60
+ }
61
+ }
62
+
63
+ @keyframes cao-progress-spinner-dash {
64
+ 0% {
65
+ stroke-dashoffset: calc(var(--_circ) * 0.95);
66
+ transform: rotate(-90deg);
67
+ }
68
+ 50% {
69
+ stroke-dashoffset: calc(var(--_circ) * 0.25);
70
+ transform: rotate(45deg);
71
+ }
72
+ 100% {
73
+ stroke-dashoffset: calc(var(--_circ) * 0.95);
74
+ transform: rotate(270deg);
75
+ }
76
+ }
77
+
78
+ @media (prefers-reduced-motion: reduce) {
79
+ .cao-progress-spinner__indicator {
80
+ transition: none;
81
+ }
82
+
83
+ .cao-progress-spinner--indeterminate .cao-progress-spinner__svg {
84
+ animation-duration: calc(var(--_duration) * 3);
85
+ }
86
+
87
+ .cao-progress-spinner--indeterminate .cao-progress-spinner__indicator {
88
+ animation-duration: calc(var(--_duration) * 4.2);
89
+ }
90
+ }
@@ -0,0 +1,50 @@
1
+ <div
2
+ class="cao-progress-spinner"
3
+ role="progressbar"
4
+ aria-valuemin="0"
5
+ aria-valuemax="100"
6
+ [class.cao-progress-spinner--determinate]="isDeterminate()"
7
+ [class.cao-progress-spinner--indeterminate]="!isDeterminate()"
8
+ [attr.aria-valuenow]="ariaValueNow()"
9
+ [attr.aria-label]="ariaLabel() || null"
10
+ [attr.aria-labelledby]="ariaLabelledBy() || null"
11
+ [attr.data-cy]="dataCy() || null"
12
+ [style.width.px]="size()"
13
+ [style.height.px]="size()"
14
+ [style.--cao-progress-spinner-color]="color() || null"
15
+ [style.--cao-progress-spinner-track-color]="trackColor() || null"
16
+ [style.--cao-progress-spinner-duration]="animationDuration() || null"
17
+ [style.--cao-progress-spinner-text-color]="textColor() || null"
18
+ [style.--cao-progress-spinner-circumference]="circumference() + 'px'">
19
+ <svg
20
+ class="cao-progress-spinner__svg"
21
+ focusable="false"
22
+ aria-hidden="true"
23
+ [attr.width]="size()"
24
+ [attr.height]="size()"
25
+ [attr.viewBox]="'0 0 ' + size() + ' ' + size()">
26
+ <circle
27
+ class="cao-progress-spinner__track"
28
+ fill="none"
29
+ [attr.cx]="center()"
30
+ [attr.cy]="center()"
31
+ [attr.r]="radius()"
32
+ [attr.stroke-width]="stroke()" />
33
+ <circle
34
+ class="cao-progress-spinner__indicator"
35
+ fill="none"
36
+ [attr.cx]="center()"
37
+ [attr.cy]="center()"
38
+ [attr.r]="radius()"
39
+ [attr.stroke-width]="stroke()"
40
+ [attr.stroke-linecap]="strokeLinecap()"
41
+ [attr.stroke-dasharray]="circumference()"
42
+ [attr.stroke-dashoffset]="dashOffset()" />
43
+ </svg>
44
+
45
+ @if (showValue() && isDeterminate()) {
46
+ <span class="cao-progress-spinner__value" aria-hidden="true" [style.font-size.px]="fontSize()">
47
+ {{ valueLabel() }}
48
+ </span>
49
+ }
50
+ </div>
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Modo do spinner de progresso (mesmos modos do Angular Material).
3
+ * - `determinate`: progresso conhecido, controlado por `value`.
4
+ * - `indeterminate`: progresso desconhecido, animação contínua.
5
+ */
6
+ export type CaoProgressSpinnerMode = 'determinate' | 'indeterminate';
7
+
8
+ export type CaoProgressSpinnerLinecap = 'round' | 'butt' | 'square';
@@ -0,0 +1,85 @@
1
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
2
+
3
+ import { CaoProgressSpinnerComponent } from './progress-spinner';
4
+
5
+ describe('CaoProgressSpinnerComponent', () => {
6
+ let fixture: ComponentFixture<CaoProgressSpinnerComponent>;
7
+ let el: HTMLElement;
8
+
9
+ const root = () => el.querySelector<HTMLElement>('[role="progressbar"]') as HTMLElement;
10
+ const indicator = () => el.querySelector<SVGCircleElement>('.cao-progress-spinner__indicator') as SVGCircleElement;
11
+
12
+ beforeEach(async () => {
13
+ await TestBed.configureTestingModule({ imports: [CaoProgressSpinnerComponent] }).compileComponents();
14
+ fixture = TestBed.createComponent(CaoProgressSpinnerComponent);
15
+ el = fixture.nativeElement;
16
+ fixture.detectChanges();
17
+ });
18
+
19
+ it('inicia no modo indeterminate, sem aria-valuenow', () => {
20
+ expect(root().classList).toContain('cao-progress-spinner--indeterminate');
21
+ expect(root().hasAttribute('aria-valuenow')).toBe(false);
22
+ expect(indicator().hasAttribute('stroke-dashoffset')).toBe(false);
23
+ });
24
+
25
+ it('usa diâmetro padrão de 48px', () => {
26
+ expect(root().style.width).toBe('48px');
27
+ expect(el.querySelector('svg')?.getAttribute('viewBox')).toBe('0 0 48 48');
28
+ });
29
+
30
+ it('aplica diâmetro e espessura customizados', () => {
31
+ fixture.componentRef.setInput('diameter', 100);
32
+ fixture.componentRef.setInput('strokeWidth', 10);
33
+ fixture.detectChanges();
34
+ expect(root().style.width).toBe('100px');
35
+ expect(indicator().getAttribute('r')).toBe('45');
36
+ expect(indicator().getAttribute('stroke-width')).toBe('10');
37
+ });
38
+
39
+ it('calcula o stroke-dashoffset no modo determinate', () => {
40
+ fixture.componentRef.setInput('mode', 'determinate');
41
+ fixture.componentRef.setInput('value', 50);
42
+ fixture.detectChanges();
43
+
44
+ const circumference = 2 * Math.PI * ((48 - 4.8) / 2);
45
+ expect(Number(indicator().getAttribute('stroke-dasharray'))).toBeCloseTo(circumference, 2);
46
+ expect(Number(indicator().getAttribute('stroke-dashoffset'))).toBeCloseTo(circumference / 2, 2);
47
+ expect(root().getAttribute('aria-valuenow')).toBe('50');
48
+ });
49
+
50
+ it('limita o valor entre 0 e 100', () => {
51
+ fixture.componentRef.setInput('mode', 'determinate');
52
+ fixture.componentRef.setInput('value', 250);
53
+ fixture.detectChanges();
54
+ expect(root().getAttribute('aria-valuenow')).toBe('100');
55
+ expect(Number(indicator().getAttribute('stroke-dashoffset'))).toBeCloseTo(0, 2);
56
+ });
57
+
58
+ it('aceita value como string (numberAttribute)', () => {
59
+ fixture.componentRef.setInput('mode', 'determinate');
60
+ fixture.componentRef.setInput('value', '25');
61
+ fixture.detectChanges();
62
+ expect(root().getAttribute('aria-valuenow')).toBe('25');
63
+ });
64
+
65
+ it('exibe o percentual apenas no modo determinate', () => {
66
+ fixture.componentRef.setInput('showValue', true);
67
+ fixture.detectChanges();
68
+ expect(el.querySelector('.cao-progress-spinner__value')).toBeNull();
69
+
70
+ fixture.componentRef.setInput('mode', 'determinate');
71
+ fixture.componentRef.setInput('value', 75);
72
+ fixture.detectChanges();
73
+ expect(el.querySelector('.cao-progress-spinner__value')?.textContent?.trim()).toBe('75%');
74
+ });
75
+
76
+ it('aplica aria-label, data-cy e linecap', () => {
77
+ fixture.componentRef.setInput('ariaLabel', 'Carregando dados');
78
+ fixture.componentRef.setInput('dataCy', 'spinner');
79
+ fixture.componentRef.setInput('strokeLinecap', 'butt');
80
+ fixture.detectChanges();
81
+ expect(root().getAttribute('aria-label')).toBe('Carregando dados');
82
+ expect(root().getAttribute('data-cy')).toBe('spinner');
83
+ expect(indicator().getAttribute('stroke-linecap')).toBe('butt');
84
+ });
85
+ });