ngx-opalbytes-components 1.27.0 → 1.29.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/README.md +361 -1
- package/cli/cao-comp.cjs +10 -10
- package/fesm2022/ngx-opalbytes-components.mjs +350 -4
- package/fesm2022/ngx-opalbytes-components.mjs.map +1 -1
- package/package.json +1 -1
- package/src/lib/shared/components/accordion/accordion.component.css +30 -0
- package/src/lib/shared/components/accordion/accordion.component.html +1 -0
- package/src/lib/shared/components/accordion/accordion.component.spec.ts +117 -0
- package/src/lib/shared/components/accordion/accordion.component.ts +195 -0
- package/src/lib/shared/components/accordion/accordion.token.ts +40 -0
- package/src/lib/shared/components/accordion-item/accordion-item.component.css +137 -0
- package/src/lib/shared/components/accordion-item/accordion-item.component.html +52 -0
- package/src/lib/shared/components/accordion-item/accordion-item.component.ts +157 -0
- package/src/lib/shared/components/accordion-item/accordion-item.spec.ts +22 -0
- package/src/lib/shared/components/avatar/avatar.constants.ts +17 -0
- package/src/lib/shared/components/avatar/avatar.css +44 -0
- package/src/lib/shared/components/avatar/avatar.html +27 -0
- package/src/lib/shared/components/avatar/avatar.model.ts +3 -0
- package/src/lib/shared/components/avatar/avatar.spec.ts +88 -0
- package/src/lib/shared/components/avatar/avatar.stories.ts +85 -0
- package/src/lib/shared/components/avatar/avatar.ts +83 -0
- package/src/lib/shared/components/avatar/index.ts +3 -0
- package/src/lib/shared/components/breadcrumb/breadcrumb.css +105 -0
- package/src/lib/shared/components/breadcrumb/breadcrumb.html +108 -0
- package/src/lib/shared/components/breadcrumb/breadcrumb.model.ts +54 -0
- package/src/lib/shared/components/breadcrumb/breadcrumb.spec.ts +93 -0
- package/src/lib/shared/components/breadcrumb/breadcrumb.stories.ts +122 -0
- package/src/lib/shared/components/breadcrumb/breadcrumb.ts +72 -0
- package/src/lib/shared/components/breadcrumb/index.ts +2 -0
- package/src/lib/shared/components/index.ts +5 -1
- package/types/ngx-opalbytes-components.d.ts +354 -129
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectionStrategy,
|
|
3
|
+
Component,
|
|
4
|
+
computed,
|
|
5
|
+
DestroyRef,
|
|
6
|
+
effect,
|
|
7
|
+
HostBinding,
|
|
8
|
+
inject,
|
|
9
|
+
input,
|
|
10
|
+
model,
|
|
11
|
+
output,
|
|
12
|
+
untracked,
|
|
13
|
+
} from '@angular/core';
|
|
14
|
+
|
|
15
|
+
import { CAO_ACCORDION, CaoAccordionAlign, CaoAccordionTogglePosition, ICaoAccordionItemRef } from '../accordion/accordion.token';
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
let nextId = 0;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Item do accordion. Pode ser usado dentro de `<cao-accordion>` ou isolado (como um cartão expansível).
|
|
22
|
+
* Os inputs de cor/posição, quando informados, sobrescrevem os valores definidos no accordion.
|
|
23
|
+
*/
|
|
24
|
+
@Component({
|
|
25
|
+
selector: 'cao-accordion-item',
|
|
26
|
+
templateUrl: './accordion-item.component.html',
|
|
27
|
+
styleUrl: './accordion-item.component.css',
|
|
28
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
29
|
+
})
|
|
30
|
+
export class CaoAccordionItemComponent implements ICaoAccordionItemRef {
|
|
31
|
+
private readonly accordion = inject(CAO_ACCORDION, { optional: true });
|
|
32
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
33
|
+
private previousExpanded: boolean | null = null;
|
|
34
|
+
|
|
35
|
+
protected readonly headerId = `cao-accordion-header-${nextId}`;
|
|
36
|
+
protected readonly panelId = `cao-accordion-panel-${nextId++}`;
|
|
37
|
+
|
|
38
|
+
// ----- Conteúdo -----
|
|
39
|
+
readonly label = input<string>('');
|
|
40
|
+
readonly description = input<string>('');
|
|
41
|
+
/** Nível do heading (aria-level) do cabeçalho. */
|
|
42
|
+
readonly headingLevel = input<number>(3);
|
|
43
|
+
|
|
44
|
+
// ----- Estado -----
|
|
45
|
+
/** Estado de expansão. Suporta two-way binding: `[(expanded)]`. */
|
|
46
|
+
readonly expanded = model<boolean>(false);
|
|
47
|
+
readonly isDisabled = input<boolean>(false);
|
|
48
|
+
/** Exibe ou oculta o item. */
|
|
49
|
+
readonly isVisible = input<boolean>(true);
|
|
50
|
+
readonly dataCy = input<string>('');
|
|
51
|
+
|
|
52
|
+
// ----- Posição (sobrescreve o accordion quando informado) -----
|
|
53
|
+
readonly hideToggle = input<boolean | undefined>(undefined);
|
|
54
|
+
readonly togglePosition = input<CaoAccordionTogglePosition | undefined>(undefined);
|
|
55
|
+
readonly headerAlign = input<CaoAccordionAlign | undefined>(undefined);
|
|
56
|
+
|
|
57
|
+
// ----- Cores (sobrescrevem o accordion quando informadas) -----
|
|
58
|
+
readonly headerBgColor = input<string>('');
|
|
59
|
+
readonly headerTextColor = input<string>('');
|
|
60
|
+
readonly headerActiveBgColor = input<string>('');
|
|
61
|
+
readonly activeColor = input<string>('');
|
|
62
|
+
readonly contentBgColor = input<string>('');
|
|
63
|
+
readonly contentTextColor = input<string>('');
|
|
64
|
+
readonly borderColor = input<string>('');
|
|
65
|
+
readonly iconColor = input<string>('');
|
|
66
|
+
|
|
67
|
+
// ----- Eventos -----
|
|
68
|
+
readonly opened = output<void>();
|
|
69
|
+
readonly closed = output<void>();
|
|
70
|
+
|
|
71
|
+
protected readonly toggleHidden = computed(
|
|
72
|
+
() => this.hideToggle() ?? this.accordion?.hideToggle() ?? false,
|
|
73
|
+
);
|
|
74
|
+
protected readonly togglePos = computed(
|
|
75
|
+
() => this.togglePosition() ?? this.accordion?.togglePosition() ?? 'after',
|
|
76
|
+
);
|
|
77
|
+
private readonly align = computed(() => this.headerAlign() ?? this.accordion?.headerAlign() ?? 'start');
|
|
78
|
+
private readonly isCard = computed(() => (this.accordion?.displayMode() ?? 'separated') === 'separated');
|
|
79
|
+
|
|
80
|
+
protected readonly headerDataCy = computed(() => (this.dataCy() ? `${this.dataCy()}-header` : null));
|
|
81
|
+
protected readonly panelDataCy = computed(() => (this.dataCy() ? `${this.dataCy()}-content` : null));
|
|
82
|
+
|
|
83
|
+
private readonly hostStyle = computed(() => ({
|
|
84
|
+
'--cao-accordion-header-bg': this.headerBgColor() || null,
|
|
85
|
+
'--cao-accordion-header-text': this.headerTextColor() || null,
|
|
86
|
+
'--cao-accordion-header-active-bg': this.headerActiveBgColor() || null,
|
|
87
|
+
'--cao-accordion-active-color': this.activeColor() || null,
|
|
88
|
+
'--cao-accordion-content-bg': this.contentBgColor() || null,
|
|
89
|
+
'--cao-accordion-content-text': this.contentTextColor() || null,
|
|
90
|
+
'--cao-accordion-border-color': this.borderColor() || null,
|
|
91
|
+
'--cao-accordion-icon-color': this.iconColor() || null,
|
|
92
|
+
'--cao-accordion-header-align': this.align(),
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
@HostBinding('style')
|
|
96
|
+
get styleBinding(): Record<string, string | null> {
|
|
97
|
+
return this.hostStyle();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@HostBinding('attr.data-cy')
|
|
101
|
+
get dataCyAttr(): string | null {
|
|
102
|
+
return this.dataCy() || null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
@HostBinding('class.cao-accordion-item--hidden')
|
|
106
|
+
get hiddenClass(): boolean {
|
|
107
|
+
return !this.isVisible();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@HostBinding('class.cao-accordion-item--expanded')
|
|
111
|
+
get expandedClass(): boolean {
|
|
112
|
+
return this.expanded();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
@HostBinding('class.cao-accordion-item--card')
|
|
116
|
+
get cardClass(): boolean {
|
|
117
|
+
return this.isCard();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
constructor() {
|
|
121
|
+
this.accordion?.register(this);
|
|
122
|
+
this.destroyRef.onDestroy(() => this.accordion?.unregister(this));
|
|
123
|
+
|
|
124
|
+
effect(() => {
|
|
125
|
+
const isExpanded = this.expanded();
|
|
126
|
+
untracked(() => this.handleExpandedChange(isExpanded));
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Alterna o estado (ignorado quando o item está desabilitado). */
|
|
131
|
+
toggle(): void {
|
|
132
|
+
if (this.isDisabled()) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
this.expanded.update((value) => !value);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
open(): void {
|
|
139
|
+
this.expanded.set(true);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
close(): void {
|
|
143
|
+
this.expanded.set(false);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private handleExpandedChange(isExpanded: boolean): void {
|
|
147
|
+
if (isExpanded) {
|
|
148
|
+
this.accordion?.notifyOpened(this);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (this.previousExpanded !== null && this.previousExpanded !== isExpanded) {
|
|
152
|
+
const event = isExpanded ? this.opened : this.closed;
|
|
153
|
+
event.emit();
|
|
154
|
+
}
|
|
155
|
+
this.previousExpanded = isExpanded;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
2
|
+
import { CaoAccordionItemComponent } from './accordion-item.component';
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
describe('AccordionItem', () => {
|
|
6
|
+
let component: CaoAccordionItemComponent;
|
|
7
|
+
let fixture: ComponentFixture<CaoAccordionItemComponent>;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
await TestBed.configureTestingModule({
|
|
11
|
+
imports: [CaoAccordionItemComponent],
|
|
12
|
+
}).compileComponents();
|
|
13
|
+
|
|
14
|
+
fixture = TestBed.createComponent(CaoAccordionItemComponent);
|
|
15
|
+
component = fixture.componentInstance;
|
|
16
|
+
await fixture.whenStable();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should create', () => {
|
|
20
|
+
expect(component).toBeTruthy();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const DEFAULT_AVATAR_SVG =
|
|
2
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">' +
|
|
3
|
+
'<rect width="64" height="64" fill="#e5e7eb"/>' +
|
|
4
|
+
'<circle cx="32" cy="25" r="11" fill="#9ca3af"/>' +
|
|
5
|
+
'<path d="M10 64c0-13 10-22 22-22s22 9 22 22z" fill="#9ca3af"/>' +
|
|
6
|
+
'</svg>';
|
|
7
|
+
|
|
8
|
+
/** Imagem padrão (silhueta) usada quando nenhuma fonte é informada ou a imagem falha ao carregar. */
|
|
9
|
+
export const CAO_AVATAR_DEFAULT_SRC = `data:image/svg+xml;utf8,${encodeURIComponent(DEFAULT_AVATAR_SVG)}`;
|
|
10
|
+
|
|
11
|
+
/** Tamanhos predefinidos, em px. */
|
|
12
|
+
export const CAO_AVATAR_SIZES = {
|
|
13
|
+
small: 24,
|
|
14
|
+
normal: 32,
|
|
15
|
+
large: 48,
|
|
16
|
+
xlarge: 64,
|
|
17
|
+
} as const;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
:host {
|
|
2
|
+
display: inline-block;
|
|
3
|
+
line-height: 0;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
.cao-avatar {
|
|
7
|
+
display: inline-flex;
|
|
8
|
+
align-items: center;
|
|
9
|
+
justify-content: center;
|
|
10
|
+
box-sizing: border-box;
|
|
11
|
+
flex-shrink: 0;
|
|
12
|
+
overflow: hidden;
|
|
13
|
+
background-color: var(--cao-avatar-bg, #e5e7eb);
|
|
14
|
+
color: var(--cao-avatar-text, #374151);
|
|
15
|
+
font-weight: 600;
|
|
16
|
+
line-height: 1;
|
|
17
|
+
user-select: none;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.cao-avatar--circle {
|
|
21
|
+
border-radius: 50%;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.cao-avatar--rounded {
|
|
25
|
+
border-radius: var(--cao-avatar-radius, 25%);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.cao-avatar--square {
|
|
29
|
+
border-radius: 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.cao-avatar__image {
|
|
33
|
+
display: block;
|
|
34
|
+
width: 100%;
|
|
35
|
+
height: 100%;
|
|
36
|
+
object-fit: cover;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.cao-avatar__label {
|
|
40
|
+
max-width: 100%;
|
|
41
|
+
overflow: hidden;
|
|
42
|
+
text-overflow: clip;
|
|
43
|
+
white-space: nowrap;
|
|
44
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<div
|
|
2
|
+
class="cao-avatar"
|
|
3
|
+
role="img"
|
|
4
|
+
[class.cao-avatar--circle]="shape() === 'circle'"
|
|
5
|
+
[class.cao-avatar--rounded]="shape() === 'rounded'"
|
|
6
|
+
[class.cao-avatar--square]="shape() === 'square'"
|
|
7
|
+
[attr.aria-label]="accessibleName()"
|
|
8
|
+
[attr.data-cy]="dataCy() || null"
|
|
9
|
+
[style.width.px]="sizePx()"
|
|
10
|
+
[style.height.px]="sizePx()"
|
|
11
|
+
[style.border-width]="strokeWidthCss()"
|
|
12
|
+
[style.border-style]="strokeStyle()"
|
|
13
|
+
[style.border-color]="strokeColor()"
|
|
14
|
+
[style.background-color]="bgColor() || null"
|
|
15
|
+
[style.color]="textColor() || null">
|
|
16
|
+
@switch (mode()) {
|
|
17
|
+
@case ('image') {
|
|
18
|
+
<img class="cao-avatar__image" alt="" aria-hidden="true" [src]="src()" (error)="onImageError()" />
|
|
19
|
+
}
|
|
20
|
+
@case ('label') {
|
|
21
|
+
<span class="cao-avatar__label" [style.font-size.px]="fontSizePx()">{{ label() }}</span>
|
|
22
|
+
}
|
|
23
|
+
@default {
|
|
24
|
+
<img class="cao-avatar__image" alt="" aria-hidden="true" [src]="defaultImage()" />
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
</div>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
2
|
+
|
|
3
|
+
import { CaoAvatarComponent } from './avatar';
|
|
4
|
+
|
|
5
|
+
describe('CaoAvatarComponent', () => {
|
|
6
|
+
let fixture: ComponentFixture<CaoAvatarComponent>;
|
|
7
|
+
let el: HTMLElement;
|
|
8
|
+
|
|
9
|
+
const avatar = () => el.querySelector<HTMLElement>('.cao-avatar') as HTMLElement;
|
|
10
|
+
const image = () => el.querySelector<HTMLImageElement>('img');
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
await TestBed.configureTestingModule({ imports: [CaoAvatarComponent] }).compileComponents();
|
|
14
|
+
fixture = TestBed.createComponent(CaoAvatarComponent);
|
|
15
|
+
el = fixture.nativeElement;
|
|
16
|
+
fixture.detectChanges();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('exibe a imagem padrão quando nada é informado', () => {
|
|
20
|
+
expect(image()).not.toBeNull();
|
|
21
|
+
expect(image()?.getAttribute('src')).toContain('data:image/svg+xml');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('exibe a imagem informada em src', () => {
|
|
25
|
+
fixture.componentRef.setInput('src', 'https://example.com/a.png');
|
|
26
|
+
fixture.detectChanges();
|
|
27
|
+
expect(image()?.getAttribute('src')).toBe('https://example.com/a.png');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('exibe o label quando não há imagem', () => {
|
|
31
|
+
fixture.componentRef.setInput('label', 'MB');
|
|
32
|
+
fixture.detectChanges();
|
|
33
|
+
expect(el.querySelector('.cao-avatar__label')?.textContent?.trim()).toBe('MB');
|
|
34
|
+
expect(image()).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('volta para a imagem padrão quando src falha e não há label/ícone', () => {
|
|
38
|
+
fixture.componentRef.setInput('src', 'https://example.com/quebrada.png');
|
|
39
|
+
fixture.detectChanges();
|
|
40
|
+
image()?.dispatchEvent(new Event('error'));
|
|
41
|
+
fixture.detectChanges();
|
|
42
|
+
expect(image()?.getAttribute('src')).toContain('data:image/svg+xml');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('volta para o label quando src falha', () => {
|
|
46
|
+
fixture.componentRef.setInput('src', 'https://example.com/quebrada.png');
|
|
47
|
+
fixture.componentRef.setInput('label', 'AB');
|
|
48
|
+
fixture.detectChanges();
|
|
49
|
+
image()?.dispatchEvent(new Event('error'));
|
|
50
|
+
fixture.detectChanges();
|
|
51
|
+
expect(el.querySelector('.cao-avatar__label')?.textContent?.trim()).toBe('AB');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('usa defaultSrc customizado', () => {
|
|
55
|
+
fixture.componentRef.setInput('defaultSrc', 'https://example.com/padrao.png');
|
|
56
|
+
fixture.detectChanges();
|
|
57
|
+
expect(image()?.getAttribute('src')).toBe('https://example.com/padrao.png');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('aplica tamanhos predefinidos, numéricos e em rem', () => {
|
|
61
|
+
fixture.componentRef.setInput('size', 'xlarge');
|
|
62
|
+
fixture.detectChanges();
|
|
63
|
+
expect(avatar().style.width).toBe('64px');
|
|
64
|
+
|
|
65
|
+
fixture.componentRef.setInput('size', 100);
|
|
66
|
+
fixture.detectChanges();
|
|
67
|
+
expect(avatar().style.width).toBe('100px');
|
|
68
|
+
|
|
69
|
+
fixture.componentRef.setInput('size', '3rem');
|
|
70
|
+
fixture.detectChanges();
|
|
71
|
+
expect(avatar().style.width).toBe('48px');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('aplica o formato', () => {
|
|
75
|
+
expect(avatar().classList).toContain('cao-avatar--circle');
|
|
76
|
+
fixture.componentRef.setInput('shape', 'square');
|
|
77
|
+
fixture.detectChanges();
|
|
78
|
+
expect(avatar().classList).toContain('cao-avatar--square');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('aplica o stroke', () => {
|
|
82
|
+
fixture.componentRef.setInput('strokeWidth', 3);
|
|
83
|
+
fixture.componentRef.setInput('strokeColor', '#4f46e5');
|
|
84
|
+
fixture.detectChanges();
|
|
85
|
+
expect(avatar().style.borderWidth).toBe('3px');
|
|
86
|
+
expect(avatar().style.borderStyle).toBe('solid');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { LucideUser, provideLucideIcons } from '@lucide/angular';
|
|
2
|
+
import { applicationConfig, argsToTemplate, Meta, StoryObj } from '@storybook/angular-vite';
|
|
3
|
+
|
|
4
|
+
import { CaoAvatarComponent } from './avatar';
|
|
5
|
+
|
|
6
|
+
/* eslint-disable @typescript-eslint/naming-convention */
|
|
7
|
+
const STORY_ICONS = { LucideUser };
|
|
8
|
+
/* eslint-enable @typescript-eslint/naming-convention */
|
|
9
|
+
|
|
10
|
+
const PHOTO = 'https://i.pravatar.cc/200?img=12';
|
|
11
|
+
const ROW = 'display:flex;gap:16px;align-items:center;flex-wrap:wrap';
|
|
12
|
+
|
|
13
|
+
const meta: Meta<CaoAvatarComponent> = {
|
|
14
|
+
title: 'Componentes/Avatar',
|
|
15
|
+
component: CaoAvatarComponent,
|
|
16
|
+
tags: ['autodocs'],
|
|
17
|
+
decorators: [applicationConfig({ providers: [provideLucideIcons(STORY_ICONS as never)] })],
|
|
18
|
+
render: (args) => ({ props: args, template: `<cao-avatar ${argsToTemplate(args)} />` }),
|
|
19
|
+
argTypes: {
|
|
20
|
+
src: { control: 'text', description: 'URL da imagem. Vazia → ícone, label ou imagem padrão.' },
|
|
21
|
+
defaultSrc: { control: 'text', description: 'Imagem padrão customizada.' },
|
|
22
|
+
label: { control: 'text' },
|
|
23
|
+
isLucideIcon: { control: 'boolean' },
|
|
24
|
+
iconColor: { control: 'color' },
|
|
25
|
+
size: { control: 'text', description: "'small' | 'normal' | 'large' | 'xlarge' | número (px) | '4rem'" },
|
|
26
|
+
shape: { control: 'inline-radio', options: ['circle', 'rounded', 'square'] },
|
|
27
|
+
strokeWidth: { control: 'text' },
|
|
28
|
+
strokeColor: { control: 'color' },
|
|
29
|
+
strokeStyle: { control: 'inline-radio', options: ['solid', 'dashed', 'dotted', 'double'] },
|
|
30
|
+
bgColor: { control: 'color' },
|
|
31
|
+
textColor: { control: 'color' },
|
|
32
|
+
},
|
|
33
|
+
args: { size: 'large', shape: 'circle', strokeWidth: 0, strokeColor: '#ffffff', strokeStyle: 'solid' },
|
|
34
|
+
};
|
|
35
|
+
export default meta;
|
|
36
|
+
|
|
37
|
+
type Story = StoryObj<CaoAvatarComponent>;
|
|
38
|
+
|
|
39
|
+
export const DefaultImage: Story = {};
|
|
40
|
+
|
|
41
|
+
export const WithImage: Story = { args: { src: PHOTO, alt: 'Foto de perfil' } };
|
|
42
|
+
|
|
43
|
+
export const WithLabel: Story = { args: { label: 'MB', bgColor: '#eef2ff', textColor: '#4338ca' } };
|
|
44
|
+
|
|
45
|
+
export const WithIcon: Story = { args: { icon: 'user', bgColor: '#dcfce7', iconColor: '#166534' } };
|
|
46
|
+
|
|
47
|
+
export const BrokenImageFallsBack: Story = { args: { src: 'https://invalid.example/nao-existe.png' } };
|
|
48
|
+
|
|
49
|
+
export const CustomDefaultImage: Story = { args: { defaultSrc: 'https://i.pravatar.cc/200?img=47' } };
|
|
50
|
+
|
|
51
|
+
export const Sizes: Story = {
|
|
52
|
+
render: () => ({
|
|
53
|
+
template: `
|
|
54
|
+
<div style="${ROW}">
|
|
55
|
+
<cao-avatar [src]="'${PHOTO}'" size="small" />
|
|
56
|
+
<cao-avatar [src]="'${PHOTO}'" size="normal" />
|
|
57
|
+
<cao-avatar [src]="'${PHOTO}'" size="large" />
|
|
58
|
+
<cao-avatar [src]="'${PHOTO}'" size="xlarge" />
|
|
59
|
+
<cao-avatar [src]="'${PHOTO}'" [size]="96" />
|
|
60
|
+
<cao-avatar [src]="'${PHOTO}'" size="7rem" />
|
|
61
|
+
</div>`,
|
|
62
|
+
}),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const Shapes: Story = {
|
|
66
|
+
render: () => ({
|
|
67
|
+
template: `
|
|
68
|
+
<div style="${ROW}">
|
|
69
|
+
<cao-avatar [src]="'${PHOTO}'" size="xlarge" shape="circle" />
|
|
70
|
+
<cao-avatar [src]="'${PHOTO}'" size="xlarge" shape="rounded" />
|
|
71
|
+
<cao-avatar [src]="'${PHOTO}'" size="xlarge" shape="square" />
|
|
72
|
+
</div>`,
|
|
73
|
+
}),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const Stroke: Story = {
|
|
77
|
+
render: () => ({
|
|
78
|
+
template: `
|
|
79
|
+
<div style="${ROW}">
|
|
80
|
+
<cao-avatar [src]="'${PHOTO}'" size="xlarge" [strokeWidth]="2" strokeColor="#4f46e5" />
|
|
81
|
+
<cao-avatar [src]="'${PHOTO}'" size="xlarge" [strokeWidth]="4" strokeColor="#16a34a" strokeStyle="dashed" />
|
|
82
|
+
<cao-avatar label="AB" size="xlarge" shape="rounded" [strokeWidth]="3" strokeColor="#f59e0b" strokeStyle="double" />
|
|
83
|
+
</div>`,
|
|
84
|
+
}),
|
|
85
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core';
|
|
2
|
+
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
|
|
3
|
+
|
|
4
|
+
import { CAO_AVATAR_DEFAULT_SRC, CAO_AVATAR_SIZES } from './avatar.constants';
|
|
5
|
+
import { CaoAvatarPresetSize, CaoAvatarShape, CaoAvatarStrokeStyle } from './avatar.model';
|
|
6
|
+
|
|
7
|
+
type CaoAvatarMode = 'image' | 'label' | 'default';
|
|
8
|
+
|
|
9
|
+
@Component({
|
|
10
|
+
selector: 'cao-avatar',
|
|
11
|
+
imports: [],
|
|
12
|
+
templateUrl: './avatar.html',
|
|
13
|
+
styleUrl: './avatar.css',
|
|
14
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
15
|
+
})
|
|
16
|
+
export class CaoAvatarComponent {
|
|
17
|
+
private readonly sanitizer = inject(DomSanitizer);
|
|
18
|
+
private readonly defaultUrl: SafeUrl = this.sanitizer.bypassSecurityTrustUrl(CAO_AVATAR_DEFAULT_SRC);
|
|
19
|
+
private readonly failedSrc = signal<string | null>(null);
|
|
20
|
+
|
|
21
|
+
/** URL da imagem. Se vazia ou inválida, usa ícone, label ou a imagem padrão (nessa ordem). */
|
|
22
|
+
readonly src = input<string | null | undefined>('');
|
|
23
|
+
/** Imagem padrão customizada, usada quando nada é informado ou `src` falha ao carregar. */
|
|
24
|
+
readonly defaultSrc = input<string>('');
|
|
25
|
+
/** Texto exibido (ex.: iniciais) quando não há imagem nem ícone. */
|
|
26
|
+
readonly label = input<string>('');
|
|
27
|
+
/** Nome do ícone Lucide ou imagem usada como ícone. */
|
|
28
|
+
readonly icon = input<string>('');
|
|
29
|
+
readonly isLucideIcon = input<boolean>(true);
|
|
30
|
+
readonly iconColor = input<string>('');
|
|
31
|
+
|
|
32
|
+
/** Tamanho: `'small' | 'normal' | 'large' | 'xlarge'`, número em px ou string (`'72px'`, `'4.5rem'`). */
|
|
33
|
+
readonly size = input<CaoAvatarPresetSize | number | string>('normal');
|
|
34
|
+
/** Formato do avatar. */
|
|
35
|
+
readonly shape = input<CaoAvatarShape>('circle');
|
|
36
|
+
|
|
37
|
+
/** Espessura do contorno (stroke). Número = px. `0` remove o contorno. */
|
|
38
|
+
readonly strokeWidth = input<number | string>(0);
|
|
39
|
+
readonly strokeColor = input<string>('#ffffff');
|
|
40
|
+
readonly strokeStyle = input<CaoAvatarStrokeStyle>('solid');
|
|
41
|
+
|
|
42
|
+
readonly bgColor = input<string>('');
|
|
43
|
+
readonly textColor = input<string>('');
|
|
44
|
+
|
|
45
|
+
readonly alt = input<string>('');
|
|
46
|
+
readonly ariaLabel = input<string>('');
|
|
47
|
+
readonly dataCy = input<string>('');
|
|
48
|
+
|
|
49
|
+
protected readonly sizePx = computed<number>(() => this.resolveSize(this.size()));
|
|
50
|
+
protected readonly fontSizePx = computed<number>(() => Math.round(this.sizePx() * 0.4));
|
|
51
|
+
protected readonly iconSizePx = computed<number>(() => Math.round(this.sizePx() * 0.55));
|
|
52
|
+
|
|
53
|
+
protected readonly strokeWidthCss = computed<string>(() => {
|
|
54
|
+
const width = this.strokeWidth();
|
|
55
|
+
return typeof width === 'number' ? `${width}px` : width;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
protected readonly mode = computed<CaoAvatarMode>(() => {
|
|
59
|
+
const src = this.src();
|
|
60
|
+
if (src && this.failedSrc() !== src) {return 'image';}
|
|
61
|
+
if (this.label()) {return 'label';}
|
|
62
|
+
return 'default';
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
protected readonly defaultImage = computed<string | SafeUrl>(() => this.defaultSrc() || this.defaultUrl);
|
|
66
|
+
|
|
67
|
+
protected readonly accessibleName = computed<string>(
|
|
68
|
+
() => this.ariaLabel() || this.alt() || this.label() || 'Avatar',
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
protected onImageError(): void {
|
|
72
|
+
this.failedSrc.set(this.src() ?? null);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private resolveSize(value: CaoAvatarPresetSize | number | string): number {
|
|
76
|
+
if (typeof value === 'number') {return value > 0 ? value : CAO_AVATAR_SIZES.normal;}
|
|
77
|
+
if (value in CAO_AVATAR_SIZES) {return CAO_AVATAR_SIZES[value as CaoAvatarPresetSize];}
|
|
78
|
+
|
|
79
|
+
const parsed = Number.parseFloat(value);
|
|
80
|
+
if (Number.isNaN(parsed) || parsed <= 0) {return CAO_AVATAR_SIZES.normal;}
|
|
81
|
+
return value.trim().endsWith('rem') || value.trim().endsWith('em') ? parsed * 16 : parsed;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
:host {
|
|
2
|
+
display: block;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.cao-breadcrumb {
|
|
6
|
+
--_link: var(--cao-breadcrumb-link-color, #6b7280);
|
|
7
|
+
--_link-hover: var(--cao-breadcrumb-link-hover-color, #4f46e5);
|
|
8
|
+
--_current: var(--cao-breadcrumb-current-color, #111827);
|
|
9
|
+
--_separator: var(--cao-breadcrumb-separator-color, #9ca3af);
|
|
10
|
+
--_disabled: var(--cao-breadcrumb-disabled-color, #d1d5db);
|
|
11
|
+
--_focus: var(--cao-breadcrumb-focus-ring, rgba(79, 70, 229, 0.35));
|
|
12
|
+
--_badge-bg: var(--cao-breadcrumb-badge-bg, #eef2ff);
|
|
13
|
+
--_badge-text: var(--cao-breadcrumb-badge-text, #4338ca);
|
|
14
|
+
--_gap: var(--cao-breadcrumb-gap, 8px);
|
|
15
|
+
--_size: var(--cao-breadcrumb-font-size, 0.875rem);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.cao-breadcrumb__list {
|
|
19
|
+
display: flex;
|
|
20
|
+
flex-wrap: wrap;
|
|
21
|
+
align-items: center;
|
|
22
|
+
gap: var(--_gap);
|
|
23
|
+
margin: 0;
|
|
24
|
+
padding: 0;
|
|
25
|
+
list-style: none;
|
|
26
|
+
font-size: var(--_size);
|
|
27
|
+
line-height: 1.25;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.cao-breadcrumb__item,
|
|
31
|
+
.cao-breadcrumb__separator {
|
|
32
|
+
display: inline-flex;
|
|
33
|
+
align-items: center;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.cao-breadcrumb__separator {
|
|
37
|
+
color: var(--_separator);
|
|
38
|
+
user-select: none;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.cao-breadcrumb__action,
|
|
42
|
+
.cao-breadcrumb__text {
|
|
43
|
+
display: inline-flex;
|
|
44
|
+
align-items: center;
|
|
45
|
+
gap: 6px;
|
|
46
|
+
padding: 2px 4px;
|
|
47
|
+
border: 0;
|
|
48
|
+
border-radius: 4px;
|
|
49
|
+
background: none;
|
|
50
|
+
color: var(--_link);
|
|
51
|
+
font: inherit;
|
|
52
|
+
text-decoration: none;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.cao-breadcrumb__action {
|
|
56
|
+
cursor: pointer;
|
|
57
|
+
transition: color 150ms ease;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.cao-breadcrumb__action:hover {
|
|
61
|
+
color: var(--_link-hover);
|
|
62
|
+
text-decoration: underline;
|
|
63
|
+
text-underline-offset: 3px;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.cao-breadcrumb__action:focus-visible {
|
|
67
|
+
outline: 2px solid var(--_focus);
|
|
68
|
+
outline-offset: 1px;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.cao-breadcrumb__item--current .cao-breadcrumb__text {
|
|
72
|
+
color: var(--_current);
|
|
73
|
+
font-weight: 600;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.cao-breadcrumb__item--disabled .cao-breadcrumb__text {
|
|
77
|
+
color: var(--_disabled);
|
|
78
|
+
cursor: not-allowed;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.cao-breadcrumb__icon {
|
|
82
|
+
display: inline-flex;
|
|
83
|
+
align-items: center;
|
|
84
|
+
line-height: 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.cao-breadcrumb__badge {
|
|
88
|
+
display: inline-flex;
|
|
89
|
+
align-items: center;
|
|
90
|
+
justify-content: center;
|
|
91
|
+
min-width: 18px;
|
|
92
|
+
padding: 1px 6px;
|
|
93
|
+
border-radius: 999px;
|
|
94
|
+
background: var(--_badge-bg);
|
|
95
|
+
color: var(--_badge-text);
|
|
96
|
+
font-size: 0.75em;
|
|
97
|
+
font-weight: 600;
|
|
98
|
+
line-height: 1.4;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@media (prefers-reduced-motion: reduce) {
|
|
102
|
+
.cao-breadcrumb__action {
|
|
103
|
+
transition: none;
|
|
104
|
+
}
|
|
105
|
+
}
|