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.
Files changed (31) hide show
  1. package/README.md +361 -1
  2. package/cli/cao-comp.cjs +10 -10
  3. package/fesm2022/ngx-opalbytes-components.mjs +350 -4
  4. package/fesm2022/ngx-opalbytes-components.mjs.map +1 -1
  5. package/package.json +1 -1
  6. package/src/lib/shared/components/accordion/accordion.component.css +30 -0
  7. package/src/lib/shared/components/accordion/accordion.component.html +1 -0
  8. package/src/lib/shared/components/accordion/accordion.component.spec.ts +117 -0
  9. package/src/lib/shared/components/accordion/accordion.component.ts +195 -0
  10. package/src/lib/shared/components/accordion/accordion.token.ts +40 -0
  11. package/src/lib/shared/components/accordion-item/accordion-item.component.css +137 -0
  12. package/src/lib/shared/components/accordion-item/accordion-item.component.html +52 -0
  13. package/src/lib/shared/components/accordion-item/accordion-item.component.ts +157 -0
  14. package/src/lib/shared/components/accordion-item/accordion-item.spec.ts +22 -0
  15. package/src/lib/shared/components/avatar/avatar.constants.ts +17 -0
  16. package/src/lib/shared/components/avatar/avatar.css +44 -0
  17. package/src/lib/shared/components/avatar/avatar.html +27 -0
  18. package/src/lib/shared/components/avatar/avatar.model.ts +3 -0
  19. package/src/lib/shared/components/avatar/avatar.spec.ts +88 -0
  20. package/src/lib/shared/components/avatar/avatar.stories.ts +85 -0
  21. package/src/lib/shared/components/avatar/avatar.ts +83 -0
  22. package/src/lib/shared/components/avatar/index.ts +3 -0
  23. package/src/lib/shared/components/breadcrumb/breadcrumb.css +105 -0
  24. package/src/lib/shared/components/breadcrumb/breadcrumb.html +108 -0
  25. package/src/lib/shared/components/breadcrumb/breadcrumb.model.ts +54 -0
  26. package/src/lib/shared/components/breadcrumb/breadcrumb.spec.ts +93 -0
  27. package/src/lib/shared/components/breadcrumb/breadcrumb.stories.ts +122 -0
  28. package/src/lib/shared/components/breadcrumb/breadcrumb.ts +72 -0
  29. package/src/lib/shared/components/breadcrumb/index.ts +2 -0
  30. package/src/lib/shared/components/index.ts +5 -1
  31. package/types/ngx-opalbytes-components.d.ts +354 -129
