diff --git a/projects/ui/src/lib/components/po-dropdown/po-dropdown-action.interface.ts b/projects/ui/src/lib/components/po-dropdown/po-dropdown-action.interface.ts index 639d917b7..ff1a21d36 100644 --- a/projects/ui/src/lib/components/po-dropdown/po-dropdown-action.interface.ts +++ b/projects/ui/src/lib/components/po-dropdown/po-dropdown-action.interface.ts @@ -8,4 +8,15 @@ import { PoPopupAction } from '../po-popup/po-popup-action.interface'; * * @usedBy PoDropdownComponent */ -export interface PoDropdownAction extends PoPopupAction {} +export interface PoDropdownAction extends PoPopupAction { + /** + * Array de ações (`PoDropdownAction`) usado para criar agrupadores de subitens. + * + * - Permite a criação de menus aninhados (submenus). + * + * > Boas práticas de desenvolvimento: + * Recomenda-se limitar a navegação a, no máximo, três níveis hierárquicos. + * Isso evita sobrecarga cognitiva, facilita a memorização da estrutura e garante uma melhor experiência de uso. + */ + subItems?: Array; +} diff --git a/projects/ui/src/lib/components/po-dropdown/po-dropdown.component.html b/projects/ui/src/lib/components/po-dropdown/po-dropdown.component.html index 3e2fc32e0..ae8431791 100644 --- a/projects/ui/src/lib/components/po-dropdown/po-dropdown.component.html +++ b/projects/ui/src/lib/components/po-dropdown/po-dropdown.component.html @@ -26,5 +26,6 @@ [p-custom-positions]="['bottom-left', 'top-left']" [p-size]="size" [p-target]="dropdownRef" + [p-listbox-subitems]="true" > diff --git a/projects/ui/src/lib/components/po-dropdown/po-dropdown.component.ts b/projects/ui/src/lib/components/po-dropdown/po-dropdown.component.ts index 9178d818b..199b8cf26 100644 --- a/projects/ui/src/lib/components/po-dropdown/po-dropdown.component.ts +++ b/projects/ui/src/lib/components/po-dropdown/po-dropdown.component.ts @@ -22,6 +22,11 @@ import { PoDropdownBaseComponent } from './po-dropdown-base.component'; * * * + * + * + * + * + * * * * @@ -59,7 +64,7 @@ export class PoDropdownComponent extends PoDropdownBaseComponent { } private checkClickArea(event: MouseEvent) { - return this.dropdownRef && this.dropdownRef.nativeElement.contains(event.target); + return this.dropdownRef?.nativeElement.contains(event.target); } private hideDropdown() { diff --git a/projects/ui/src/lib/components/po-dropdown/samples/sample-po-dropdown-labs/sample-po-dropdown-labs.component.html b/projects/ui/src/lib/components/po-dropdown/samples/sample-po-dropdown-labs/sample-po-dropdown-labs.component.html index 1ef755d82..3b9609397 100644 --- a/projects/ui/src/lib/components/po-dropdown/samples/sample-po-dropdown-labs/sample-po-dropdown-labs.component.html +++ b/projects/ui/src/lib/components/po-dropdown/samples/sample-po-dropdown-labs/sample-po-dropdown-labs.component.html @@ -12,18 +12,28 @@
- + - + - + - + + + + ; + parentList: Array; label: string; properties: Array; size: string; @@ -52,16 +53,72 @@ export class SamplePoDropdownLabsComponent implements OnInit { this.restore(); } - addAction(action: PoDropdownAction) { - const newAction = Object.assign({}, action); + addAction(action: PoDropdownAction & { parent?: string }) { + const newAction: PoDropdownAction = { ...action }; newAction.action = newAction.action ? this.showAction.bind(this, newAction.action) : undefined; - this.actions.push(newAction); + + if (!action.parent) { + this.actions = [...this.actions, newAction]; + } else { + const parentNode = this.getActionNode(this.actions, action.parent); + if (parentNode) { + parentNode.subItems = [...(parentNode.subItems || []), newAction]; + } else { + this.actions = [...this.actions, newAction]; + } + } + + this.actions = [].concat(this.actions); + this.parentList = this.updateParentList(this.actions); this.restoreActionForm(); } + private getActionNode(items: Array, value: string): PoDropdownAction | undefined { + if (!items || !Array.isArray(items) || !value) { + return undefined; + } + + for (const item of items) { + if (item.label === value || (item as any).value === value) { + return item; + } + + if (item.subItems && Array.isArray(item.subItems)) { + const found = this.getActionNode(item.subItems, value); + if (found) { + return found; + } + } + } + + return undefined; + } + + private updateParentList( + items: Array, + level = 0, + parentList: Array = [] + ): Array { + if (!items || !Array.isArray(items)) { + return parentList; + } + + items.forEach(item => { + const { label } = item; + parentList.push({ label: `${'-'.repeat(level)} ${label}`, value: label }); + + if (item.subItems && Array.isArray(item.subItems)) { + this.updateParentList(item.subItems, level + 1, parentList); + } + }); + + return parentList; + } + restore() { this.actions = []; + this.parentList = []; this.label = 'PO Dropdown'; this.size = 'medium'; this.properties = []; @@ -71,8 +128,9 @@ export class SamplePoDropdownLabsComponent implements OnInit { restoreActionForm() { this.action = { label: undefined, - visible: null - }; + visible: null, + parent: undefined + } as any; } showAction(label: string): void { diff --git a/projects/ui/src/lib/components/po-dropdown/samples/sample-po-dropdown-subitems/sample-po-dropdown-subitems.component.html b/projects/ui/src/lib/components/po-dropdown/samples/sample-po-dropdown-subitems/sample-po-dropdown-subitems.component.html new file mode 100644 index 000000000..73a6209f0 --- /dev/null +++ b/projects/ui/src/lib/components/po-dropdown/samples/sample-po-dropdown-subitems/sample-po-dropdown-subitems.component.html @@ -0,0 +1 @@ + diff --git a/projects/ui/src/lib/components/po-dropdown/samples/sample-po-dropdown-subitems/sample-po-dropdown-subitems.component.ts b/projects/ui/src/lib/components/po-dropdown/samples/sample-po-dropdown-subitems/sample-po-dropdown-subitems.component.ts new file mode 100644 index 000000000..8c14a7db0 --- /dev/null +++ b/projects/ui/src/lib/components/po-dropdown/samples/sample-po-dropdown-subitems/sample-po-dropdown-subitems.component.ts @@ -0,0 +1,34 @@ +import { Component } from '@angular/core'; +import { PoDropdownAction } from '@po-ui/ng-components'; + +@Component({ + selector: 'sample-po-dropdown-subitems', + templateUrl: './sample-po-dropdown-subitems.component.html', + standalone: false +}) +export class SamplePoDropdownSubitemsComponent { + actions: Array = [ + { label: 'New Sale', action: () => console.log('New Sale') }, + { label: 'New Cancellation', action: () => console.log('New Cancellation'), type: 'danger' }, + { + label: 'Reports', + subItems: [ + { label: 'Monthly Sales', action: () => console.log('Monthly Sales'), icon: 'an an-chart-line-up' }, + { label: 'Annual Sales', action: () => console.log('Annual Sales'), icon: 'an an-chart-line-up' } + ] + }, + { + label: 'Settings', + subItems: [ + { label: 'Users', action: () => console.log('Users') }, + { + label: 'System', + subItems: [ + { label: 'Backup', action: () => console.log('Backup') }, + { label: 'Logs', action: () => console.log('Logs') } + ] + } + ] + } + ]; +} diff --git a/projects/ui/src/lib/components/po-listbox/interfaces/po-listbox-literals.interface.ts b/projects/ui/src/lib/components/po-listbox/interfaces/po-listbox-literals.interface.ts index 1d682778a..6918548a4 100644 --- a/projects/ui/src/lib/components/po-listbox/interfaces/po-listbox-literals.interface.ts +++ b/projects/ui/src/lib/components/po-listbox/interfaces/po-listbox-literals.interface.ts @@ -6,6 +6,9 @@ * Interface para definição de literais utilizadas no `po-listbox` */ export interface PoListBoxLiterals { + /** Texto do botão para voltar ao agrupador anterior. */ + backToPreviousGroup?: string; + /** Texto exibido quando não houver itens na lista */ noItems?: string; diff --git a/projects/ui/src/lib/components/po-listbox/po-item-list/po-item-list-base.component.ts b/projects/ui/src/lib/components/po-listbox/po-item-list/po-item-list-base.component.ts index 24c901961..f98f84454 100644 --- a/projects/ui/src/lib/components/po-listbox/po-item-list/po-item-list-base.component.ts +++ b/projects/ui/src/lib/components/po-listbox/po-item-list/po-item-list-base.component.ts @@ -58,7 +58,9 @@ export class PoItemListBaseComponent { @Input('p-label') label: string; /** Tamanho do texto exibido. */ - @Input('p-size') size: string; + @HostBinding('attr.p-size') + @Input('p-size') + size: string; /** Valor do item. */ @Input('p-value') value: string; @@ -118,6 +120,9 @@ export class PoItemListBaseComponent { */ @Input('p-icon') icon: string | TemplateRef; + // Define a posição do ícone: 'left' (padrão) ou 'right'. + @Input('p-icon-position') iconPosition: 'left' | 'right' = 'left'; + /** * @optional * diff --git a/projects/ui/src/lib/components/po-listbox/po-item-list/po-item-list.component.html b/projects/ui/src/lib/components/po-listbox/po-item-list/po-item-list.component.html index c40446dc0..b78bfb54b 100644 --- a/projects/ui/src/lib/components/po-listbox/po-item-list/po-item-list.component.html +++ b/projects/ui/src/lib/components/po-listbox/po-item-list/po-item-list.component.html @@ -12,10 +12,18 @@ [class.po-item-list__danger]="danger" class="po-item-list po-item-list__action" > - @if (icon) { - + @if (icon && iconPosition === 'left') { + } {{ label }} + + @if (icon && iconPosition === 'right') { + + } } @case ('option') { diff --git a/projects/ui/src/lib/components/po-listbox/po-listbox-base.component.ts b/projects/ui/src/lib/components/po-listbox/po-listbox-base.component.ts index 8b6927033..7013d9109 100644 --- a/projects/ui/src/lib/components/po-listbox/po-listbox-base.component.ts +++ b/projects/ui/src/lib/components/po-listbox/po-listbox-base.component.ts @@ -1,4 +1,4 @@ -import { Directive, EventEmitter, Input, Output, TemplateRef } from '@angular/core'; +import { Directive, EventEmitter, HostBinding, Input, Output, TemplateRef } from '@angular/core'; import { poLocaleDefault } from '../../services/po-language/po-language.constant'; import { PoLanguageService } from '../../services/po-language/po-language.service'; @@ -14,15 +14,19 @@ import { PoItemListOption } from './po-item-list/interfaces/po-item-list-option. export const poListBoxLiteralsDefault = { en: { + backToPreviousGroup: 'Go back to the previous list', noItems: 'No items found' }, es: { + backToPreviousGroup: 'Volver a la lista anterior', noItems: 'No se encontraron artículos' }, pt: { + backToPreviousGroup: 'Voltar para a lista anterior', noItems: 'Nenhum item encontrado' }, ru: { + backToPreviousGroup: 'Вернуться к предыдущему списку', noItems: 'ничего не найдено' } }; @@ -39,6 +43,8 @@ export class PoListBoxBaseComponent { private language: string = poLocaleDefault; private _size?: string = undefined; + @Input('p-listbox-subitems') listboxSubitems = false; + @Input({ alias: 'p-visible', transform: convertToBoolean }) visible: boolean = false; @Input('p-type') set type(value: string) { @@ -82,22 +88,6 @@ export class PoListBoxBaseComponent { // parâmetro que pode ser passado para o popup ao clicar em um item @Input('p-param') param?; - @Output('p-select-item') selectItem = new EventEmitter(); - - @Output('p-close') closeEvent = new EventEmitter(); - // MULTISELECT PROPERTIES - - //output para evento do checkbox - @Output('p-change') change = new EventEmitter(); - - //output para evento do checkbox - @Output('p-selectcombo-item') selectCombo = new EventEmitter(); - - //output para evento do checkbox de selecionar todos - @Output('p-change-all') changeAll = new EventEmitter(); - - @Output('p-update-infinite-scroll') UpdateInfiniteScroll = new EventEmitter(); - //valor do checkbox de selecionar todos @Input('p-checkboxAllValue') checkboxAllValue: any; @@ -111,9 +101,6 @@ export class PoListBoxBaseComponent { @Input('p-field-label') fieldLabel: string = 'label'; - // Evento disparado a cada tecla digitada na pesquisa. - @Output('p-change-search') changeSearch = new EventEmitter(); - // Propriedade que recebe as literais definidas no componente `po-multiselect`. @Input('p-literal-search') literalSearch?: any; @@ -151,7 +138,9 @@ export class PoListBoxBaseComponent { @Input('p-should-mark-letter') shouldMarkLetters: boolean = true; - @Input('p-size') set size(value: string) { + @HostBinding('attr.p-size') + @Input('p-size') + set size(value: string) { this._size = validateSizeFn(value, PoFieldSize); } @@ -177,6 +166,25 @@ export class PoListBoxBaseComponent { // Define se haverá ou não um separador entre todos os itens do listbox @Input('p-separator') separator: boolean = false; + // Evento disparado a cada tecla digitada na pesquisa. + @Output('p-change-search') changeSearch = new EventEmitter(); + + @Output('p-select-item') selectItem = new EventEmitter(); + + @Output('p-close') closeEvent = new EventEmitter(); + // MULTISELECT PROPERTIES + + //output para evento do checkbox + @Output('p-change') change = new EventEmitter(); + + //output para evento do checkbox + @Output('p-selectcombo-item') selectCombo = new EventEmitter(); + + //output para evento do checkbox de selecionar todos + @Output('p-change-all') changeAll = new EventEmitter(); + + @Output('p-update-infinite-scroll') UpdateInfiniteScroll = new EventEmitter(); + // Evento disparado quando uma tab é ativada @Output('p-activated-tabs') activatedTab = new EventEmitter(); diff --git a/projects/ui/src/lib/components/po-listbox/po-listbox.component.html b/projects/ui/src/lib/components/po-listbox/po-listbox.component.html index aa2f75239..7d0c2a3a6 100644 --- a/projects/ui/src/lib/components/po-listbox/po-listbox.component.html +++ b/projects/ui/src/lib/components/po-listbox/po-listbox.component.html @@ -21,7 +21,7 @@ > } - @if (checkTemplate()) { + @if (checkTemplate() && !listboxSubitems) {
    @if (type === 'check' && items.length && !searchElement?.inputValue && !hideSelectAll) {
  • } - @if (returnBooleanValue(item, 'visible') !== false && !item.options) { + @if (returnBooleanValue(item, 'visible') !== false) { + @if (!currentGroup) { + @for (item of currentItems; track item) { +
  • + @if (item.subItems?.length) { + + + } @else if (!item.subItems?.length && returnBooleanValue(item, 'visible') !== false) { + + + } +
  • + } + } + + @if (currentGroup) { +
  • + + +
  • + + @for (subItem of currentItems; track subItem.label) { + @if (subItem.subItems?.length) { +
  • + + +
  • + } @else if (!subItem.subItems?.length && returnBooleanValue(subItem, 'visible') !== false) { +
  • + + +
  • + } + } + } +
+ } + @if (isServerSearching && type !== 'action') {
diff --git a/projects/ui/src/lib/components/po-listbox/po-listbox.component.spec.ts b/projects/ui/src/lib/components/po-listbox/po-listbox.component.spec.ts index 488b4a0f3..e0b9d5b3f 100644 --- a/projects/ui/src/lib/components/po-listbox/po-listbox.component.spec.ts +++ b/projects/ui/src/lib/components/po-listbox/po-listbox.component.spec.ts @@ -1,7 +1,8 @@ -import { ElementRef, NO_ERRORS_SCHEMA, SimpleChange } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ElementRef, NO_ERRORS_SCHEMA, QueryList, SimpleChange } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { PoListBoxComponent } from './po-listbox.component'; +import { PoDropdownAction } from '../po-dropdown'; import * as UtilFunctions from './../../utils/util'; import { Subscription, debounceTime, fromEvent, of } from 'rxjs'; @@ -173,6 +174,30 @@ describe('PoListBoxComponent', () => { }); describe('ngAfterViewInit:', () => { + it('should focus the first item and dispatch focus event when listboxSubitems is defined', fakeAsync(() => { + component.listboxSubitems = true; + + const mockElement = document.createElement('div'); + spyOn(mockElement, 'focus'); + spyOn(mockElement, 'dispatchEvent'); + + component.listboxItems = { + first: new ElementRef(mockElement) + } as QueryList; + + spyOn(globalThis, 'requestAnimationFrame').and.callFake((cb: FrameRequestCallback) => { + cb(0); + return 0; + }); + + component.ngAfterViewInit(); + + tick(); + + expect(mockElement.focus).toHaveBeenCalled(); + expect(mockElement.dispatchEvent).toHaveBeenCalledWith(jasmine.any(FocusEvent)); + })); + it('should have been called', () => { spyOn(component, 'setListBoxMaxHeight'); @@ -388,9 +413,140 @@ describe('PoListBoxComponent', () => { expect(item.action).toHaveBeenCalled(); expect(component['openUrl']).not.toHaveBeenCalled(); }); + + it('should call openGroup when item has subItems', () => { + const item = { + label: 'Group', + value: 'group1', + subItems: [{ label: 'SubItem 1', value: 1 }] + }; + const event = new MouseEvent('click'); + + spyOn(component, 'openGroup'); + + component.onSelectItem(item, event); + + expect(component.openGroup).toHaveBeenCalledWith(item, event); + }); + + it('should emit closeEvent when there are no subItems and listboxSubitems is defined', () => { + const mockItem = { label: 'Item sem subitems' } as any; + component.listboxSubitems = true; + + const emitSpy = spyOn(component.closeEvent, 'emit'); + + component.onSelectItem(mockItem); + + expect(emitSpy).toHaveBeenCalled(); + }); }); }); + describe('openGroup and goBack:', () => { + beforeEach(() => { + spyOn(window, 'requestAnimationFrame').and.callFake((cb: FrameRequestCallback) => { + cb(0); + return 0; + }); + }); + + it('openGroup should set currentGroup/currentItems and focus first item', done => { + component.listboxGroupHeader = new ElementRef(document.createElement('div')); + component.currentGroup = null; + component.currentItems = []; + + const group = { label: 'Group', subItems: [{ label: 'Sub' }] } as PoDropdownAction; + + spyOn(component.listboxGroupHeader.nativeElement, 'focus'); + + component.openGroup(group, new MouseEvent('click')); + + expect(component.currentGroup).toBe(group); + expect(component.currentItems).toEqual(group.subItems); + + setTimeout(() => { + expect(component.listboxGroupHeader.nativeElement.focus).toHaveBeenCalled(); + done(); + }, 20); + }); + + it('goBack should restore previous group/items or default items and focus first item', done => { + const firstItemEl = document.createElement('li'); + component.listboxItems = { + first: { nativeElement: firstItemEl } + } as any; + component.items = [{ label: 'Item1' }]; + + spyOn(firstItemEl, 'focus'); + + component.goBack(new MouseEvent('click')); + expect(component.currentGroup).toBeNull(); + expect(component.currentItems).toEqual(component.items); + + setTimeout(() => { + expect(firstItemEl.focus).toHaveBeenCalled(); + done(); + }, 20); + }); + + it('should handle openGroup and goBack correctly even with multiple groups and empty subItems', () => { + component.listboxGroupHeader = new ElementRef(document.createElement('div')); + spyOn(component.listboxGroupHeader.nativeElement, 'focus'); + + const group1 = { label: 'Group1', subItems: [{ label: 'Sub1' }] } as PoDropdownAction; + component.openGroup(group1); + + expect(component.currentGroup).toEqual(group1); + expect(component.currentItems).toEqual(group1.subItems); + expect((component as any).navigationStack.length).toBe(1); + + const group2 = { label: 'Group2', subItems: [{ label: 'Sub2' }] } as PoDropdownAction; + component.openGroup(group2); + + expect(component.currentGroup).toEqual(group2); + expect(component.currentItems).toEqual(group2.subItems); + expect((component as any).navigationStack.length).toBe(2); + + component.goBack(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(component.currentGroup).toEqual(group1); + expect(component.currentItems).toEqual(group1.subItems); + expect((component as any).navigationStack.length).toBe(1); + + const groupWithoutSubItems = { label: 'EmptyGroup' } as PoDropdownAction; + component.openGroup(groupWithoutSubItems); + + expect(component.currentGroup).toEqual(groupWithoutSubItems); + expect(component.currentItems).toEqual([]); + expect((component as any).navigationStack.length).toBe(2); + }); + + it('onKeydownGoBack should call goBack on Enter, emit closeEvent on Escape or Tab', () => { + const eventEnter = new KeyboardEvent('keydown', { key: 'Enter' }); + const eventEscape = new KeyboardEvent('keydown', { code: 'Escape' }); + const eventTab = new KeyboardEvent('keydown', { code: 'Tab' }); + + spyOn(component, 'goBack'); + spyOn(component.closeEvent, 'emit'); + + component.onKeydownGoBack(eventEnter); + expect(component.goBack).toHaveBeenCalledWith(eventEnter); + + component.onKeydownGoBack(eventEscape); + expect(component.closeEvent.emit).toHaveBeenCalled(); + + component.onKeydownGoBack(eventTab); + expect(component.closeEvent.emit).toHaveBeenCalledTimes(2); + }); + }); + it('ngOnInit should set currentItems to items if listboxSubitems is true', () => { + component.listboxSubitems = true; + component.items = [{ label: 'Item 1', value: 1 }]; + + component.ngOnInit(); + + expect(component.currentItems).toEqual(component.items); + }); + describe('onSelectTabs:', () => { it('Should emit if changeStateTabs if `isTabs` and has tab', () => { component.isTabs = true; @@ -566,6 +722,38 @@ describe('PoListBoxComponent', () => { 'maxHeight' ); }); + + it('should set maxHeight to dropdownMaxHeight when listboxSubitems is true', () => { + spyOn(component['renderer'], 'setStyle'); + + component.listboxSubitems = true; + component.listbox = new ElementRef(document.createElement('div')); + + component['setListBoxMaxHeight'](); + + expect(component['renderer'].setStyle).toHaveBeenCalledWith( + component.listbox.nativeElement, + 'maxHeight', + '400px' + ); + }); + }); + + it('should set minWidth and maxWidth when listboxSubitems is true and items exist', () => { + spyOn(component['renderer'], 'setStyle'); + + component.listboxSubitems = true; + component.items = [ + { label: 'Item 1', value: 1 }, + { label: 'Item 2', value: 2 } + ]; + component.listbox = new ElementRef(document.createElement('div')); + + component['setListBoxWidth'](); + + expect(component['renderer'].setStyle).toHaveBeenCalledWith(component.listbox.nativeElement, 'minWidth', '240px'); + + expect(component['renderer'].setStyle).toHaveBeenCalledWith(component.listbox.nativeElement, 'maxWidth', '340px'); }); describe('checkboxClicked:', () => { diff --git a/projects/ui/src/lib/components/po-listbox/po-listbox.component.ts b/projects/ui/src/lib/components/po-listbox/po-listbox.component.ts index 6bf380fe4..5820fe1e4 100644 --- a/projects/ui/src/lib/components/po-listbox/po-listbox.component.ts +++ b/projects/ui/src/lib/components/po-listbox/po-listbox.component.ts @@ -5,20 +5,23 @@ import { ElementRef, OnChanges, OnDestroy, + OnInit, + QueryList, Renderer2, SimpleChanges, ViewChild, + ViewChildren, inject } from '@angular/core'; import { Router } from '@angular/router'; import { PoListBoxBaseComponent } from './po-listbox-base.component'; - import { PoItemListOptionGroup } from './po-item-list/interfaces/po-item-list-option-group.interface'; import { PoItemListOption } from './po-item-list/interfaces/po-item-list-option.interface'; import { PoLanguageService } from '../../services/po-language/po-language.service'; import { isExternalLink, isTypeof, openExternalLink } from '../../utils/util'; import { PoSearchListComponent } from './po-search-list/po-search-list.component'; +import { PoDropdownAction } from '../po-dropdown/po-dropdown-action.interface'; import { Observable, Subscription, debounceTime, fromEvent } from 'rxjs'; import { PoFieldSize } from '../../enums/po-field-size.enum'; @@ -27,16 +30,22 @@ import { PoFieldSize } from '../../enums/po-field-size.enum'; templateUrl: './po-listbox.component.html', standalone: false }) -export class PoListBoxComponent extends PoListBoxBaseComponent implements AfterViewInit, OnChanges, OnDestroy { +export class PoListBoxComponent extends PoListBoxBaseComponent implements OnInit, AfterViewInit, OnChanges, OnDestroy { element = inject(ElementRef); + public currentItems: Array = []; + public currentGroup: PoDropdownAction | null = null; + + private readonly navigationStack: Array<{ group: PoDropdownAction | null; items: Array }> = []; private readonly renderer = inject(Renderer2); private readonly router = inject(Router); private readonly changeDetector = inject(ChangeDetectorRef); @ViewChild('listbox', { static: true }) listbox: ElementRef; @ViewChild('listboxItemList', { static: false }) listboxItemList: ElementRef; + @ViewChild('listboxGroupHeader') listboxGroupHeader: ElementRef; @ViewChild('searchElement') searchElement: PoSearchListComponent; @ViewChild('popupHeaderContainer') popupHeaderContainer: ElementRef; + @ViewChildren('listboxItem') listboxItems!: QueryList; private scrollEvent$: Observable; private subscriptionScrollEvent: Subscription; @@ -47,15 +56,35 @@ export class PoListBoxComponent extends PoListBoxBaseComponent implements AfterV super(languageService); } + ngOnInit(): void { + if (this.listboxSubitems) { + this.currentItems = this.items; + } + } + ngAfterViewInit(): void { this.setListBoxMaxHeight(); + this.setListBoxWidth(); this.listboxItemList?.nativeElement.focus(); + if (this.listboxSubitems) { + requestAnimationFrame(() => { + const firstItem = this.listboxItems?.first.nativeElement; + if (firstItem) { + firstItem.focus(); + + setTimeout(() => { + firstItem.dispatchEvent(new FocusEvent('focus', { bubbles: true })); + }, 0); + } + }); + } this.changeDetector.detectChanges(); } ngOnChanges(changes?: SimpleChanges): void { if (changes?.items) { this.setListBoxMaxHeight(); + this.setListBoxWidth(); } if (this.visible && this.infiniteScroll) { @@ -69,7 +98,67 @@ export class PoListBoxComponent extends PoListBoxBaseComponent implements AfterV } } - onSelectItem(itemListAction: PoItemListOption | PoItemListOptionGroup | any) { + public openGroup(group: PoDropdownAction, event?: MouseEvent | KeyboardEvent): void { + event?.stopPropagation(); + + this.navigationStack.push({ + group: this.currentGroup, + items: this.currentItems + }); + + this.currentGroup = group; + this.currentItems = group.subItems || []; + + requestAnimationFrame(() => { + const firstItem = this.listboxGroupHeader?.nativeElement; + if (firstItem) { + firstItem.focus(); + + setTimeout(() => { + firstItem.dispatchEvent(new FocusEvent('focus', { bubbles: true })); + }, 0); + } + }); + } + + public goBack(event: MouseEvent | KeyboardEvent): void { + event?.stopPropagation(); + + const previous = this.navigationStack.pop(); + + if (previous) { + this.currentGroup = previous.group; + this.currentItems = previous.items; + } else { + this.currentGroup = null; + this.currentItems = this.items; + } + + this.clickItem.emit({ goBack: true }); + + requestAnimationFrame(() => { + const firstItem = this.listboxItems?.first?.nativeElement; + if (firstItem) { + firstItem.focus(); + + setTimeout(() => { + firstItem.dispatchEvent(new FocusEvent('focus', { bubbles: true })); + }, 0); + } + }); + } + + public onKeydownGoBack(event: KeyboardEvent): void { + if (event.key === 'Enter') { + this.goBack(event); + } + + if (event?.code === 'Escape' || event.code === 'Tab') { + this.closeEvent.emit(); + } + } + + onSelectItem(itemListAction: PoItemListOption | PoItemListOptionGroup | any, event?: MouseEvent | KeyboardEvent) { const isDisabled = itemListAction.hasOwnProperty('disabled') && itemListAction.disabled !== null && @@ -87,14 +176,21 @@ export class PoListBoxComponent extends PoListBoxBaseComponent implements AfterV this.onClickTabs(itemListAction); } - if (itemListAction && itemListAction.action && !isDisabled && isVisible) { + if (itemListAction?.action && !isDisabled && isVisible) { + console.log('oi'); itemListAction.action(this.param || itemListAction); } - if (itemListAction && itemListAction.url && !isDisabled && isVisible) { + if (itemListAction?.url && !isDisabled && isVisible) { return this.openUrl(itemListAction.url); } + if (itemListAction?.subItems?.length) { + this.openGroup(itemListAction, event); + } else if (this.listboxSubitems) { + this.closeEvent.emit(); + } + if (!isDisabled) { this.clickItem.emit(itemListAction); } @@ -107,7 +203,7 @@ export class PoListBoxComponent extends PoListBoxBaseComponent implements AfterV } onKeyDown(itemListAction: PoItemListOption | PoItemListOptionGroup | any, event?: KeyboardEvent) { - event.preventDefault(); + event?.preventDefault(); if ((event && event.code === 'Enter') || event.code === 'Space') { if (itemListAction.type === 'footerAction') { @@ -253,10 +349,11 @@ export class PoListBoxComponent extends PoListBoxBaseComponent implements AfterV } private setListBoxMaxHeight(): void { + const dropdownMaxHeight = 400; const itemsLength = this.items.length; const hasPopupHeaderContainer = this.popupHeaderContainer?.nativeElement?.children?.length > 0; - if (itemsLength > 6) { + if (!this.listboxSubitems && itemsLength > 6) { if (this.type === 'check' && !this.hideSearch) { this.renderer.setStyle(this.listbox.nativeElement, 'maxHeight', `${44 * 6 - 44 / 3 + 60}px`); } else if (hasPopupHeaderContainer) { @@ -270,6 +367,20 @@ export class PoListBoxComponent extends PoListBoxBaseComponent implements AfterV this.renderer.setStyle(this.listbox.nativeElement, 'maxHeight', `${44 * 6 - 44 / 3}px`); } } + + if (this.listboxSubitems) { + this.renderer.setStyle(this.listbox.nativeElement, 'maxHeight', `${dropdownMaxHeight}px`); + } + } + + private setListBoxWidth(): void { + const dropdownMinWidth = 240; + const dropdownMaxWidth = 340; + + if (this.listboxSubitems && this.items) { + this.renderer.setStyle(this.listbox.nativeElement, 'minWidth', `${dropdownMinWidth}px`); + this.renderer.setStyle(this.listbox.nativeElement, 'maxWidth', `${dropdownMaxWidth}px`); + } } private openUrl(url: string) { diff --git a/projects/ui/src/lib/components/po-listbox/po-listbox.module.ts b/projects/ui/src/lib/components/po-listbox/po-listbox.module.ts index 59c91c661..086c4068f 100644 --- a/projects/ui/src/lib/components/po-listbox/po-listbox.module.ts +++ b/projects/ui/src/lib/components/po-listbox/po-listbox.module.ts @@ -1,16 +1,17 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { CdkListboxModule } from '@angular/cdk/listbox'; +import { PoTagModule } from '../po-tag'; +import { PoIconModule } from '../po-icon/po-icon.module'; import { PoListBoxComponent } from './po-listbox.component'; +import { PoLoadingModule } from '../po-loading/po-loading.module'; import { PoItemListComponent } from './po-item-list/po-item-list.component'; -import { PoSearchListComponent } from './po-search-list/po-search-list.component'; import { PoCheckboxModule } from '../po-field/po-checkbox/po-checkbox.module'; -import { PoIconModule } from '../po-icon/po-icon.module'; -import { PoLoadingModule } from '../po-loading/po-loading.module'; +import { PoSearchListComponent } from './po-search-list/po-search-list.component'; @NgModule({ declarations: [PoListBoxComponent, PoItemListComponent, PoSearchListComponent], exports: [PoListBoxComponent], - imports: [CommonModule, PoCheckboxModule, PoIconModule, PoLoadingModule, CdkListboxModule] + imports: [CommonModule, PoCheckboxModule, PoIconModule, PoLoadingModule, PoTagModule, CdkListboxModule] }) export class PoListBoxModule {} diff --git a/projects/ui/src/lib/components/po-page/interfaces/po-page-action.interface.ts b/projects/ui/src/lib/components/po-page/interfaces/po-page-action.interface.ts index a894cde63..cd8a6960d 100644 --- a/projects/ui/src/lib/components/po-page/interfaces/po-page-action.interface.ts +++ b/projects/ui/src/lib/components/po-page/interfaces/po-page-action.interface.ts @@ -1,16 +1,17 @@ -import { PoPopupAction } from '../../po-popup/po-popup-action.interface'; +import { PoDropdownAction } from '../../po-dropdown'; /** * @description * Interface para as ações dos componentes po-page-default e po-page-list. * - * > As propriedades `selected`, `separator` e `type` serão vistas a partir da terceira ação e somente quando - * definir quatro ações ou mais. + * > Quando o array de actions possui quatro ou mais registros, os dois últimos e os seguintes são automaticamente agrupados no po-dropdown. + * A partir desse ponto, as propriedades `selected`, `separator`, `type` e `subItems` passam a ter efeito apenas nas ações exibidas dentro do dropdown, ou seja, a partir da terceira ação. + * Dessa forma, o uso de subItems (agrupadores dentro do dropdown) só terá efeito quando houver pelo menos quatro ações definidas. * - * @docsExtends PoPopupAction + * @docsExtends PoDropdownAction * * @ignoreExtendedDescription * * @usedBy PoPageDefaultComponent, PoPageListComponent */ -export interface PoPageAction extends PoPopupAction {} +export interface PoPageAction extends PoDropdownAction {} diff --git a/projects/ui/src/lib/components/po-page/po-page-default/samples/sample-po-page-default-dashboard/sample-po-page-default-dashboard.component.ts b/projects/ui/src/lib/components/po-page/po-page-default/samples/sample-po-page-default-dashboard/sample-po-page-default-dashboard.component.ts index 2078f3222..cc42cbd67 100644 --- a/projects/ui/src/lib/components/po-page/po-page-default/samples/sample-po-page-default-dashboard/sample-po-page-default-dashboard.component.ts +++ b/projects/ui/src/lib/components/po-page/po-page-default/samples/sample-po-page-default-dashboard/sample-po-page-default-dashboard.component.ts @@ -40,6 +40,15 @@ export class SamplePoPageDefaultDashboardComponent implements OnInit { public readonly actions: Array = [ { label: 'Share', action: this.modalOpen.bind(this), icon: 'an an-share' }, { label: 'GitHub', url: 'https://github.com/po-ui/po-angular' }, + { + label: 'More info', + subItems: [ + { + label: 'po-dropdown documentation', + url: 'https://po-ui.io/documentation/po-dropdown' + } + ] + }, { label: 'Components', url: '/documentation' }, { label: 'Disable notification', action: this.disableNotification.bind(this), disabled: () => this.isSubscribed } ]; diff --git a/projects/ui/src/lib/components/po-popup/po-popup-action.interface.ts b/projects/ui/src/lib/components/po-popup/po-popup-action.interface.ts index 52250dfb8..22029019c 100644 --- a/projects/ui/src/lib/components/po-popup/po-popup-action.interface.ts +++ b/projects/ui/src/lib/components/po-popup/po-popup-action.interface.ts @@ -8,12 +8,22 @@ import { TemplateRef } from '@angular/core'; * Interface para lista de ações do componente. */ export interface PoPopupAction { - /** Rótulo da ação. */ + /** + * @description + * + * Rótulo da ação. + * + * No componente `po-dropdown`, a label também pode representar o agrupador de subitens. + */ label: string; /** + * @description + * * Ação que será executada, sendo possível passar o nome ou a referência da função. * + * No componente `po-dropdown`, a action também pode ser executada para o agrupador de subitens. + * * > Para que a função seja executada no contexto do elemento filho o mesmo deve ser passado utilizando *bind*. * * Exemplo: `action: this.myFunction.bind(this)` @@ -62,13 +72,21 @@ export interface PoPopupAction { */ icon?: string | TemplateRef; - /** Atribui uma linha separadora acima do item. */ + /** + * @description + * + * Atribui uma linha separadora acima do item. + * + * */ separator?: boolean; /** + * @description + * * Função que deve retornar um booleano para habilitar ou desabilitar a ação para o registro selecionado. * * Também é possível informar diretamente um valor booleano que vai habilitar ou desabilitar a ação para todos os registros. + * */ disabled?: boolean | Function; @@ -83,10 +101,24 @@ export interface PoPopupAction { */ type?: string; - /** URL utilizada no redirecionamento das páginas. */ + /** + * @description + * + * URL utilizada para redirecionamento das páginas. + * + * No componente `po-dropdown`, a url também pode ser configurada para o agrupador de subitens. + * Entretanto, quando a `url` é informada em um agrupador, o clique **não abrirá os subitens**, pois o item será + * tratado como um link e o redirecionamento terá prioridade sobre a exibição da lista. + * + */ url?: string; - /** Define se a ação está selecionada. */ + /** + * @description + * + * Define se a ação está selecionada. + * + */ selected?: boolean; /** diff --git a/projects/ui/src/lib/components/po-popup/po-popup-base.component.ts b/projects/ui/src/lib/components/po-popup/po-popup-base.component.ts index af22c517e..d193a6831 100644 --- a/projects/ui/src/lib/components/po-popup/po-popup-base.component.ts +++ b/projects/ui/src/lib/components/po-popup/po-popup-base.component.ts @@ -87,6 +87,9 @@ export class PoPopupBaseComponent { private _size?: string = undefined; private _target: any; + // Indica se há um listbox com subitens + @Input('p-listbox-subitems') listboxSubitems = false; + /** Lista de ações que serão exibidas no componente. */ @Input('p-actions') set actions(value: Array) { this._actions = Array.isArray(value) ? value : []; diff --git a/projects/ui/src/lib/components/po-popup/po-popup.component.html b/projects/ui/src/lib/components/po-popup/po-popup.component.html index ad6647d80..8d0b77120 100644 --- a/projects/ui/src/lib/components/po-popup/po-popup.component.html +++ b/projects/ui/src/lib/components/po-popup/po-popup.component.html @@ -11,6 +11,7 @@ [p-items]="actions" [p-param]="param" [p-size]="size" + [p-listbox-subitems]="listboxSubitems" (p-close)="close()" (p-click-item)="onClickItem($event)" > diff --git a/projects/ui/src/lib/components/po-popup/po-popup.component.spec.ts b/projects/ui/src/lib/components/po-popup/po-popup.component.spec.ts index 89142c17a..981fc2a7e 100644 --- a/projects/ui/src/lib/components/po-popup/po-popup.component.spec.ts +++ b/projects/ui/src/lib/components/po-popup/po-popup.component.spec.ts @@ -448,12 +448,41 @@ describe('PoPopupComponent:', () => { expect(component['elementContains'](element, 'po-popup-item-disabled')).toBeFalsy(); }); - it('onClickItem: should emit clickItem', () => { - spyOn(component.clickItem, 'emit'); + it('onClickItem: should emit clickItem when item has no goBack', () => { + const spyEmit = spyOn(component.clickItem, 'emit'); + const spyDetect = spyOn(component['changeDetector'], 'detectChanges'); + const spyValidate = spyOn(component as any, 'validateInitialContent'); component.onClickItem({ label: 'test' }); - expect(component.clickItem.emit).toHaveBeenCalled(); + expect(spyEmit).toHaveBeenCalledWith({ label: 'test' }); + expect(spyDetect).not.toHaveBeenCalled(); + expect(spyValidate).not.toHaveBeenCalled(); + }); + + it('onClickItem: should NOT emit clickItem when goBack is true, but should call detectChanges and validateInitialContent', () => { + const spyEmit = spyOn(component.clickItem, 'emit'); + const spyDetect = spyOn(component['changeDetector'], 'detectChanges'); + const spyValidate = spyOn(component as any, 'validateInitialContent'); + + component.onClickItem({ goBack: true }); + + expect(spyEmit).not.toHaveBeenCalled(); + expect(spyDetect).toHaveBeenCalled(); + expect(spyValidate).toHaveBeenCalled(); + }); + + it('onClickItem: should emit and also call detectChanges and validateInitialContent when item has subItems', () => { + const spyEmit = spyOn(component.clickItem, 'emit'); + const spyDetect = spyOn(component['changeDetector'], 'detectChanges'); + const spyValidate = spyOn(component as any, 'validateInitialContent'); + + const item = { subItems: [{ label: 'child' }] }; + component.onClickItem(item); + + expect(spyEmit).toHaveBeenCalledWith(item); + expect(spyDetect).toHaveBeenCalled(); + expect(spyValidate).toHaveBeenCalled(); }); describe('checkBooleanValue:', () => { diff --git a/projects/ui/src/lib/components/po-popup/po-popup.component.ts b/projects/ui/src/lib/components/po-popup/po-popup.component.ts index 9a25042e4..8742f2d95 100644 --- a/projects/ui/src/lib/components/po-popup/po-popup.component.ts +++ b/projects/ui/src/lib/components/po-popup/po-popup.component.ts @@ -71,12 +71,12 @@ export class PoPopupComponent extends PoPopupBaseComponent { onActionClick(popupAction: PoPopupAction) { const actionNoDisabled = popupAction && !this.returnBooleanValue(popupAction, 'disabled'); - if (popupAction && popupAction.action && actionNoDisabled) { + if (popupAction?.action && actionNoDisabled) { this.close(); popupAction.action(this.param || popupAction); } - if (popupAction && popupAction.url && actionNoDisabled) { + if (popupAction?.url && actionNoDisabled) { this.close(); return this.openUrl(popupAction.url); } @@ -113,7 +113,14 @@ export class PoPopupComponent extends PoPopupBaseComponent { } onClickItem(item: any) { - this.clickItem.emit(item); + if (!item.goBack) { + this.clickItem.emit(item); + } + + if (item.subItems || item.goBack) { + this.changeDetector.detectChanges(); + this.validateInitialContent(); + } } protected checkAllActionIsInvisible() { diff --git a/projects/ui/src/lib/services/po-theme/helpers/types/po-theme-dark-defaults.constant.ts b/projects/ui/src/lib/services/po-theme/helpers/types/po-theme-dark-defaults.constant.ts index 3f4dc3c3f..38293c2df 100644 --- a/projects/ui/src/lib/services/po-theme/helpers/types/po-theme-dark-defaults.constant.ts +++ b/projects/ui/src/lib/services/po-theme/helpers/types/po-theme-dark-defaults.constant.ts @@ -199,6 +199,11 @@ const poThemeDefaultDarkValues = { /** SELECT */ 'po-select': { '--color-hover': 'var(--color-action-hover);' + }, + /** DROPDOWN */ + '.po-listbox-group-header .po-tag': { + 'color': 'var(--color-neutral-light-00);', + 'background-color': 'var(--color-action-default);' } }, onRoot: {