ngx-opalbytes-components 1.28.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 +160 -0
- package/cli/cao-comp.cjs +10 -10
- package/fesm2022/ngx-opalbytes-components.mjs +57 -2
- package/fesm2022/ngx-opalbytes-components.mjs.map +1 -1
- package/package.json +1 -1
- 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 +2 -1
- package/types/ngx-opalbytes-components.d.ts +82 -2
package/package.json
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
<ng-template #content let-item let-index="index">
|
|
2
|
+
@if (item.icon && iconPosition(item) === 'left') {
|
|
3
|
+
<ng-container *ngTemplateOutlet="icon; context: { $implicit: item }" />
|
|
4
|
+
}
|
|
5
|
+
@if (item.label) {
|
|
6
|
+
<span class="cao-breadcrumb__label">{{ item.label }}</span>
|
|
7
|
+
}
|
|
8
|
+
@if (item.icon && iconPosition(item) === 'right') {
|
|
9
|
+
<ng-container *ngTemplateOutlet="icon; context: { $implicit: item }" />
|
|
10
|
+
}
|
|
11
|
+
@if (item.badge !== undefined && item.badge !== null && item.badge !== '') {
|
|
12
|
+
<span
|
|
13
|
+
class="cao-breadcrumb__badge"
|
|
14
|
+
[style.color]="item.badgeColor || null"
|
|
15
|
+
[style.background-color]="item.badgeBgColor || null"
|
|
16
|
+
[attr.data-cy]="item.dataCy ? item.dataCy + '-badge' : null">
|
|
17
|
+
{{ item.badge }}
|
|
18
|
+
</span>
|
|
19
|
+
}
|
|
20
|
+
</ng-template>
|
|
21
|
+
|
|
22
|
+
<ng-template #icon let-item>
|
|
23
|
+
<span class="cao-breadcrumb__icon" aria-hidden="true">
|
|
24
|
+
<cao-base-icon
|
|
25
|
+
[imgSource]="item.icon"
|
|
26
|
+
[isLucideIcon]="isLucide(item)"
|
|
27
|
+
[iconColor]="item.iconColor ?? ''"
|
|
28
|
+
[strokeWidthIcon]="item.iconStrokeWidth ?? 1.5"
|
|
29
|
+
[size]="item.iconSize ?? 16" />
|
|
30
|
+
</span>
|
|
31
|
+
</ng-template>
|
|
32
|
+
|
|
33
|
+
@if (allItems().length) {
|
|
34
|
+
<nav
|
|
35
|
+
class="cao-breadcrumb"
|
|
36
|
+
[attr.aria-label]="ariaLabel()"
|
|
37
|
+
[attr.data-cy]="dataCy() || null"
|
|
38
|
+
[style.--cao-breadcrumb-link-color]="linkColor() || null"
|
|
39
|
+
[style.--cao-breadcrumb-link-hover-color]="linkHoverColor() || null"
|
|
40
|
+
[style.--cao-breadcrumb-current-color]="currentColor() || null"
|
|
41
|
+
[style.--cao-breadcrumb-separator-color]="separatorColor() || null"
|
|
42
|
+
[style.--cao-breadcrumb-font-size]="fontSize() || null"
|
|
43
|
+
[style.--cao-breadcrumb-gap]="gap() || null">
|
|
44
|
+
<ol class="cao-breadcrumb__list">
|
|
45
|
+
@for (item of allItems(); track $index; let i = $index; let last = $last) {
|
|
46
|
+
<li
|
|
47
|
+
class="cao-breadcrumb__item"
|
|
48
|
+
[class.cao-breadcrumb__item--current]="last"
|
|
49
|
+
[class.cao-breadcrumb__item--disabled]="item.disabled"
|
|
50
|
+
[attr.data-cy]="item.dataCy || null">
|
|
51
|
+
@if (isInteractive(item, last)) {
|
|
52
|
+
@if (hasLink(item)) {
|
|
53
|
+
<a
|
|
54
|
+
class="cao-breadcrumb__action"
|
|
55
|
+
[routerLink]="item.link"
|
|
56
|
+
[queryParams]="item.queryParams"
|
|
57
|
+
[target]="item.target ?? '_self'"
|
|
58
|
+
[attr.aria-label]="item.ariaLabel || null"
|
|
59
|
+
(click)="onItemClick(item, i, $event)">
|
|
60
|
+
<ng-container *ngTemplateOutlet="content; context: { $implicit: item, index: i }" />
|
|
61
|
+
</a>
|
|
62
|
+
} @else if (item.href) {
|
|
63
|
+
<a
|
|
64
|
+
class="cao-breadcrumb__action"
|
|
65
|
+
[attr.href]="item.href"
|
|
66
|
+
[attr.target]="item.target || null"
|
|
67
|
+
[attr.rel]="item.target === '_blank' ? 'noopener noreferrer' : null"
|
|
68
|
+
[attr.aria-label]="item.ariaLabel || null"
|
|
69
|
+
(click)="onItemClick(item, i, $event)">
|
|
70
|
+
<ng-container *ngTemplateOutlet="content; context: { $implicit: item, index: i }" />
|
|
71
|
+
</a>
|
|
72
|
+
} @else {
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
class="cao-breadcrumb__action"
|
|
76
|
+
[attr.aria-label]="item.ariaLabel || null"
|
|
77
|
+
(click)="onItemClick(item, i, $event)">
|
|
78
|
+
<ng-container *ngTemplateOutlet="content; context: { $implicit: item, index: i }" />
|
|
79
|
+
</button>
|
|
80
|
+
}
|
|
81
|
+
} @else {
|
|
82
|
+
<span
|
|
83
|
+
class="cao-breadcrumb__text"
|
|
84
|
+
[attr.aria-current]="last ? 'page' : null"
|
|
85
|
+
[attr.aria-disabled]="item.disabled ? 'true' : null"
|
|
86
|
+
[attr.aria-label]="item.ariaLabel || null">
|
|
87
|
+
<ng-container *ngTemplateOutlet="content; context: { $implicit: item, index: i }" />
|
|
88
|
+
</span>
|
|
89
|
+
}
|
|
90
|
+
</li>
|
|
91
|
+
|
|
92
|
+
@if (!last) {
|
|
93
|
+
<li class="cao-breadcrumb__separator" role="presentation" aria-hidden="true">
|
|
94
|
+
@if (separatorIcon()) {
|
|
95
|
+
<cao-base-icon
|
|
96
|
+
[imgSource]="separatorIcon()"
|
|
97
|
+
[isLucideIcon]="isLucideSeparatorIcon()"
|
|
98
|
+
[strokeWidthIcon]="separatorIconStrokeWidth()"
|
|
99
|
+
[size]="separatorIconSize()" />
|
|
100
|
+
} @else {
|
|
101
|
+
{{ separator() }}
|
|
102
|
+
}
|
|
103
|
+
</li>
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
</ol>
|
|
107
|
+
</nav>
|
|
108
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Params } from '@angular/router';
|
|
2
|
+
|
|
3
|
+
/** Posição do ícone em relação ao texto do item. */
|
|
4
|
+
export type CaoBreadcrumbIconPosition = 'left' | 'right';
|
|
5
|
+
|
|
6
|
+
/** Item do breadcrumb. */
|
|
7
|
+
export interface ICaoBreadcrumbItem {
|
|
8
|
+
/** Texto exibido. Pode ser omitido em itens apenas com ícone (informe `ariaLabel`). */
|
|
9
|
+
label?: string;
|
|
10
|
+
/** Nome do ícone Lucide ou caminho/URL/data URI da imagem. */
|
|
11
|
+
icon?: string;
|
|
12
|
+
/** Define se `icon` é um ícone Lucide. Padrão: `true`. */
|
|
13
|
+
isLucideIcon?: boolean;
|
|
14
|
+
/** Posição do ícone em relação ao texto. Padrão: `'left'`. */
|
|
15
|
+
iconPosition?: CaoBreadcrumbIconPosition;
|
|
16
|
+
/** Cor do ícone (aplicada a ícones Lucide). */
|
|
17
|
+
iconColor?: string;
|
|
18
|
+
/** Tamanho do ícone em px. Padrão: `16`. */
|
|
19
|
+
iconSize?: number;
|
|
20
|
+
/** Espessura do traço do ícone Lucide. Padrão: `1.5`. */
|
|
21
|
+
iconStrokeWidth?: number;
|
|
22
|
+
|
|
23
|
+
/** Navegação interna via Angular Router (ex.: `'/clientes'` ou `['/clientes', 12]`). */
|
|
24
|
+
link?: string | unknown[];
|
|
25
|
+
/** Query params usados junto com `link`. */
|
|
26
|
+
queryParams?: Params;
|
|
27
|
+
/** Navegação externa via `href`. Ignorado se `link` estiver definido. */
|
|
28
|
+
href?: string;
|
|
29
|
+
/** `target` do link (ex.: `'_blank'`). */
|
|
30
|
+
target?: '_self' | '_blank' | '_parent' | '_top';
|
|
31
|
+
/** Callback executado ao clicar (item sem `link`/`href` vira um botão). */
|
|
32
|
+
command?: (event: ICaoBreadcrumbItemClickEvent) => void;
|
|
33
|
+
|
|
34
|
+
/** Texto/valor do badge exibido após o item. */
|
|
35
|
+
badge?: string | number;
|
|
36
|
+
/** Cor do texto do badge. */
|
|
37
|
+
badgeColor?: string;
|
|
38
|
+
/** Cor de fundo do badge. */
|
|
39
|
+
badgeBgColor?: string;
|
|
40
|
+
|
|
41
|
+
/** Desabilita a interação com o item. */
|
|
42
|
+
disabled?: boolean;
|
|
43
|
+
/** `aria-label` do item (recomendado para itens apenas com ícone). */
|
|
44
|
+
ariaLabel?: string;
|
|
45
|
+
/** Atributo `data-cy` para testes E2E. */
|
|
46
|
+
dataCy?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Evento emitido ao clicar em um item. */
|
|
50
|
+
export interface ICaoBreadcrumbItemClickEvent {
|
|
51
|
+
item: ICaoBreadcrumbItem;
|
|
52
|
+
index: number;
|
|
53
|
+
originalEvent: Event;
|
|
54
|
+
}
|