@@ -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
+ }
@@ -0,0 +1,93 @@
1
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
2
+ import { provideRouter } from '@angular/router';
3
+
4
+ import { CaoBreadcrumbComponent } from './breadcrumb';
5
+ import { ICaoBreadcrumbItem } from './breadcrumb.model';
6
+
7
+ describe('CaoBreadcrumbComponent', () => {
8
+ let fixture: ComponentFixture<CaoBreadcrumbComponent>;
9
+ let el: HTMLElement;
10
+
11
+ const items: ICaoBreadcrumbItem[] = [
12
+ { label: 'Início', link: '/' },
13
+ { label: 'Clientes', href: 'https://example.com' },
14
+ { label: 'Detalhe', badge: 3 },
15
+ ];
16
+
17
+ beforeEach(async () => {
18
+ await TestBed.configureTestingModule({
19
+ imports: [CaoBreadcrumbComponent],
20
+ providers: [provideRouter([])],
21
+ }).compileComponents();
22
+
23
+ fixture = TestBed.createComponent(CaoBreadcrumbComponent);
24
+ el = fixture.nativeElement;
25
+ fixture.componentRef.setInput('items', items);
26
+ fixture.detectChanges();
27
+ });
28
+
29
+ it('renderiza um item por entrada', () => {
30
+ expect(el.querySelectorAll('.cao-breadcrumb__item').length).toBe(3);
31
+ });
32
+
33
+ it('usa "/" como divisor padrão (n - 1 divisores)', () => {
34
+ const seps = el.querySelectorAll('.cao-breadcrumb__separator');
35
+ expect(seps.length).toBe(2);
36
+ expect(seps[0].textContent?.trim()).toBe('/');
37
+ });
38
+
39
+ it('aceita divisor de texto customizado', () => {
40
+ fixture.componentRef.setInput('separator', '>');
41
+ fixture.detectChanges();
42
+ expect(el.querySelector('.cao-breadcrumb__separator')?.textContent?.trim()).toBe('>');
43
+ });
44
+
45
+ it('marca o último item como página atual e sem link', () => {
46
+ const current = el.querySelector('.cao-breadcrumb__item--current .cao-breadcrumb__text');
47
+ expect(current?.getAttribute('aria-current')).toBe('page');
48
+ expect(el.querySelector('.cao-breadcrumb__item--current a')).toBeNull();
49
+ });
50
+
51
+ it('renderiza badge', () => {
52
+ expect(el.querySelector('.cao-breadcrumb__badge')?.textContent?.trim()).toBe('3');
53
+ });
54
+
55
+ it('inclui o item home como primeiro', () => {
56
+ fixture.componentRef.setInput('home', { label: 'Home', link: '/' });
57
+ fixture.detectChanges();
58
+ const first = el.querySelector('.cao-breadcrumb__item');
59
+ expect(first?.textContent).toContain('Home');
60
+ expect(el.querySelectorAll('.cao-breadcrumb__item').length).toBe(4);
61
+ });
62
+
63
+ it('emite itemClick e executa command', () => {
64
+ let commandCalled = false;
65
+ const emitted: unknown[] = [];
66
+ fixture.componentRef.setInput('items', [
67
+ { label: 'Ação', command: () => (commandCalled = true) },
68
+ { label: 'Atual' },
69
+ ]);
70
+ fixture.componentInstance.itemClick.subscribe((e) => emitted.push(e));
71
+ fixture.detectChanges();
72
+
73
+ el.querySelector<HTMLButtonElement>('button.cao-breadcrumb__action')?.click();
74
+
75
+ expect(commandCalled).toBe(true);
76
+ expect(emitted.length).toBe(1);
77
+ });
78
+
79
+ it('não torna interativo um item desabilitado', () => {
80
+ fixture.componentRef.setInput('items', [
81
+ { label: 'Bloqueado', link: '/x', disabled: true },
82
+ { label: 'Atual' },
83
+ ]);
84
+ fixture.detectChanges();
85
+ expect(el.querySelector('a, button')).toBeNull();
86
+ });
87
+
88
+ it('não renderiza nada sem itens', () => {
89
+ fixture.componentRef.setInput('items', []);
90
+ fixture.detectChanges();
91
+ expect(el.querySelector('nav')).toBeNull();
92
+ });
93
+ });
@@ -0,0 +1,122 @@
1
+ import { provideRouter } from '@angular/router';
2
+
3
+ import { provideLucideIcons, LucideBuilding2, LucideChevronRight, LucideFolder, LucideHouse, LucideUser } from '@lucide/angular';
4
+ import { applicationConfig, argsToTemplate, Meta, StoryObj } from '@storybook/angular-vite';
5
+
6
+ import { CaoBreadcrumbComponent } from './breadcrumb';
7
+ import { ICaoBreadcrumbItem } from './breadcrumb.model';
8
+
9
+ /* eslint-disable @typescript-eslint/naming-convention */
10
+ const STORY_ICONS = { LucideBuilding2, LucideChevronRight, LucideFolder, LucideHouse, LucideUser };
11
+ /* eslint-enable @typescript-eslint/naming-convention */
12
+
13
+ const IMG_ICON =
14
+ `data:image/svg+xml;utf8,${encodeURIComponent(
15
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="#4f46e5"/><path d="M8 12l3 3 5-6" stroke="#fff" stroke-width="2" fill="none"/></svg>',
16
+ )}`;
17
+
18
+ const basicItems: ICaoBreadcrumbItem[] = [
19
+ { label: 'Início', link: '/' },
20
+ { label: 'Clientes', link: '/clientes' },
21
+ { label: 'Maria Souza' },
22
+ ];
23
+
24
+ const meta: Meta<CaoBreadcrumbComponent> = {
25
+ title: 'Componentes/Breadcrumb',
26
+ component: CaoBreadcrumbComponent,
27
+ tags: ['autodocs'],
28
+ decorators: [
29
+ applicationConfig({
30
+ providers: [
31
+ provideRouter([]),
32
+ provideLucideIcons(STORY_ICONS as never),
33
+ ],
34
+ }),
35
+ ],
36
+ render: (args) => ({ props: args, template: `<cao-breadcrumb ${argsToTemplate(args)} />` }),
37
+ argTypes: {
38
+ items: { control: 'object', description: 'Lista de itens (o último é a página atual).' },
39
+ home: { control: 'object', description: 'Item home opcional, exibido antes dos itens.' },
40
+ separator: { control: 'text', description: 'Texto do divisor. Padrão: "/".' },
41
+ separatorIcon: { control: 'text', description: 'Ícone do divisor (Lucide ou imagem). Sobrepõe `separator`.' },
42
+ isLucideSeparatorIcon: { control: 'boolean' },
43
+ separatorIconSize: { control: 'number' },
44
+ linkColor: { control: 'color' },
45
+ linkHoverColor: { control: 'color' },
46
+ currentColor: { control: 'color' },
47
+ separatorColor: { control: 'color' },
48
+ fontSize: { control: 'text' },
49
+ gap: { control: 'text' },
50
+ ariaLabel: { control: 'text' },
51
+ itemClick: { action: 'itemClick' },
52
+ },
53
+ args: { items: basicItems, separator: '/', ariaLabel: 'Breadcrumb' },
54
+ };
55
+ export default meta;
56
+
57
+ type Story = StoryObj<CaoBreadcrumbComponent>;
58
+
59
+ export const Default: Story = {};
60
+
61
+ export const WithHome: Story = {
62
+ args: { home: { icon: 'house', ariaLabel: 'Início', link: '/' }, items: basicItems.slice(1) },
63
+ };
64
+
65
+ export const IconsLeftAndRight: Story = {
66
+ args: {
67
+ items: [
68
+ { label: 'Empresa', icon: 'building-2', iconPosition: 'left', link: '/empresa' },
69
+ { label: 'Projetos', icon: 'folder', iconPosition: 'right', link: '/projetos' },
70
+ { label: 'Perfil', icon: 'user', iconPosition: 'left' },
71
+ ],
72
+ },
73
+ };
74
+
75
+ export const ImageIcon: Story = {
76
+ args: {
77
+ items: [
78
+ { label: 'Início', icon: IMG_ICON, isLucideIcon: false, link: '/' },
79
+ { label: 'Relatórios', icon: IMG_ICON, isLucideIcon: false, iconPosition: 'right', link: '/relatorios' },
80
+ { label: 'Mensal' },
81
+ ],
82
+ },
83
+ };
84
+
85
+ export const WithBadges: Story = {
86
+ args: {
87
+ items: [
88
+ { label: 'Início', link: '/' },
89
+ { label: 'Pedidos', link: '/pedidos', badge: 12 },
90
+ { label: 'Pendentes', badge: 'novo', badgeBgColor: '#dcfce7', badgeColor: '#166534' },
91
+ ],
92
+ },
93
+ };
94
+
95
+ export const CustomTextSeparator: Story = { args: { separator: '>' } };
96
+
97
+ export const IconSeparator: Story = {
98
+ args: { separatorIcon: 'chevron-right', isLucideSeparatorIcon: true, separatorColor: '#6b7280' },
99
+ };
100
+
101
+ export const ExternalLinkAndCommand: Story = {
102
+ args: {
103
+ items: [
104
+ { label: 'Docs', href: 'https://angular.dev', target: '_blank' },
105
+ { label: 'Ação customizada', command: () => console.warn('command executado') },
106
+ { label: 'Desabilitado', link: '/x', disabled: true },
107
+ { label: 'Atual' },
108
+ ],
109
+ },
110
+ };
111
+
112
+ export const Themed: Story = {
113
+ args: {
114
+ linkColor: '#0f766e',
115
+ linkHoverColor: '#134e4a',
116
+ currentColor: '#042f2e',
117
+ separatorColor: '#5eead4',
118
+ fontSize: '1rem',
119
+ gap: '12px',
120
+ separatorIcon: 'chevron-right',
121
+ },
122
+ };
@@ -0,0 +1,72 @@
1
+ import { NgTemplateOutlet } from '@angular/common';
2
+ import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
3
+ import { RouterLink } from '@angular/router';
4
+
5
+ import { ICaoBreadcrumbItem, ICaoBreadcrumbItemClickEvent } from './breadcrumb.model';
6
+ import { CaoBaseIcon } from '../base-icon/base-icon';
7
+
8
+ @Component({
9
+ selector: 'cao-breadcrumb',
10
+ imports: [NgTemplateOutlet, RouterLink, CaoBaseIcon],
11
+ templateUrl: './breadcrumb.html',
12
+ styleUrl: './breadcrumb.css',
13
+ changeDetection: ChangeDetectionStrategy.OnPush,
14
+ })
15
+ export class CaoBreadcrumbComponent {
16
+ /** Itens do breadcrumb. O último item é tratado como página atual. */
17
+ readonly items = input<ICaoBreadcrumbItem[]>([]);
18
+ /** Item "home" opcional, sempre exibido como primeiro item. */
19
+ readonly home = input<ICaoBreadcrumbItem | undefined>(undefined);
20
+
21
+ /** Texto do divisor. Padrão: `'/'`. Ignorado se `separatorIcon` for informado. */
22
+ readonly separator = input<string>('/');
23
+ /** Ícone do divisor (nome Lucide ou imagem). Tem prioridade sobre `separator`. */
24
+ readonly separatorIcon = input<string>('');
25
+ readonly isLucideSeparatorIcon = input<boolean>(true);
26
+ readonly separatorIconSize = input<number>(14);
27
+ readonly separatorIconStrokeWidth = input<number>(1.5);
28
+
29
+ readonly linkColor = input<string>('');
30
+ readonly linkHoverColor = input<string>('');
31
+ readonly currentColor = input<string>('');
32
+ readonly separatorColor = input<string>('');
33
+ readonly fontSize = input<string>('');
34
+ readonly gap = input<string>('');
35
+
36
+ readonly ariaLabel = input<string>('Breadcrumb');
37
+ readonly dataCy = input<string>('');
38
+
39
+ /** Emitido ao clicar em qualquer item interativo. */
40
+ readonly itemClick = output<ICaoBreadcrumbItemClickEvent>();
41
+
42
+ protected readonly allItems = computed<ICaoBreadcrumbItem[]>(() => {
43
+ const home = this.home();
44
+ return home ? [home, ...this.items()] : this.items();
45
+ });
46
+
47
+ protected isInteractive(item: ICaoBreadcrumbItem, isLast: boolean): boolean {
48
+ return !isLast && !item.disabled && (this.hasLink(item) || !!item.href || !!item.command);
49
+ }
50
+
51
+ protected hasLink(item: ICaoBreadcrumbItem): boolean {
52
+ return item.link !== undefined && item.link !== null;
53
+ }
54
+
55
+ protected onItemClick(item: ICaoBreadcrumbItem, index: number, event: Event): void {
56
+ if (item.disabled) {
57
+ event.preventDefault();
58
+ return;
59
+ }
60
+ const payload: ICaoBreadcrumbItemClickEvent = { item, index, originalEvent: event };
61
+ item.command?.(payload);
62
+ this.itemClick.emit(payload);
63
+ }
64
+
65
+ protected iconPosition(item: ICaoBreadcrumbItem): 'left' | 'right' {
66
+ return item.iconPosition ?? 'left';
67
+ }
68
+
69
+ protected isLucide(item: ICaoBreadcrumbItem): boolean {
70
+ return item.isLucideIcon ?? true;
71
+ }
72
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./breadcrumb"
2
+ export * from "./breadcrumb.model"
@@ -1,3 +1,6 @@
1
+ export * from './accordion/accordion.component'
2
+ export * from './accordion/accordion.token'
3
+ export * from './accordion-item/accordion-item.component'
1
4
  export * from './base-button/base-button.component'
2
5
  export * from './base-input/base-input'
3
6
  export * from './base-time-range/base-time-range'
@@ -31,4 +34,5 @@ export * from "./slide-toggle/slide-toggle.component"
31
34
  export * from "./radio/radio-group.component"
32
35
  export * from "./radio/radio-button.component"
33
36
  export * from "./checkbox/checkbox.component"
34
- export * from "./footer/footer"
37
+ export * from "./footer/footer"
38
+ export * from "./breadcrumb/breadcrumb"