diff --git a/projects/portal/docs/configuration.js b/projects/portal/docs/configuration.js index eea01b69f4..7b02493584 100644 --- a/projects/portal/docs/configuration.js +++ b/projects/portal/docs/configuration.js @@ -56,8 +56,6 @@ module.exports = { { name: 'usedBy' }, { name: 'optional' }, { name: 'default' }, - { name: "Output" }, - { name: "Input" }, { name: 'override' } ], diff --git a/projects/portal/docs/processors/helpers/functions.js b/projects/portal/docs/processors/helpers/functions.js index 47f764b05e..27a5e6a37f 100644 --- a/projects/portal/docs/processors/helpers/functions.js +++ b/projects/portal/docs/processors/helpers/functions.js @@ -41,7 +41,7 @@ module.exports = { // Resolve all methods and properties from the classDoc. Includes inherited docs. classDoc.methods = classDoc.methods.concat(this.resolveMethods(classDoc)); - + // Concatena propriedades da classe atual const currentProperties = this.resolveProperties(classDoc); classDoc.properties = classDoc.properties.concat(currentProperties); @@ -55,14 +55,14 @@ module.exports = { propertyMap.set(prop.name, prop); continue; } - + const hasOverrideTag = (prop.tags?.tags || []).some(tag => tag.tagName === 'override'); if (hasOverrideTag) { propertyMap.set(prop.name, prop); } } - + // Converte o Map de volta para array e ordena classDoc.properties = Array.from(propertyMap.values()).sort((a, b) => { return a.name > b.name ? 1 : -1; @@ -95,21 +95,11 @@ module.exports = { processPropertyDoc: function (propertyDoc) { this.processPublicDoc(propertyDoc); - if (propertyDoc.Input != undefined) { - propertyDoc.isDirectiveInput = true; - propertyDoc.directiveInputAlias = propertyDoc.directiveInputAlias || propertyDoc.Input; - } else { - propertyDoc.isDirectiveInput = this.isDirectiveInput(propertyDoc); - propertyDoc.directiveInputAlias = this.getDirectiveInputAlias(propertyDoc); - } + propertyDoc.isDirectiveInput = this.isDirectiveInput(propertyDoc) || propertyDoc.isDirectiveInput; + propertyDoc.directiveInputAlias = this.getDirectiveInputAlias(propertyDoc) || propertyDoc.directiveInputAlias; - if (propertyDoc.Output != undefined) { - propertyDoc.isDirectiveOutput = true; - propertyDoc.directiveOutputAlias = propertyDoc.Output; - } else { - propertyDoc.isDirectiveOutput = this.isDirectiveOutput(propertyDoc); - propertyDoc.directiveOutputAlias = this.getDirectiveOutputAlias(propertyDoc); - } + propertyDoc.isDirectiveOutput = this.isDirectiveOutput(propertyDoc) || propertyDoc.isDirectiveOutput; + propertyDoc.directiveOutputAlias = this.getDirectiveOutputAlias(propertyDoc) || propertyDoc.directiveOutputAlias; }, /** @@ -130,13 +120,13 @@ module.exports = { resolveProperties: function (classDoc) { let properties = classDoc.members.filter(member => !member.hasOwnProperty('parameters')); - const inferTypeFromInput = codeLine => { + const inferTypeFromCode = codeLine => { // Extrai tipo genérico - const genericMatch = codeLine.match(/input<([^>]+)>/); + const genericMatch = codeLine.match(/(?:input(?:\.required)?|output|model)<(.+)>(?=\s*\()/); const genericRaw = genericMatch ? genericMatch[1].trim() : null; // Extrai valor passado como primeiro argumento - const valueMatch = codeLine.match(/input(?:<[^>]+>)?\(\s*([^,)]*)/); + const valueMatch = codeLine.match(/(?:input(?:\.required)?|output|model)(?:<.+>)?\(\s*([^,)]*)/); const value = valueMatch ? valueMatch[1].trim() : ''; // Extrai alias (entre aspas simples ou duplas) @@ -152,7 +142,7 @@ module.exports = { // Tipo inferido por valor literal if (value === 'true' || value === 'false') return { type: 'boolean', alias }; if (value.startsWith('"') || value.startsWith("'")) return { type: 'string', alias }; - if (!isNaN(Number(value))) return { type: 'number', alias }; + if (value !== '' && !isNaN(Number(value))) return { type: 'number', alias }; // Fallback return { type: 'unknown', alias }; @@ -167,10 +157,20 @@ module.exports = { property.isOptional = true; } - if (property.type.includes('input') && property.Input !== undefined) { - const { type, alias } = inferTypeFromInput(property.type); + // Tratamento input e model no Signal + if ((property.type.includes('input') || property.type.includes('model'))) { + const { type, alias } = inferTypeFromCode(property.type); properties[index].type = type; properties[index].directiveInputAlias = alias; + properties[index].isDirectiveInput = true; + } + + // Tratamento output no Signal + if (property.type.includes('output')) { + const { type, alias } = inferTypeFromCode(property.type); + properties[index].type = 'EventEmitter'; + properties[index].directiveOutputAlias = alias; + properties[index].isDirectiveOutput = true; } }); diff --git a/projects/ui/src/lib/components/components.module.ts b/projects/ui/src/lib/components/components.module.ts index 9abc0c8bf2..1d2838f8d6 100644 --- a/projects/ui/src/lib/components/components.module.ts +++ b/projects/ui/src/lib/components/components.module.ts @@ -9,6 +9,7 @@ import { PoButtonModule } from './po-button/po-button.module'; import { PoCalendarModule } from './po-calendar/po-calendar.module'; import { PoChartModule } from './po-chart/po-chart.module'; import { PoContainerModule } from './po-container/po-container.module'; +import { PoContextMenuModule } from './po-context-menu/po-context-menu.module'; import { PoDisclaimerGroupModule } from './po-disclaimer-group/po-disclaimer-group.module'; import { PoDisclaimerModule } from './po-disclaimer/po-disclaimer.module'; import { PoDividerModule } from './po-divider/po-divider.module'; @@ -61,6 +62,7 @@ const PO_MODULES = [ PoCalendarModule, PoChartModule, PoContainerModule, + PoContextMenuModule, PoDisclaimerGroupModule, PoDisclaimerModule, PoDividerModule, diff --git a/projects/ui/src/lib/components/index.ts b/projects/ui/src/lib/components/index.ts index c1a3b8b594..5376de77d0 100644 --- a/projects/ui/src/lib/components/index.ts +++ b/projects/ui/src/lib/components/index.ts @@ -9,6 +9,7 @@ export * from './po-button/index'; export * from './po-calendar/index'; export * from './po-chart/index'; export * from './po-container/index'; +export * from './po-context-menu/index'; export * from './po-disclaimer-group/index'; export * from './po-disclaimer/index'; export * from './po-divider/index'; diff --git a/projects/ui/src/lib/components/po-context-menu/index.ts b/projects/ui/src/lib/components/po-context-menu/index.ts new file mode 100644 index 0000000000..0ccb4c193a --- /dev/null +++ b/projects/ui/src/lib/components/po-context-menu/index.ts @@ -0,0 +1,3 @@ +export * from './po-context-menu-item.interface'; +export * from './po-context-menu.component'; +export * from './po-context-menu.module'; diff --git a/projects/ui/src/lib/components/po-context-menu/po-context-menu-base.component.ts b/projects/ui/src/lib/components/po-context-menu/po-context-menu-base.component.ts new file mode 100644 index 0000000000..40bceefe04 --- /dev/null +++ b/projects/ui/src/lib/components/po-context-menu/po-context-menu-base.component.ts @@ -0,0 +1,96 @@ +import { Directive, input, model, output } from '@angular/core'; + +import { PoContextMenuItem } from './po-context-menu-item.interface'; + +/** + * @description + * + * O componente `po-context-menu` e uma barra lateral de contexto (sidebar) para navegacao interna de modulos. + * Inspirado visualmente no `po-menu`, porem independente e focado em navegacao contextual. + * + * Quando estiver sendo utilizado o componente po-page-default, ambos devem estar no mesmo nível + * e inseridos em uma div com a classe **po-context-menu-wrapper**. + * Esta classe será responsável por fazer os cálculos necessários de alinhamento dos componentes. + * + * #### Tokens customizaveis + * + * E possivel alterar o estilo do componente usando os seguintes tokens (CSS): + * + * > Para maiores informacoes, acesse o guia [Personalizando o Tema Padrao com Tokens CSS](https://po-ui.io/guides/theme-customization). + * + * | Propriedade | Descricao | Valor Padrao | + * |----------------------------------------|--------------------------------------------------------------|-------------------------------------------------| + * | **Default Values** | | | + * | `--font-family` | Familia tipografica usada | `var(--font-family-theme)` | + * | `--font-size` | Tamanho da fonte dos itens | `var(--font-size-default)` | + * | `--font-size-context-title` | Tamanho da fonte do titulo de contexto | `var(--font-size-sm)` | + * | `--font-size-title` | Tamanho da fonte do titulo principal | `var(--font-size-lg)` | + * | `--line-height` | Altura da linha | `var(--line-height-md)` | + * | `--border-radius` | Raio dos cantos dos itens | `var(--border-radius-md)` | + * | `--border-color` | Cor da borda lateral direita do componente | `var(--color-neutral-light-20)` | + * | `--background-color` | Cor de fundo do componente | `var(--color-neutral-light-05)` | + * | `--color` | Cor do texto dos itens | `var(--color-action-default)` | + * | `--color-context-title` | Cor do texto do titulo de contexto | `var(--color-neutral-mid-40)` | + * | `--color-title` | Cor do texto do titulo principal | `var(--color-neutral-dark-80)` | + * | `--font-weight` | Peso da fonte dos itens | `var(--font-weight-bold)` | + * | `--font-weight-title` | Peso da fonte do titulo principal | `var(--font-weight-bold)` | + * | `--outline-color-focused` | Cor do outline no estado de focus | `var(--color-action-focus)` | + * | **Hover** | | | + * | `--color-hover` | Cor do texto no estado hover | `var(--color-brand-01-darkest)` | + * | `--background-color-hover` | Cor de fundo no estado hover | `var(--color-brand-01-lighter)` | + * | **Pressed** | | | + * | `--background-color-pressed` | Cor de fundo no estado pressed | `var(--color-brand-01-light)` | + * | **Active (Selected)** | | | + * | `--background-color-actived` | Cor de fundo do item selecionado | `var(--color-brand-01-lightest)` | + * | `--color-actived` | Cor do texto do item selecionado | `var(--color-action-pressed)` | + * + */ +@Directive() +export class PoContextMenuBaseComponent { + /** + * Titulo do contexto superior + */ + contextTitle = input('', { alias: 'p-context-title' }); + + /** + * Titulo principal do menu + */ + title = input('', { alias: 'p-title' }); + + /** + * Lista de itens para renderizacao. + * + * > Ao receber os itens, o componente valida que apenas um item pode ter `selected: true`. + * > Se mais de um item estiver com `selected: true`, apenas o primeiro sera mantido como selecionado. + */ + items = input>([], { alias: 'p-items' }); + + /** + * Define se o menu estar aberto ou fechado. + * + * Suporta two-way binding: + * + * ```html + * + * ``` + * + * ou + * + * ```html + * + * ``` + * + * @default `true` + */ + expanded = model(true, { alias: 'p-expanded' }); + + /** + * Evento emitido ao selecionar um item. Emite o item selecionado. + */ + itemSelected = output({ alias: 'p-item-selected' }); +} diff --git a/projects/ui/src/lib/components/po-context-menu/po-context-menu-item.interface.ts b/projects/ui/src/lib/components/po-context-menu/po-context-menu-item.interface.ts new file mode 100644 index 0000000000..b28098ddd5 --- /dev/null +++ b/projects/ui/src/lib/components/po-context-menu/po-context-menu-item.interface.ts @@ -0,0 +1,17 @@ +/** + * @usedBy PoContextMenuComponent + * + * @description + * + * Interface para os itens do componente po-context-menu. + */ +export interface PoContextMenuItem { + /** Texto do item de menu. */ + label: string; + + /** Acao executada ao clicar no item. */ + action?: Function; + + /** Estado de selecao do item. */ + selected?: boolean; +} diff --git a/projects/ui/src/lib/components/po-context-menu/po-context-menu.component.html b/projects/ui/src/lib/components/po-context-menu/po-context-menu.component.html new file mode 100644 index 0000000000..294b31e245 --- /dev/null +++ b/projects/ui/src/lib/components/po-context-menu/po-context-menu.component.html @@ -0,0 +1,47 @@ +
+
+ + + @if (expanded()) { +
+ @if (contextTitle()) { + {{ contextTitle() }} + } + @if (title()) { + {{ title() }} + } +
+ } +
+ + @if (expanded()) { + + } +
diff --git a/projects/ui/src/lib/components/po-context-menu/po-context-menu.component.spec.ts b/projects/ui/src/lib/components/po-context-menu/po-context-menu.component.spec.ts new file mode 100644 index 0000000000..28eb9ff6d7 --- /dev/null +++ b/projects/ui/src/lib/components/po-context-menu/po-context-menu.component.spec.ts @@ -0,0 +1,450 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { configureTestSuite } from './../../util-test/util-expect.spec'; + +import { PoIconModule } from '../po-icon/po-icon.module'; +import { PoTooltipModule } from '../../directives'; + +import { PoContextMenuBaseComponent } from './po-context-menu-base.component'; +import { PoContextMenuComponent } from './po-context-menu.component'; +import { PoContextMenuItem } from './po-context-menu-item.interface'; + +describe('PoContextMenuComponent:', () => { + let component: PoContextMenuComponent; + let fixture: ComponentFixture; + let nativeElement: any; + + configureTestSuite(() => { + TestBed.configureTestingModule({ + imports: [PoIconModule, PoTooltipModule], + declarations: [PoContextMenuComponent] + }); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(PoContextMenuComponent); + component = fixture.componentInstance; + nativeElement = fixture.debugElement.nativeElement; + }); + + it('should be created', () => { + expect(component instanceof PoContextMenuBaseComponent).toBeTruthy(); + expect(component instanceof PoContextMenuComponent).toBeTruthy(); + }); + + describe('Properties:', () => { + it('should have expanded as true by default', () => { + expect(component.expanded()).toBe(true); + }); + + it('should have _items as empty array by default', () => { + expect(component['_items']()).toEqual([]); + }); + + it('should have contextTitle as empty string by default', () => { + expect(component.contextTitle()).toBe(''); + }); + + it('should have title as empty string by default', () => { + expect(component.title()).toBe(''); + }); + + it('should have items as empty array by default', () => { + expect(component.items()).toEqual([]); + }); + }); + + describe('Methods:', () => { + describe('toggleExpand:', () => { + it('should toggle expanded from true to false', () => { + component.expanded.set(true); + component.toggleExpand(); + expect(component.expanded()).toBe(false); + }); + + it('should toggle expanded from false to true', () => { + component.expanded.set(false); + component.toggleExpand(); + expect(component.expanded()).toBe(true); + }); + }); + + describe('selectItem:', () => { + it('should set the clicked item as selected and deselect others', () => { + const items: Array = [ + { label: 'Item 1', action: () => {}, selected: true }, + { label: 'Item 2', action: () => {} }, + { label: 'Item 3', action: () => {} } + ]; + fixture.componentRef.setInput('p-items', items); + fixture.detectChanges(); + + const targetItem = items[1]; + component.selectItem(targetItem); + + const updatedItems = component['_items'](); + expect(updatedItems[0].selected).toBe(false); + expect(updatedItems[1].selected).toBe(true); + expect(updatedItems[2].selected).toBe(false); + }); + + it('should call the action of the selected item', () => { + const actionSpy = jasmine.createSpy('action'); + const items: Array = [{ label: 'Item 1', action: actionSpy }]; + fixture.componentRef.setInput('p-items', items); + fixture.detectChanges(); + + component.selectItem(items[0]); + expect(actionSpy).toHaveBeenCalledWith(items[0]); + }); + + it('should not call action when action is undefined', () => { + const items: Array = [{ label: 'Item 1' }]; + fixture.componentRef.setInput('p-items', items); + fixture.detectChanges(); + + expect(() => component.selectItem(items[0])).not.toThrow(); + }); + + it('should emit itemSelected event when an item is selected', () => { + const items: Array = [{ label: 'Item 1', action: () => {} }]; + fixture.componentRef.setInput('p-items', items); + fixture.detectChanges(); + + spyOn(component.itemSelected, 'emit'); + component.selectItem(items[0]); + + expect(component.itemSelected.emit).toHaveBeenCalledWith(items[0]); + }); + + it('should select item by label comparison, not by reference', () => { + const items: Array = [ + { label: 'Item 1', action: () => {} }, + { label: 'Item 2', action: () => {} } + ]; + fixture.componentRef.setInput('p-items', items); + fixture.detectChanges(); + + // Cria um novo objeto com o mesmo label (referencia diferente) + const itemCopy = { label: 'Item 2', action: () => {} }; + component.selectItem(itemCopy); + + const updatedItems = component['_items'](); + expect(updatedItems[0].selected).toBe(false); + expect(updatedItems[1].selected).toBe(true); + }); + }); + + describe('sanitizeSelection (via effect):', () => { + it('should keep only the first selected item when multiple are selected', () => { + const items: Array = [ + { label: 'Item 1', action: () => {}, selected: true }, + { label: 'Item 2', action: () => {}, selected: true }, + { label: 'Item 3', action: () => {}, selected: true } + ]; + fixture.componentRef.setInput('p-items', items); + fixture.detectChanges(); + + const updatedItems = component['_items'](); + expect(updatedItems[0].selected).toBe(true); + expect(updatedItems[1].selected).toBe(false); + expect(updatedItems[2].selected).toBe(false); + }); + + it('should not modify items when only one is selected', () => { + const items: Array = [ + { label: 'Item 1', action: () => {} }, + { label: 'Item 2', action: () => {}, selected: true }, + { label: 'Item 3', action: () => {} } + ]; + fixture.componentRef.setInput('p-items', items); + fixture.detectChanges(); + + const updatedItems = component['_items'](); + expect(updatedItems[0].selected).toBeFalsy(); + expect(updatedItems[1].selected).toBe(true); + expect(updatedItems[2].selected).toBeFalsy(); + }); + + it('should not modify items when none is selected', () => { + const items: Array = [ + { label: 'Item 1', action: () => {} }, + { label: 'Item 2', action: () => {} } + ]; + fixture.componentRef.setInput('p-items', items); + fixture.detectChanges(); + + const updatedItems = component['_items'](); + expect(updatedItems[0].selected).toBeFalsy(); + expect(updatedItems[1].selected).toBeFalsy(); + }); + + it('should sync _items with items input when items change', () => { + const items: Array = [{ label: 'Item 1', action: () => {} }]; + fixture.componentRef.setInput('p-items', items); + fixture.detectChanges(); + + expect(component['_items']().length).toBe(1); + expect(component['_items']()[0].label).toBe('Item 1'); + }); + }); + + describe('handlerTooltip:', () => { + it('should not set tooltip if item already has tooltip', () => { + const item = { label: 'Item 1', tooltip: 'existing tooltip' }; + const li = document.createElement('li'); + const span = document.createElement('span'); + li.appendChild(span); + + component['handlerTooltip'](item as any, li); + expect(item.tooltip).toBe('existing tooltip'); + }); + + it('should set tooltip when label text overflows', () => { + const items = [{ label: 'Long text that overflows' }, { label: 'Short text' }]; + fixture.componentRef.setInput('p-items', items); + fixture.detectChanges(); + + const item = component['_items']()[0]; + const li = document.createElement('li'); + const span = document.createElement('span'); + li.appendChild(span); + + Object.defineProperty(span, 'scrollHeight', { value: 40, configurable: true }); + Object.defineProperty(span, 'offsetHeight', { value: 20, configurable: true }); + + component['handlerTooltip'](item as any, li); + + const updatedItem = component['_items']().find(i => i.label === 'Long text that overflows'); + expect(updatedItem?.tooltip).toBe('Long text that overflows'); + }); + + it('should not set tooltip when label text does not overflow', () => { + const items = [{ label: 'Short text' }]; + fixture.componentRef.setInput('p-items', items); + fixture.detectChanges(); + + const item = component['_items']()[0]; + const li = document.createElement('li'); + const span = document.createElement('span'); + li.appendChild(span); + + Object.defineProperty(span, 'scrollHeight', { value: 20, configurable: true }); + Object.defineProperty(span, 'offsetHeight', { value: 20, configurable: true }); + + component['handlerTooltip'](item as any, li); + + const updatedItem = component['_items']().find(i => i.label === 'Short text'); + expect(updatedItem?.tooltip).toBeUndefined(); + }); + }); + }); + + describe('Templates:', () => { + it('should show context title when expanded and contextTitle has value', () => { + fixture.componentRef.setInput('p-context-title', 'Jornada'); + component.expanded.set(true); + fixture.detectChanges(); + + const contextTitleEl = nativeElement.querySelector('.po-context-menu-context-title'); + expect(contextTitleEl).toBeTruthy(); + expect(contextTitleEl.textContent.trim()).toBe('Jornada'); + }); + + it('should not show context title when contextTitle is empty', () => { + fixture.componentRef.setInput('p-context-title', ''); + component.expanded.set(true); + fixture.detectChanges(); + + expect(nativeElement.querySelector('.po-context-menu-context-title')).toBeFalsy(); + }); + + it('should show title when expanded and title has value', () => { + fixture.componentRef.setInput('p-title', 'Prestador de compra'); + component.expanded.set(true); + fixture.detectChanges(); + + const titleEl = nativeElement.querySelector('.po-context-menu-title'); + expect(titleEl).toBeTruthy(); + expect(titleEl.textContent.trim()).toBe('Prestador de compra'); + }); + + it('should not show title when title is empty', () => { + fixture.componentRef.setInput('p-title', ''); + component.expanded.set(true); + fixture.detectChanges(); + + expect(nativeElement.querySelector('.po-context-menu-title')).toBeFalsy(); + }); + + it('should not show header-content when collapsed', () => { + component.expanded.set(false); + fixture.detectChanges(); + + expect(nativeElement.querySelector('.po-context-menu-header-content')).toBeFalsy(); + }); + + it('should add po-context-menu-collapsed class when not expanded', () => { + component.expanded.set(false); + fixture.detectChanges(); + + expect(nativeElement.querySelector('.po-context-menu-collapsed')).toBeTruthy(); + }); + + it('should not have po-context-menu-collapsed class when expanded', () => { + component.expanded.set(true); + fixture.detectChanges(); + + expect(nativeElement.querySelector('.po-context-menu-collapsed')).toBeFalsy(); + }); + + it('should render items when expanded', () => { + const items: Array = [ + { label: 'Item 1', action: () => {} }, + { label: 'Item 2', action: () => {} } + ]; + fixture.componentRef.setInput('p-items', items); + component.expanded.set(true); + fixture.detectChanges(); + + const listItems = nativeElement.querySelectorAll('.po-context-menu-list-item'); + expect(listItems.length).toBe(2); + }); + + it('should not render items when collapsed', () => { + const items: Array = [{ label: 'Item 1', action: () => {} }]; + fixture.componentRef.setInput('p-items', items); + component.expanded.set(false); + fixture.detectChanges(); + + expect(nativeElement.querySelector('.po-context-menu-list-item')).toBeFalsy(); + }); + + it('should not render nav element when collapsed', () => { + component.expanded.set(false); + fixture.detectChanges(); + + expect(nativeElement.querySelector('.po-context-menu-body')).toBeFalsy(); + }); + + it('should add po-context-menu-item-selected class on selected item', () => { + const items: Array = [ + { label: 'Item 1', action: () => {}, selected: true }, + { label: 'Item 2', action: () => {} } + ]; + fixture.componentRef.setInput('p-items', items); + component.expanded.set(true); + fixture.detectChanges(); + + const selectedItems = nativeElement.querySelectorAll('.po-context-menu-item-selected'); + expect(selectedItems.length).toBe(1); + }); + + it('should display correct label text for each item', () => { + const items: Array = [{ label: 'Alpha' }, { label: 'Beta' }]; + fixture.componentRef.setInput('p-items', items); + component.expanded.set(true); + fixture.detectChanges(); + + const labels = nativeElement.querySelectorAll('.po-context-menu-item-label'); + expect(labels[0].textContent.trim()).toBe('Alpha'); + expect(labels[1].textContent.trim()).toBe('Beta'); + }); + + it('should call selectItem when item is clicked', () => { + const items: Array = [{ label: 'Item 1', action: () => {} }]; + fixture.componentRef.setInput('p-items', items); + component.expanded.set(true); + fixture.detectChanges(); + + spyOn(component, 'selectItem'); + const listItem: HTMLElement = nativeElement.querySelector('.po-context-menu-list-item'); + listItem.click(); + + expect(component.selectItem).toHaveBeenCalled(); + }); + + it('should call selectItem on keydown.enter', () => { + const items: Array = [{ label: 'Item 1', action: () => {} }]; + fixture.componentRef.setInput('p-items', items); + component.expanded.set(true); + fixture.detectChanges(); + + spyOn(component, 'selectItem'); + const listItem: HTMLElement = nativeElement.querySelector('.po-context-menu-list-item'); + listItem.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + + expect(component.selectItem).toHaveBeenCalled(); + }); + + it('should call selectItem on keydown.space', () => { + const items: Array = [{ label: 'Item 1', action: () => {} }]; + fixture.componentRef.setInput('p-items', items); + component.expanded.set(true); + fixture.detectChanges(); + + spyOn(component, 'selectItem'); + const listItem: HTMLElement = nativeElement.querySelector('.po-context-menu-list-item'); + listItem.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })); + + expect(component.selectItem).toHaveBeenCalled(); + }); + + it('should call toggleExpand when toggle button is clicked', () => { + component.expanded.set(true); + fixture.detectChanges(); + + spyOn(component, 'toggleExpand'); + const toggleBtn: HTMLElement = nativeElement.querySelector('.po-context-menu-toggle'); + toggleBtn.click(); + + expect(component.toggleExpand).toHaveBeenCalled(); + }); + + it('should show aria-label "Recolher menu" when expanded', () => { + component.expanded.set(true); + fixture.detectChanges(); + + const toggleBtn = nativeElement.querySelector('.po-context-menu-toggle'); + expect(toggleBtn.getAttribute('aria-label')).toBe('Recolher menu'); + }); + + it('should show aria-label "Expandir menu" when collapsed', () => { + component.expanded.set(false); + fixture.detectChanges(); + + const toggleBtn = nativeElement.querySelector('.po-context-menu-toggle'); + expect(toggleBtn.getAttribute('aria-label')).toBe('Expandir menu'); + }); + + it('should have role="menu" on the list element', () => { + const items: Array = [{ label: 'Item 1' }]; + fixture.componentRef.setInput('p-items', items); + component.expanded.set(true); + fixture.detectChanges(); + + const ul = nativeElement.querySelector('.po-context-menu-list'); + expect(ul.getAttribute('role')).toBe('menu'); + }); + + it('should have role="menuitem" on list items', () => { + const items: Array = [{ label: 'Item 1' }]; + fixture.componentRef.setInput('p-items', items); + component.expanded.set(true); + fixture.detectChanges(); + + const li = nativeElement.querySelector('.po-context-menu-list-item'); + expect(li.getAttribute('role')).toBe('menuitem'); + }); + + it('should have tabindex="0" on list items', () => { + const items: Array = [{ label: 'Item 1' }]; + fixture.componentRef.setInput('p-items', items); + component.expanded.set(true); + fixture.detectChanges(); + + const li = nativeElement.querySelector('.po-context-menu-list-item'); + expect(li.getAttribute('tabindex')).toBe('0'); + }); + }); +}); diff --git a/projects/ui/src/lib/components/po-context-menu/po-context-menu.component.ts b/projects/ui/src/lib/components/po-context-menu/po-context-menu.component.ts new file mode 100644 index 0000000000..1d5c15f2f2 --- /dev/null +++ b/projects/ui/src/lib/components/po-context-menu/po-context-menu.component.ts @@ -0,0 +1,86 @@ +import { ChangeDetectionStrategy, Component, effect, signal } from '@angular/core'; + +import { PoContextMenuBaseComponent } from './po-context-menu-base.component'; +import { PoContextMenuItem } from './po-context-menu-item.interface'; + +interface PoInternalContextMenuItem extends PoContextMenuItem { + tooltip?: string; +} + +/** + * @docsExtends PoContextMenuBaseComponent + * + * @example + * + * + * + * + * + * + * + * + * + * + */ +@Component({ + selector: 'po-context-menu', + templateUrl: './po-context-menu.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false +}) +export class PoContextMenuComponent extends PoContextMenuBaseComponent { + protected _items = signal>([]); + + constructor() { + super(); + + // Valida que apenas um item pode ter selected: true. + // Se mais de um item tiver selected: true, mantem apenas o primeiro. + effect(() => { + const currentItems = this.items(); + if (this.hasMultipleSelected(currentItems)) { + this._items.set(this.sanitizeSelection(currentItems)); + } else { + this._items.set(currentItems); + } + }); + } + + toggleExpand(): void { + this.expanded.update(v => !v); + } + + selectItem(item: PoContextMenuItem): void { + const updatedItems = this._items().map(i => ({ + ...i, + selected: i.label === item.label + })); + this._items.set(updatedItems); + + this.itemSelected.emit(item); + + if (item.action) { + item.action(item); + } + } + + protected handlerTooltip(item: PoInternalContextMenuItem, value: HTMLLIElement): void { + if (item.tooltip) { + return; + } + + const label = value.firstElementChild as HTMLSpanElement; + if (label.scrollHeight > label.offsetHeight) { + this._items.update(items => items.map(i => (i === item ? { ...i, tooltip: item.label } : i))); + } + } + + private hasMultipleSelected(items: Array): boolean { + return items.filter(i => i.selected).length > 1; + } + + private sanitizeSelection(items: Array): Array { + const firstIndex = items.findIndex(i => i.selected); + return items.map((item, index) => (item.selected && index !== firstIndex ? { ...item, selected: false } : item)); + } +} diff --git a/projects/ui/src/lib/components/po-context-menu/po-context-menu.module.ts b/projects/ui/src/lib/components/po-context-menu/po-context-menu.module.ts new file mode 100644 index 0000000000..341736e532 --- /dev/null +++ b/projects/ui/src/lib/components/po-context-menu/po-context-menu.module.ts @@ -0,0 +1,19 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { PoIconModule } from '../po-icon/po-icon.module'; +import { PoTooltipModule } from '../../directives'; + +import { PoContextMenuComponent } from './po-context-menu.component'; + +/** + * @description + * + * Modulo do componente po-context-menu. + */ +@NgModule({ + imports: [CommonModule, PoIconModule, PoTooltipModule], + declarations: [PoContextMenuComponent], + exports: [PoContextMenuComponent] +}) +export class PoContextMenuModule {} diff --git a/projects/ui/src/lib/components/po-context-menu/samples/sample-po-context-menu-basic/sample-po-context-menu-basic.component.html b/projects/ui/src/lib/components/po-context-menu/samples/sample-po-context-menu-basic/sample-po-context-menu-basic.component.html new file mode 100644 index 0000000000..f5c51d797e --- /dev/null +++ b/projects/ui/src/lib/components/po-context-menu/samples/sample-po-context-menu-basic/sample-po-context-menu-basic.component.html @@ -0,0 +1,7 @@ + + diff --git a/projects/ui/src/lib/components/po-context-menu/samples/sample-po-context-menu-basic/sample-po-context-menu-basic.component.ts b/projects/ui/src/lib/components/po-context-menu/samples/sample-po-context-menu-basic/sample-po-context-menu-basic.component.ts new file mode 100644 index 0000000000..a751bc0e36 --- /dev/null +++ b/projects/ui/src/lib/components/po-context-menu/samples/sample-po-context-menu-basic/sample-po-context-menu-basic.component.ts @@ -0,0 +1,21 @@ +import { Component } from '@angular/core'; + +import { PoContextMenuItem } from '@po-ui/ng-components'; + +@Component({ + selector: 'sample-po-context-menu-basic', + templateUrl: './sample-po-context-menu-basic.component.html', + standalone: false +}) +export class SamplePoContextMenuBasicComponent { + menuItems: Array = [ + { label: 'Dados cadastrais', selected: true }, + { label: 'Endereços' }, + { label: 'Dependentes' }, + { label: 'Documentos' } + ]; + + onItemSelected(item: PoContextMenuItem): void { + console.log('Item selecionado:', item.label); + } +} diff --git a/projects/ui/src/lib/components/po-context-menu/samples/sample-po-context-menu-labs/sample-po-context-menu-labs.component.html b/projects/ui/src/lib/components/po-context-menu/samples/sample-po-context-menu-labs/sample-po-context-menu-labs.component.html new file mode 100644 index 0000000000..2b38308c25 --- /dev/null +++ b/projects/ui/src/lib/components/po-context-menu/samples/sample-po-context-menu-labs/sample-po-context-menu-labs.component.html @@ -0,0 +1,43 @@ + + + + + +
+
+ + + + +
+ +
+ + +
+ + + +
+ + + +
+ +
+ +
+
diff --git a/projects/ui/src/lib/components/po-context-menu/samples/sample-po-context-menu-labs/sample-po-context-menu-labs.component.ts b/projects/ui/src/lib/components/po-context-menu/samples/sample-po-context-menu-labs/sample-po-context-menu-labs.component.ts new file mode 100644 index 0000000000..132a81176a --- /dev/null +++ b/projects/ui/src/lib/components/po-context-menu/samples/sample-po-context-menu-labs/sample-po-context-menu-labs.component.ts @@ -0,0 +1,44 @@ +import { Component } from '@angular/core'; + +import { PoContextMenuItem, PoNotificationService } from '@po-ui/ng-components'; + +@Component({ + selector: 'sample-po-context-menu-labs', + templateUrl: './sample-po-context-menu-labs.component.html', + standalone: false +}) +export class SamplePoContextMenuLabsComponent { + contextTitle: string = 'Jornada'; + title: string = 'Prestador de compra'; + expanded: boolean = true; + newItemLabel: string = ''; + + menuItems: Array = [ + { label: 'Dados cadastrais', selected: true }, + { label: 'Endereços' }, + { label: 'Dependentes' } + ]; + + constructor(private poNotification: PoNotificationService) {} + + onItemSelected(item: PoContextMenuItem): void { + this.poNotification.success(`Item selecionado: ${item.label}`); + } + + addItem(): void { + if (!this.newItemLabel) { + return; + } + + this.menuItems = [...this.menuItems, { label: this.newItemLabel }]; + this.newItemLabel = ''; + } + + restore(): void { + this.contextTitle = 'Jornada'; + this.title = 'Prestador de compra'; + this.expanded = true; + this.newItemLabel = ''; + this.menuItems = [{ label: 'Dados cadastrais', selected: true }, { label: 'Endereços' }, { label: 'Dependentes' }]; + } +} diff --git a/projects/ui/src/lib/components/po-icon/po-icon-dictionary.ts b/projects/ui/src/lib/components/po-icon/po-icon-dictionary.ts index d732f4f3d8..de6cf175b9 100644 --- a/projects/ui/src/lib/components/po-icon/po-icon-dictionary.ts +++ b/projects/ui/src/lib/components/po-icon/po-icon-dictionary.ts @@ -62,6 +62,8 @@ export const AnimaliaIconDictionary: { [key: string]: string } = { ICON_REFRESH: 'an an-arrow-clockwise', ICON_SEARCH: 'an an-magnifying-glass', ICON_SETTINGS: 'an an-gear-six', + ICON_SIDEBAR: 'an an-sidebar', + ICON_SIDEBAR_SIMPLES: 'an an-sidebar-simple', ICON_SORT: 'an an-arrows-down-up', ICON_SORT_ASC: 'an an-arrow-up', ICON_SORT_ASCENDING: 'an an-sort-ascending', diff --git a/projects/ui/src/lib/components/po-tabs/po-tab/po-tab-base.component.ts b/projects/ui/src/lib/components/po-tabs/po-tab/po-tab-base.component.ts index 12fed0004e..0911126c5d 100644 --- a/projects/ui/src/lib/components/po-tabs/po-tab/po-tab-base.component.ts +++ b/projects/ui/src/lib/components/po-tabs/po-tab/po-tab-base.component.ts @@ -1,4 +1,4 @@ -import { EventEmitter, Input, Output, Directive } from '@angular/core'; +import { EventEmitter, Input, Output, Directive, output } from '@angular/core'; import { convertToBoolean, uuid } from '../../../utils/util'; @@ -45,8 +45,18 @@ export abstract class PoTabBaseComponent { */ @Output('p-close-tab') closeTab = new EventEmitter(); + /** + * @optional + * + * @description + * + * Evento emitido quando a aba e ativada. + * Emite a instancia do componente da aba que foi ativada. + */ + activatedTab = output({ alias: 'p-activated-tab' }); + // ID da aba - id?: string = uuid(); + @Input('id') id: string = uuid(); private _active?: boolean = false; private _disabled?: boolean = false; diff --git a/projects/ui/src/lib/components/po-tabs/po-tab/po-tab.base.component.spec.ts b/projects/ui/src/lib/components/po-tabs/po-tab/po-tab.base.component.spec.ts index 59c503e9ff..0305ab35b3 100644 --- a/projects/ui/src/lib/components/po-tabs/po-tab/po-tab.base.component.spec.ts +++ b/projects/ui/src/lib/components/po-tabs/po-tab/po-tab.base.component.spec.ts @@ -1,4 +1,5 @@ import { Directive } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; import { expectPropertiesValues } from '../../../util-test/util-expect.spec'; @@ -10,7 +11,12 @@ class PoTabComponent extends PoTabBaseComponent { } describe('PoTabBaseComponent', () => { - const component: PoTabComponent = new PoTabComponent(); + let component: PoTabComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({}).compileComponents(); + component = TestBed.runInInjectionContext(() => new PoTabComponent()); + }); it('should be created', () => { expect(component).toBeTruthy(); diff --git a/projects/ui/src/lib/components/po-tabs/po-tabs.component.spec.ts b/projects/ui/src/lib/components/po-tabs/po-tabs.component.spec.ts index 8f52ff1960..79ebe1c7c3 100644 --- a/projects/ui/src/lib/components/po-tabs/po-tabs.component.spec.ts +++ b/projects/ui/src/lib/components/po-tabs/po-tabs.component.spec.ts @@ -109,6 +109,8 @@ describe('PoTabsComponent:', () => { it('onTabActive: should set `previousActiveTab` and call `deactivateTab`', () => { component['previousActiveTab'] = undefined; + defaultTab.activatedTab = { emit: () => {} }; + activeTab.activatedTab = { emit: () => {} }; component.tabsChildren = [defaultTab, activeTab]; const tab = component.tabsChildren[0]; @@ -514,6 +516,7 @@ describe('PoTabsComponent:', () => { it('onTabActiveByDropdown: should correctly call methods and update styles when called', () => { const tabMock = jasmine.createSpyObj('PoTabComponent', ['id'], { id: 'tab-id' }); tabMock.click = { emit: () => {} }; + tabMock.activatedTab = { emit: () => {} }; component.defaultLastTabWidth = 100; const nativeElementMock = { style: { width: '' }, getBoundingClientRect: () => ({ width: 100 }) }; @@ -706,5 +709,38 @@ describe('PoTabsComponent:', () => { expect(lastTabs.widthButton).toBe(200); }); + + describe('selectedTab', () => { + it('should call selectedTab when the tab id is found in the array', () => { + const mockTab = { id: 'tab-perfil', title: 'Perfil' }; + const mockTabsArray = [{ id: 'tab-home', title: 'Home' }, mockTab]; + + Object.defineProperty(component, 'tabsChildrenArray', { + get: () => mockTabsArray, + configurable: true + }); + + const spySelectedTab = spyOn(component, 'selectedTab'); + + component.focusTab('tab-perfil'); + + expect(spySelectedTab).toHaveBeenCalledWith(mockTab); + }); + + it('should not call selectedTab if the id does not exist in the array', () => { + const mockTabsArray = [{ id: 'tab-home' }]; + + Object.defineProperty(component, 'tabsChildrenArray', { + get: () => mockTabsArray, + configurable: true + }); + + const spySelectedTab = spyOn(component, 'selectedTab'); + + component.focusTab('id-inexistente'); + + expect(spySelectedTab).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/projects/ui/src/lib/components/po-tabs/po-tabs.component.ts b/projects/ui/src/lib/components/po-tabs/po-tabs.component.ts index 6d1e62b404..f51572643e 100644 --- a/projects/ui/src/lib/components/po-tabs/po-tabs.component.ts +++ b/projects/ui/src/lib/components/po-tabs/po-tabs.component.ts @@ -216,6 +216,7 @@ export class PoTabsComponent extends PoTabsBaseComponent implements OnInit, Afte // Função disparada quando alguma tab ficar ativa onTabActive(tab: PoTabComponent) { this.previousActiveTab = this.tabsChildren.find(tabChild => tabChild.active && tabChild.id !== tab.id); + tab.activatedTab.emit(tab); this.deactivateTab(); } @@ -500,4 +501,31 @@ export class PoTabsComponent extends PoTabsBaseComponent implements OnInit, Afte this.setTabIndex(tabRemoveElements[0], 0); } } + + /** + * Ativa a aba correspondente ao `id` informado. + * + * Para utiliza-la e necessario ter a instancia do componente no DOM, podendo ser utilizado o ViewChild da seguinte forma: + * + * ``` + * import { PoTabsComponent } from '@po-ui/ng-components'; + * + * ... + * + * @ViewChild('poTab', { static: true }) poTab: PoTabsComponent; + * + * focusOnTab() { + * this.poTab.focusTab('meu-id-da-aba'); + * } + * ``` + * + * @param id Identificador unico da aba a ser ativada. + */ + public focusTab(id: string) { + const tab = this.tabsChildrenArray.find(x => x.id === id); + + if (tab) { + this.selectedTab(tab); + } + } }