diff --git a/projects/ui/src/lib/components/po-calendar/po-calendar-base.component.spec.ts b/projects/ui/src/lib/components/po-calendar/po-calendar-base.component.spec.ts index 4da9503cca..ceb574f980 100644 --- a/projects/ui/src/lib/components/po-calendar/po-calendar-base.component.spec.ts +++ b/projects/ui/src/lib/components/po-calendar/po-calendar-base.component.spec.ts @@ -72,6 +72,18 @@ describe('PoCalendarBaseComponent:', () => { expect(component.yearRangeLimit).toBe(50); }); + it('p-year-range-limit: should truncate a positive fractional number', () => { + component.yearRangeLimit = 25.9; + + expect(component.yearRangeLimit).toBe(25); + }); + + it('p-year-range-limit: should fallback to 150 for invalid numbers', () => { + const invalidValues = [-1, NaN, Infinity, -Infinity]; + + expectPropertiesValues(component, 'yearRangeLimit', invalidValues, 150); + }); + it('p-year-range-limit: should fallback to 150 when value is null', () => { component.yearRangeLimit = null; diff --git a/projects/ui/src/lib/components/po-calendar/po-calendar-base.component.ts b/projects/ui/src/lib/components/po-calendar/po-calendar-base.component.ts index 6971a504d8..bd70f30a42 100644 --- a/projects/ui/src/lib/components/po-calendar/po-calendar-base.component.ts +++ b/projects/ui/src/lib/components/po-calendar/po-calendar-base.component.ts @@ -29,6 +29,8 @@ import { PoCalendarRangePreset } from './interfaces/po-calendar-range-preset.int * * **Importante:** * - Datas fora do intervalo (`p-min-date` / `p-max-date`) aparecem desabilitadas sem alterar o *model*. + * - São aceitos anos entre 0000 e 9999. No seletor de ano do cabeçalho é possível digitar qualquer ano com + * 4 dígitos, mesmo que não esteja listado, e a navegação é bloqueada nos limites desse intervalo. * * #### Boas práticas * @@ -343,7 +345,9 @@ export class PoCalendarBaseComponent { // O valor informado determina o intervalo de anos anterior e posterior // à data corrente que será disponibilizado para seleção. @Input('p-year-range-limit') set yearRangeLimit(value: number) { - this._yearRangeLimit = value ?? 150; + const rangeLimit = value === null || value === undefined ? Number.NaN : Number(value); + + this._yearRangeLimit = Number.isFinite(rangeLimit) && rangeLimit >= 0 ? Math.floor(rangeLimit) : 150; } get yearRangeLimit(): number { diff --git a/projects/ui/src/lib/components/po-calendar/po-calendar-header/po-calendar-header.component.html b/projects/ui/src/lib/components/po-calendar/po-calendar-header/po-calendar-header.component.html index 509d4f82bc..38736a2688 100644 --- a/projects/ui/src/lib/components/po-calendar/po-calendar-header/po-calendar-header.component.html +++ b/projects/ui/src/lib/components/po-calendar/po-calendar-header/po-calendar-header.component.html @@ -11,6 +11,7 @@ (keydown.enter)="previous.emit(); $event.preventDefault()" [p-aria-label]="previousMonthLabel" p-icon="ICON_ARROW_LEFT" + [p-disabled]="disablePrevious()" [p-size]="size" > } @@ -22,6 +23,7 @@ (keydown.enter)="next.emit(); $event.preventDefault()" [p-aria-label]="nextMonthLabel" p-icon="ICON_ARROW_RIGHT" + [p-disabled]="disableNext()" [p-size]="size" > } diff --git a/projects/ui/src/lib/components/po-calendar/po-calendar-header/po-calendar-header.component.spec.ts b/projects/ui/src/lib/components/po-calendar/po-calendar-header/po-calendar-header.component.spec.ts index 66952b4aa5..9a7e0668f9 100644 --- a/projects/ui/src/lib/components/po-calendar/po-calendar-header/po-calendar-header.component.spec.ts +++ b/projects/ui/src/lib/components/po-calendar/po-calendar-header/po-calendar-header.component.spec.ts @@ -22,6 +22,20 @@ describe('PoCalendarHeaderComponent', () => { expect(component).toBeTruthy(); }); + it('should initialize disablePrevious and disableNext with false', () => { + expect(component.disablePrevious()).toBeFalse(); + expect(component.disableNext()).toBeFalse(); + }); + + it('should update disablePrevious and disableNext from `p-disable-previous` and `p-disable-next`', () => { + fixture.componentRef.setInput('p-disable-previous', true); + fixture.componentRef.setInput('p-disable-next', true); + fixture.detectChanges(); + + expect(component.disablePrevious()).toBeTrue(); + expect(component.disableNext()).toBeTrue(); + }); + it('should call markForCheck and detectChanges when templateContext changes', () => { const cdr = (component as any).cdr; diff --git a/projects/ui/src/lib/components/po-calendar/po-calendar-header/po-calendar-header.component.ts b/projects/ui/src/lib/components/po-calendar/po-calendar-header/po-calendar-header.component.ts index 7a056e8716..e87a90f640 100644 --- a/projects/ui/src/lib/components/po-calendar/po-calendar-header/po-calendar-header.component.ts +++ b/projects/ui/src/lib/components/po-calendar/po-calendar-header/po-calendar-header.component.ts @@ -8,6 +8,7 @@ import { OnChanges, SimpleChanges, inject, + input, ChangeDetectorRef, OnInit } from '@angular/core'; @@ -26,6 +27,8 @@ export class PoCalendarHeaderComponent implements OnInit, OnChanges { @Input('p-hide-previous') hidePrevious = false; @Input('p-hide-next') hideNext = false; + disablePrevious = input(false, { alias: 'p-disable-previous' }); + disableNext = input(false, { alias: 'p-disable-next' }); @Input('p-display-month') displayMonth!: number; @Input('p-display-year') displayYear!: number; @Input('p-display-month-label') displayMonthLabel: string; diff --git a/projects/ui/src/lib/components/po-calendar/po-calendar-preset-list/po-calendar-preset-list.component.spec.ts b/projects/ui/src/lib/components/po-calendar/po-calendar-preset-list/po-calendar-preset-list.component.spec.ts index fa52f78a44..b4f4867d23 100644 --- a/projects/ui/src/lib/components/po-calendar/po-calendar-preset-list/po-calendar-preset-list.component.spec.ts +++ b/projects/ui/src/lib/components/po-calendar/po-calendar-preset-list/po-calendar-preset-list.component.spec.ts @@ -1,7 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { configureTestSuite } from '../../../util-test/util-expect.spec'; - import { PoButtonModule } from '../../po-button/po-button.module'; import { PoCalendarPresetListComponent } from './po-calendar-preset-list.component'; import { PoCalendarRangePreset } from '../interfaces/po-calendar-range-preset.interface'; @@ -12,7 +10,7 @@ describe('PoCalendarPresetListComponent:', () => { let fixture: ComponentFixture; let nativeElement: HTMLElement; - configureTestSuite(() => { + beforeEach(() => { TestBed.configureTestingModule({ imports: [PoButtonModule], declarations: [PoCalendarPresetListComponent] diff --git a/projects/ui/src/lib/components/po-calendar/po-calendar-wrapper/po-calendar-wrapper.component.html b/projects/ui/src/lib/components/po-calendar/po-calendar-wrapper/po-calendar-wrapper.component.html index b4949b86c5..70e7bedf7b 100644 --- a/projects/ui/src/lib/components/po-calendar/po-calendar-wrapper/po-calendar-wrapper.component.html +++ b/projects/ui/src/lib/components/po-calendar/po-calendar-wrapper/po-calendar-wrapper.component.html @@ -5,6 +5,8 @@ { expect(result.getDate()).toBe(13); }); + it('getDateToUse: should keep year between 0 and 99 when parsing YYYY-MM-DD string', () => { + const result = (component as any).getDateToUse('0050-03-10'); + + expect(result.getFullYear()).toBe(50); + expect(result.getMonth()).toBe(2); + expect(result.getDate()).toBe(10); + }); + it('getDateToUse: should return new Date when value is invalid', () => { const result = (component as any).getDateToUse({ start: 'invalid' }); @@ -359,6 +367,283 @@ describe('PoCalendarWrapperComponent', () => { expect(component['updateDisplay']).toHaveBeenCalledWith(2023, 6); }); + it('updateYear: shouldn`t call updateDisplay when new year is less than 0', () => { + component.displayYear = 5; + component.displayMonthNumber = 6; + + spyOn(component, 'updateDisplay'); + + component.updateYear(-10); + + expect(component['updateDisplay']).not.toHaveBeenCalled(); + }); + + it('updateYear: shouldn`t call updateDisplay when new year is greater than 9999', () => { + component.displayYear = 9995; + component.displayMonthNumber = 6; + + spyOn(component, 'updateDisplay'); + + component.updateYear(10); + + expect(component['updateDisplay']).not.toHaveBeenCalled(); + }); + + it('onPreviousMonth: shouldn`t call updateDisplay when display is January of year 0', () => { + component.displayYear = 0; + component.displayMonthNumber = 0; + + spyOn(component, 'updateDisplay'); + spyOn(component.headerChange, 'emit'); + + component.onPreviousMonth(); + + expect(component['updateDisplay']).not.toHaveBeenCalled(); + expect(component.headerChange.emit).not.toHaveBeenCalled(); + }); + + it('onNextMonth: shouldn`t call updateDisplay when display is December of year 9999', () => { + component.displayYear = 9999; + component.displayMonthNumber = 11; + + spyOn(component, 'updateDisplay'); + spyOn(component.headerChange, 'emit'); + + component.onNextMonth(); + + expect(component['updateDisplay']).not.toHaveBeenCalled(); + expect(component.headerChange.emit).not.toHaveBeenCalled(); + }); + + it('isPreviousMonthDisabled: should return true only when display is January of year 0', () => { + component.displayYear = 0; + component.displayMonthNumber = 0; + expect(component.isPreviousMonthDisabled).toBeTrue(); + + component.displayMonthNumber = 1; + expect(component.isPreviousMonthDisabled).toBeFalse(); + + component.displayYear = 1; + component.displayMonthNumber = 0; + expect(component.isPreviousMonthDisabled).toBeFalse(); + }); + + it('isNextMonthDisabled: should return true only when display is December of year 9999', () => { + component.displayYear = 9999; + component.displayMonthNumber = 11; + expect(component.isNextMonthDisabled).toBeTrue(); + + component.displayMonthNumber = 10; + expect(component.isNextMonthDisabled).toBeFalse(); + + component.displayYear = 9998; + component.displayMonthNumber = 11; + expect(component.isNextMonthDisabled).toBeFalse(); + }); + + it('isPreviousYearDisabled: should return true when displayYear minus step is less than 0', () => { + component.displayYear = 5; + + expect(component.isPreviousYearDisabled(10)).toBeTrue(); + expect(component.isPreviousYearDisabled(5)).toBeFalse(); + }); + + it('isNextYearDisabled: should return true when displayYear plus step is greater than 9999', () => { + component.displayYear = 9995; + + expect(component.isNextYearDisabled(10)).toBeTrue(); + expect(component.isNextYearDisabled(4)).toBeFalse(); + }); + + it('updateTemplateContext: should format displayYear label with 4 digits when it is missing', () => { + component.displayYear = 50; + component.displayMonthNumber = 5; + component.comboYearsOptions = [{ label: '2020', value: 2020 }]; + + (component as any).updateTemplateContext(); + + expect(component.templateContext.yearsOptions[0]).toEqual({ label: '0050', value: 50 }); + }); + + it('updateTemplateContext: should keep the same displayYear option reference between updates', () => { + component.displayYear = 2035; + component.displayMonthNumber = 5; + component.comboYearsOptions = [{ label: '2020', value: 2020 }]; + + (component as any).updateTemplateContext(); + const firstOption = component.templateContext.yearsOptions.find(option => option.value === 2035); + + (component as any).updateTemplateContext(); + const secondOption = component.templateContext.yearsOptions.find(option => option.value === 2035); + + expect(secondOption).toBe(firstOption); + }); + + it('updateTemplateContext: should add typedYearOption to yearsOptions sorted by value', () => { + component.displayYear = 2020; + component.displayMonthNumber = 5; + component.comboYearsOptions = [ + { label: '2020', value: 2020 }, + { label: '2021', value: 2021 } + ]; + component['typedYearOption'] = { label: '1500', value: 1500 }; + + (component as any).updateTemplateContext(); + + expect(component.templateContext.yearsOptions.map(option => option.value)).toEqual([1500, 2020, 2021]); + }); + + it('updateTemplateContext: should use typedYearOption as displayYearOption when it is the displayYear', () => { + const typedYearOption = { label: '1500', value: 1500 }; + component.displayYear = 1500; + component.displayMonthNumber = 5; + component.comboYearsOptions = [{ label: '2020', value: 2020 }]; + component['typedYearOption'] = typedYearOption; + + (component as any).updateTemplateContext(); + + expect(component['typedYearOption']).toBeUndefined(); + expect(component['displayYearOption']).toBe(typedYearOption); + expect(component.templateContext.yearsOptions.filter(option => option.value === 1500).length).toBe(1); + }); + + it('templateContext: should call onYearInputChange when templateContext.onYearInputChange is invoked', () => { + const comboComponent = {}; + spyOn(component, 'onYearInputChange'); + + component.templateContext.onYearInputChange('2999', comboComponent); + (component as any).updateTemplateContext(); + component.templateContext.onYearInputChange('2999', comboComponent); + + expect(component.onYearInputChange).toHaveBeenCalledTimes(2); + expect(component.onYearInputChange).toHaveBeenCalledWith('2999', comboComponent); + }); + + describe('onYearInputChange:', () => { + let comboComponent; + + beforeEach(() => { + comboComponent = { + options: [], + filterMode: 'startsWith', + searchForLabel: jasmine.createSpy('searchForLabel') + }; + component.displayYear = 2026; + component.displayMonthNumber = 5; + component.comboYearsOptions = [ + { label: '2025', value: 2025 }, + { label: '2026', value: 2026 } + ]; + }); + + it('should return early when comboComponent is undefined', () => { + spyOn(component as any, 'updateTemplateContext'); + + component.onYearInputChange('2999'); + + expect(component['updateTemplateContext']).not.toHaveBeenCalled(); + }); + + it('should add typed year to yearsOptions and filter combo when year is not listed', () => { + component.onYearInputChange('2999', comboComponent); + + expect(component['typedYearOption']).toEqual({ label: '2999', value: 2999 }); + expect(component.templateContext.yearsOptions.some(option => option.value === 2999)).toBeTrue(); + expect(component.cdr.detectChanges).toHaveBeenCalled(); + expect(comboComponent.searchForLabel).toHaveBeenCalledWith('2999', comboComponent.options, 'startsWith'); + }); + + it('should format typed year label with 4 digits', () => { + component.onYearInputChange('0050', comboComponent); + + expect(component['typedYearOption']).toEqual({ label: '0050', value: 50 }); + }); + + it('shouldn`t update templateContext when typed year is listed and there is no typed year option', () => { + spyOn(component as any, 'updateTemplateContext'); + + component.onYearInputChange('2025', comboComponent); + + expect(component['updateTemplateContext']).not.toHaveBeenCalled(); + expect(comboComponent.searchForLabel).not.toHaveBeenCalled(); + }); + + it('shouldn`t update templateContext when typed year is the same of typed year option', () => { + component['typedYearOption'] = { label: '2999', value: 2999 }; + spyOn(component as any, 'updateTemplateContext'); + + component.onYearInputChange('2999', comboComponent); + + expect(component['updateTemplateContext']).not.toHaveBeenCalled(); + }); + + it('should remove typed year option when typed text is not a valid year', () => { + component['typedYearOption'] = { label: '2999', value: 2999 }; + + component.onYearInputChange('29', comboComponent); + + expect(component['typedYearOption']).toBeUndefined(); + expect(component.templateContext.yearsOptions.some(option => option.value === 2999)).toBeFalse(); + expect(comboComponent.searchForLabel).toHaveBeenCalledWith('29', comboComponent.options, 'startsWith'); + }); + + it('should remove typed year option when typed year is the displayYear', () => { + component['typedYearOption'] = { label: '2999', value: 2999 }; + + component.onYearInputChange('2026', comboComponent); + + expect(component['typedYearOption']).toBeUndefined(); + }); + + it('should keep the cleared year and month of the combo when typed year is added', () => { + component.templateContext = { ...component.templateContext, year: null, monthIndex: 5 }; + + component.onYearInputChange('9999', comboComponent); + + expect(component.templateContext.year).toBeNull(); + expect(component.templateContext.monthIndex).toBe(5); + expect(component.templateContext.yearsOptions.some(option => option.value === 9999)).toBeTrue(); + }); + + it('should keep the cleared year of the combo when typed year option is removed', () => { + component['typedYearOption'] = { label: '9999', value: 9999 }; + component.templateContext = { ...component.templateContext, year: null, monthIndex: 5 }; + + component.onYearInputChange('99999', comboComponent); + + expect(component.templateContext.year).toBeNull(); + expect(component['typedYearOption']).toBeUndefined(); + }); + }); + + describe('getTypedYear:', () => { + it('should return undefined when value doesn`t have 4 digits', () => { + expect(component['getTypedYear']('299')).toBeUndefined(); + expect(component['getTypedYear']('29999')).toBeUndefined(); + expect(component['getTypedYear']('abcd')).toBeUndefined(); + expect(component['getTypedYear'](undefined)).toBeUndefined(); + }); + + it('should return the year when value has 4 digits', () => { + expect(component['getTypedYear'](' 0050 ')).toBe(50); + expect(component['getTypedYear']('2999')).toBe(2999); + }); + + it('should return undefined when year is less than minDate year', () => { + component.minDate = new Date(2020, 0, 1); + + expect(component['getTypedYear']('2019')).toBeUndefined(); + expect(component['getTypedYear']('2020')).toBe(2020); + }); + + it('should return undefined when year is greater than maxDate year', () => { + component.maxDate = new Date(2030, 11, 31); + + expect(component['getTypedYear']('2031')).toBeUndefined(); + expect(component['getTypedYear']('2030')).toBe(2030); + }); + }); + it(`updateDisplay: should return early when year is undefined`, () => { component['updateDisplay'](undefined, 5); @@ -735,6 +1020,35 @@ describe('PoCalendarWrapperComponent', () => { expect(component['poDate'].validateDateRange).toHaveBeenCalledWith(date, component.minDate, component.maxDate); }); + it('isDayDisabled: should return `true` when date year is less than 0', () => { + const date = new Date(2024, 11, 31); + date.setFullYear(-1); + + spyOn(component['poDate'], 'validateDateRange').and.returnValue(true); + + expect(component.isDayDisabled(date)).toBeTrue(); + expect(component['poDate'].validateDateRange).not.toHaveBeenCalled(); + }); + + it('isDayDisabled: should return `true` when date year is greater than 9999', () => { + const date = new Date(10000, 0, 1); + + spyOn(component['poDate'], 'validateDateRange').and.returnValue(true); + + expect(component.isDayDisabled(date)).toBeTrue(); + expect(component['poDate'].validateDateRange).not.toHaveBeenCalled(); + }); + + it('onSelectDate: shouldn`t emit when date year is out of range', () => { + const date = new Date(10000, 0, 1); + + spyOn(component.selectDate, 'emit'); + + component.onSelectDate(date); + + expect(component.selectDate.emit).not.toHaveBeenCalled(); + }); + it(`monthLabel: should call 'poCalendarLangService.getMonthLabel'`, () => { spyOn(component['poCalendarLangService'], 'getMonthLabel').and.callThrough(); @@ -3332,6 +3646,145 @@ describe('PoCalendarWrapperComponent', () => { expect(component.updateDate).toHaveBeenCalledWith(2026, 5, null); }); + it('should restore options and close combo on blur when there are no visible options', fakeAsync(() => { + component.templateContext.year = 2026; + component.templateContext.monthIndex = 5; + + const mockComboComponent = { + visibleOptions: [], + verifyValidOption: jasmine.createSpy('verifyValidOption'), + controlComboVisibility: jasmine.createSpy('controlComboVisibility') + }; + + component.onComboBlur(mockComboComponent); + tick(0); + + expect(mockComboComponent.verifyValidOption).toHaveBeenCalled(); + expect(mockComboComponent.controlComboVisibility).toHaveBeenCalledWith(false); + })); + + it('shouldn`t restore options or close combo on blur when there are visible options', fakeAsync(() => { + component.templateContext.year = 2026; + component.templateContext.monthIndex = 5; + + const mockComboComponent = { + visibleOptions: [{ label: '2026', value: 2026 }], + verifyValidOption: jasmine.createSpy('verifyValidOption'), + controlComboVisibility: jasmine.createSpy('controlComboVisibility') + }; + + component.onComboBlur(mockComboComponent); + tick(0); + + expect(mockComboComponent.verifyValidOption).not.toHaveBeenCalled(); + expect(mockComboComponent.controlComboVisibility).not.toHaveBeenCalled(); + })); + + describe('when focus moves to the combo listbox:', () => { + let comboElement: HTMLElement; + let inputElement: HTMLInputElement; + let optionElement: HTMLElement; + let outsideElement: HTMLButtonElement; + let mockComboComponent: any; + let updateDisplaySpy: jasmine.Spy; + let focusedElement: Element; + + beforeEach(() => { + comboElement = document.createElement('div'); + inputElement = document.createElement('input'); + optionElement = document.createElement('li'); + optionElement.tabIndex = -1; + outsideElement = document.createElement('button'); + comboElement.append(inputElement, optionElement); + document.body.append(comboElement, outsideElement); + + mockComboComponent = { + element: { nativeElement: comboElement }, + visibleOptions: [{ label: 'Maio', value: 4 }], + verifyValidOption: jasmine.createSpy('verifyValidOption'), + controlComboVisibility: jasmine.createSpy('controlComboVisibility') + }; + + component.templateContext.year = 2026; + component.templateContext.monthIndex = null; + component.displayMonthNumber = 9; + updateDisplaySpy = spyOn(component, 'updateDisplay'); + + // O `document.activeElement` real depende do foco da janela do runner; simulado para o teste ser determinístico. + focusedElement = document.body; + spyOnProperty(document, 'activeElement', 'get').and.callFake(() => focusedElement); + }); + + afterEach(() => { + comboElement.remove(); + outsideElement.remove(); + }); + + // O foco é simulado: o `focusout` é disparado manualmente e `activeElement` é atualizado. + const moveFocus = (from: HTMLElement, to: HTMLElement) => { + focusedElement = to; + from.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: to })); + }; + + it('shouldn`t restore the display while focus stays inside the combo', fakeAsync(() => { + focusedElement = optionElement; + + component.onComboBlur(mockComboComponent); + tick(0); + + expect(updateDisplaySpy).not.toHaveBeenCalled(); + })); + + it('should restore the display when focus leaves the combo', fakeAsync(() => { + focusedElement = optionElement; + component.onComboBlur(mockComboComponent); + tick(0); + + moveFocus(optionElement, outsideElement); + tick(0); + + expect(updateDisplaySpy).toHaveBeenCalledOnceWith(2026, 9); + })); + + it('shouldn`t restore the display when focus moves between elements of the combo', fakeAsync(() => { + focusedElement = optionElement; + component.onComboBlur(mockComboComponent); + tick(0); + + moveFocus(optionElement, inputElement); + tick(0); + + expect(updateDisplaySpy).not.toHaveBeenCalled(); + })); + + it('shouldn`t restore the display when an option is selected before focus leaves the combo', fakeAsync(() => { + focusedElement = optionElement; + component.onComboBlur(mockComboComponent); + tick(0); + + component.templateContext.monthIndex = 4; + moveFocus(optionElement, outsideElement); + tick(0); + + expect(updateDisplaySpy).not.toHaveBeenCalled(); + })); + + it('should register a single focusout listener per combo', fakeAsync(() => { + const addEventListenerSpy = spyOn(comboElement, 'addEventListener').and.callThrough(); + focusedElement = optionElement; + + component.onComboBlur(mockComboComponent); + component.onComboBlur(mockComboComponent); + tick(0); + + expect(addEventListenerSpy).toHaveBeenCalledTimes(1); + })); + }); + + it('resetComboWithoutOptions: shouldn`t throw when comboComponent is undefined', () => { + expect(() => component['resetComboWithoutOptions'](undefined)).not.toThrow(); + }); + it('should handle invalid month value (string) and restore from selectedOption', fakeAsync(() => { const updateDisplaySpy = spyOn(component as any, 'updateDisplay'); component.displayYear = 2026; @@ -3408,16 +3861,16 @@ describe('PoCalendarWrapperComponent', () => { expect(updateDisplaySpy).not.toHaveBeenCalled(); })); - it('should handle out-of-range year (below 1900) and restore from selectedOption', fakeAsync(() => { + it('should handle out-of-range year (below 0) and restore from selectedOption', fakeAsync(() => { const updateDisplaySpy = spyOn(component as any, 'updateDisplay'); component.displayYear = 2026; component.displayMonthNumber = 5; - component.templateContext.year = 1800; + component.templateContext.year = -1; component.templateContext.monthIndex = 5; const mockComboComponent = { selectedOption: { value: 2020, label: '2020' }, - inputEl: { nativeElement: { value: '1800' } } + inputEl: { nativeElement: { value: '-1' } } }; component.onComboBlur(mockComboComponent); @@ -3427,16 +3880,16 @@ describe('PoCalendarWrapperComponent', () => { expect(updateDisplaySpy).not.toHaveBeenCalled(); })); - it('should handle out-of-range year (above 2100) and restore from selectedOption', fakeAsync(() => { + it('should handle out-of-range year (above 9999) and restore from selectedOption', fakeAsync(() => { const updateDisplaySpy = spyOn(component as any, 'updateDisplay'); component.displayYear = 2026; component.displayMonthNumber = 5; - component.templateContext.year = 2150; + component.templateContext.year = 10000; component.templateContext.monthIndex = 5; const mockComboComponent = { selectedOption: { value: 2030, label: '2030' }, - inputEl: { nativeElement: { value: '2150' } } + inputEl: { nativeElement: { value: '10000' } } }; component.onComboBlur(mockComboComponent); diff --git a/projects/ui/src/lib/components/po-calendar/po-calendar-wrapper/po-calendar-wrapper.component.ts b/projects/ui/src/lib/components/po-calendar/po-calendar-wrapper/po-calendar-wrapper.component.ts index ebc3a4498b..2fe69d5a4a 100644 --- a/projects/ui/src/lib/components/po-calendar/po-calendar-wrapper/po-calendar-wrapper.component.ts +++ b/projects/ui/src/lib/components/po-calendar/po-calendar-wrapper/po-calendar-wrapper.component.ts @@ -19,6 +19,10 @@ import { debounceTime } from 'rxjs/operators'; import { PoCalendarLangService } from '../services/po-calendar.lang.service'; import { PoCalendarService } from '../services/po-calendar.service'; import { PoDateService } from '../../../services/po-date/po-date.service'; +import { createDate } from '../../../utils/util'; + +const MIN_YEAR = 0; +const MAX_YEAR = 9999; @Component({ selector: 'po-calendar-wrapper', @@ -92,12 +96,18 @@ export class PoCalendarWrapperComponent implements OnInit, OnChanges { year: 0, yearsOptions: [], updateDate: (year: number, month: number, comboComponent?: any) => this.updateDate(year, month, comboComponent), - onComboBlur: () => this.onComboBlur() + onComboBlur: () => this.onComboBlur(), + onYearInputChange: (search: string, comboComponent?: any) => this.onYearInputChange(search, comboComponent) }; protected currentMonthNumber: number; protected lastDisplay: string; + // Referências mantidas para que o listbox não recrie o item clicado e o calendário não feche. + private displayYearOption: { label: string; value: number }; + private typedYearOption: { label: string; value: number }; + private readonly combosAwaitingFocusOut = new WeakSet(); + get monthLabel() { return this.poCalendarLangService.getMonthLabel(); } @@ -163,17 +173,17 @@ export class PoCalendarWrapperComponent implements OnInit, OnChanges { const dateOnlyRegex = /^(\d{4})-(\d{2})-(\d{2})$/; if (dateOnlyRegex.test(dateValue)) { const [, year, month, day] = dateOnlyRegex.exec(dateValue).map(Number); - return new Date(year, month - 1, day); + return createDate(year, month - 1, day); } dateValue = new Date(dateValue); } if (dateValue instanceof Date && !Number.isNaN(dateValue.getTime())) { if (dateValue.getUTCHours() === 0 && dateValue.getUTCMinutes() === 0) { - return new Date(dateValue.getUTCFullYear(), dateValue.getUTCMonth(), dateValue.getUTCDate()); + return createDate(dateValue.getUTCFullYear(), dateValue.getUTCMonth(), dateValue.getUTCDate()); } - return new Date(dateValue.getFullYear(), dateValue.getMonth(), dateValue.getDate()); + return createDate(dateValue.getFullYear(), dateValue.getMonth(), dateValue.getDate()); } return new Date(); @@ -221,25 +231,86 @@ export class PoCalendarWrapperComponent implements OnInit, OnChanges { this.updateDisplay(event.year, event.month); } - private updateTemplateContext() { + private updateTemplateContext(keepComboSelection: boolean = false) { const yearsOptions = [...this.comboYearsOptions]; + if (this.typedYearOption?.value === this.displayYear) { + this.displayYearOption = this.typedYearOption; + this.typedYearOption = undefined; + } + if (this.displayYear !== undefined && !yearsOptions.some(option => option.value === this.displayYear)) { - yearsOptions.push({ label: this.displayYear.toString(), value: this.displayYear }); + if (this.displayYearOption?.value !== this.displayYear) { + this.displayYearOption = { label: this.formatYearLabel(this.displayYear), value: this.displayYear }; + } + yearsOptions.push(this.displayYearOption); + } + + if (this.typedYearOption && !yearsOptions.some(option => option.value === this.typedYearOption.value)) { + yearsOptions.push(this.typedYearOption); + } + + if (yearsOptions.length > this.comboYearsOptions.length) { yearsOptions.sort((a, b) => a.value - b.value); } this.templateContext = { - monthIndex: this.displayMonthNumber, + monthIndex: keepComboSelection ? this.templateContext.monthIndex : this.displayMonthNumber, monthsOptions: [...this.comboMonthsOptions], - year: this.displayYear, + year: keepComboSelection ? this.templateContext.year : this.displayYear, yearsOptions, updateDate: (year: number, month: number, comboComponent?: any) => this.updateDate(year, month, comboComponent), - onComboBlur: (comboComponent?: any) => this.onComboBlur(comboComponent) + onComboBlur: (comboComponent?: any) => this.onComboBlur(comboComponent), + onYearInputChange: (search: string, comboComponent?: any) => this.onYearInputChange(search, comboComponent) }; this.cdr.markForCheck(); } + onYearInputChange(search: string, comboComponent?: any) { + if (!comboComponent) { + return; + } + + const typedYear = this.getTypedYear(search); + const isListedYear = + typedYear === undefined || + typedYear === this.displayYear || + this.comboYearsOptions.some(option => option.value === typedYear); + + if (isListedYear ? !this.typedYearOption : this.typedYearOption?.value === typedYear) { + return; + } + + this.typedYearOption = isListedYear ? undefined : { label: this.formatYearLabel(typedYear), value: typedYear }; + // Só as opções mudam: regravar o ano exibido trocaria o texto digitado num combo que foi esvaziado. + this.updateTemplateContext(true); + + this.cdr.detectChanges(); + comboComponent.searchForLabel(search, comboComponent.options, comboComponent.filterMode); + } + + private getTypedYear(search: string): number | undefined { + const value = (search || '').trim(); + + if (!/^\d{4}$/.test(value)) { + return undefined; + } + + const year = Number(value); + const minYear = this.minDate ? this.getDateToUse(this.minDate).getFullYear() : undefined; + const maxYear = this.maxDate ? this.getDateToUse(this.maxDate).getFullYear() : undefined; + + if ((minYear !== undefined && year < minYear) || (maxYear !== undefined && year > maxYear)) { + return undefined; + } + + return year; + } + + private formatYearLabel(year: number): string { + return year.toString().padStart(4, '0'); + } + updateDate(year: number, month: number, comboComponent?: any) { const isInvalidYear = year === undefined || year === null; const isInvalidMonth = month === undefined || month === null; @@ -271,7 +342,27 @@ export class PoCalendarWrapperComponent implements OnInit, OnChanges { } } + // Sem itens, o combo manteria a lista "Nenhum item encontrado" aberta após perder o foco. + private resetComboWithoutOptions(comboComponent: any) { + if (!comboComponent || comboComponent.visibleOptions?.length) { + return; + } + + comboComponent.verifyValidOption?.(); + comboComponent.controlComboVisibility?.(false); + } + private handleComboBlur(comboComponent: any) { + const comboElement: HTMLElement = comboComponent?.element?.nativeElement; + + // A seta para baixo leva o foco à lista do próprio combo; validar agora descartaria o texto filtrado. + if (comboElement?.contains(document.activeElement)) { + this.validateOnComboFocusOut(comboComponent, comboElement); + return; + } + + this.resetComboWithoutOptions(comboComponent); + let currentYear = this.templateContext.year; let currentMonth = this.templateContext.monthIndex; @@ -293,6 +384,25 @@ export class PoCalendarWrapperComponent implements OnInit, OnChanges { this.ensureValidDisplay(currentYear, currentMonth); } + private validateOnComboFocusOut(comboComponent: any, comboElement: HTMLElement) { + if (this.combosAwaitingFocusOut.has(comboElement)) { + return; + } + + const onFocusOut = (event: FocusEvent) => { + if (comboElement.contains(event.relatedTarget as Node)) { + return; + } + + comboElement.removeEventListener('focusout', onFocusOut); + this.combosAwaitingFocusOut.delete(comboElement); + this.onComboBlur(comboComponent); + }; + + this.combosAwaitingFocusOut.add(comboElement); + comboElement.addEventListener('focusout', onFocusOut); + } + private syncInputValue(comboComponent: any) { if (comboComponent?.inputEl?.nativeElement) { const currentInputValue = comboComponent.inputEl.nativeElement.value; @@ -332,7 +442,7 @@ export class PoCalendarWrapperComponent implements OnInit, OnChanges { } private isYearInvalid(year: any): boolean { - return typeof year === 'string' || Number.isNaN(year) || (year && year < 1900) || (year && year > 2100); + return typeof year === 'string' || Number.isNaN(year) || (year && year < MIN_YEAR) || (year && year > MAX_YEAR); } private updateDisplay(year: number, month: number) { @@ -418,7 +528,27 @@ export class PoCalendarWrapperComponent implements OnInit, OnChanges { return index === this.focusedDayIndex ? 0 : -1; } + get isPreviousMonthDisabled(): boolean { + return this.displayYear <= MIN_YEAR && this.displayMonthNumber === 0; + } + + get isNextMonthDisabled(): boolean { + return this.displayYear >= MAX_YEAR && this.displayMonthNumber === 11; + } + + isPreviousYearDisabled(step: number): boolean { + return this.displayYear - step < MIN_YEAR; + } + + isNextYearDisabled(step: number): boolean { + return this.displayYear + step > MAX_YEAR; + } + onNextMonth() { + if (this.isNextMonthDisabled) { + return; + } + const newMonth = this.displayMonthNumber < 11 ? this.displayMonthNumber + 1 : 0; const newYear = this.displayMonthNumber < 11 ? this.displayYear : this.displayYear + 1; this.updateDisplay(newYear, newMonth); @@ -426,6 +556,10 @@ export class PoCalendarWrapperComponent implements OnInit, OnChanges { } onPreviousMonth() { + if (this.isPreviousMonthDisabled) { + return; + } + const newMonth = this.displayMonthNumber > 0 ? this.displayMonthNumber - 1 : 11; const newYear = this.displayMonthNumber > 0 ? this.displayYear : this.displayYear - 1; this.updateDisplay(newYear, newMonth); @@ -434,6 +568,10 @@ export class PoCalendarWrapperComponent implements OnInit, OnChanges { updateYear(value: number) { const newYear = this.displayYear + value; + if (newYear < MIN_YEAR || newYear > MAX_YEAR) { + return; + } + this.updateDisplay(newYear, this.displayMonthNumber); } @@ -455,7 +593,7 @@ export class PoCalendarWrapperComponent implements OnInit, OnChanges { } onSelectDate(date: Date) { - if (!this.poDate.validateDateRange(date, this.minDate, this.maxDate)) { + if (this.isDayDisabled(date)) { return; } this.selectDate.emit(date); @@ -819,6 +957,11 @@ export class PoCalendarWrapperComponent implements OnInit, OnChanges { } isDayDisabled(date: Date): boolean { + const year = date?.getFullYear(); + if (year < MIN_YEAR || year > MAX_YEAR) { + return true; + } + return !this.poDate.validateDateRange(date, this.minDate, this.maxDate); } @@ -827,7 +970,7 @@ export class PoCalendarWrapperComponent implements OnInit, OnChanges { const prefix = `po-calendar-box-${type}`; if (date.getMonth() !== this.displayMonthNumber) { - const isDisabled = !this.poDate.validateDateRange(date, this.minDate, this.maxDate); + const isDisabled = this.isDayDisabled(date); return isDisabled ? `${prefix}-other-month-disabled` : `${prefix}-other-month`; } @@ -871,32 +1014,26 @@ export class PoCalendarWrapperComponent implements OnInit, OnChanges { getColorForDate(date: Date, type: 'background' | 'foreground') { const prefix = `po-calendar-box-${type}`; - return this.poDate.validateDateRange(date, this.minDate, this.maxDate) - ? `${prefix}-selected` - : `${prefix}-selected-disabled`; + return this.isDayDisabled(date) ? `${prefix}-selected-disabled` : `${prefix}-selected`; } getColorForDefaultDate(date: Date, type: 'background' | 'foreground') { const prefix = `po-calendar-box-${type}`; - return this.poDate.validateDateRange(date, this.minDate, this.maxDate) ? prefix : `${prefix}-disabled`; + return this.isDayDisabled(date) ? `${prefix}-disabled` : prefix; } getColorForToday(date: Date, type: 'background' | 'foreground') { const prefix = `po-calendar-box-${type}`; - return this.poDate.validateDateRange(date, this.minDate, this.maxDate) - ? `${prefix}-today` - : `${prefix}-today-disabled`; + return this.isDayDisabled(date) ? `${prefix}-today-disabled` : `${prefix}-today`; } getColorForDateRange(date: Date, type: 'background' | 'foreground') { const prefix = `po-calendar-box-${type}`; - return this.poDate.validateDateRange(date, this.minDate, this.maxDate) - ? `${prefix}-in-range` - : `${prefix}-in-range-disabled`; + return this.isDayDisabled(date) ? `${prefix}-in-range-disabled` : `${prefix}-in-range`; } getColorState(date: Date, prefix: string, state: string) { - const isValid = this.poDate.validateDateRange(date, this.minDate, this.maxDate); + const isValid = !this.isDayDisabled(date); return isValid ? `${prefix}-${state}` : `${prefix}-${state}-disabled`; } diff --git a/projects/ui/src/lib/components/po-calendar/po-calendar.component.html b/projects/ui/src/lib/components/po-calendar/po-calendar.component.html index 6626f55eb6..b4a23dd8e2 100644 --- a/projects/ui/src/lib/components/po-calendar/po-calendar.component.html +++ b/projects/ui/src/lib/components/po-calendar/po-calendar.component.html @@ -117,7 +117,7 @@ [class.po-button-selected]="i === selectedIndexYear" role="option" p-kind="tertiary" - p-label="{{ year }}" + p-label="{{ formatYearLabel(year) }}" [attr.aria-selected]="i === selectedIndexYear" [p-tabindex]="i === focusedIndex ? 0 : -1" [p-size]="size" diff --git a/projects/ui/src/lib/components/po-calendar/po-calendar.component.spec.ts b/projects/ui/src/lib/components/po-calendar/po-calendar.component.spec.ts index 4a83951866..f581bd8ad1 100644 --- a/projects/ui/src/lib/components/po-calendar/po-calendar.component.spec.ts +++ b/projects/ui/src/lib/components/po-calendar/po-calendar.component.spec.ts @@ -547,6 +547,36 @@ describe('PoCalendarComponent:', () => { expect(component.activateDate.end.getFullYear()).toEqual(2024); }); + it(`onHeaderChange: should keep year between 0 and 99 when changing the start header`, () => { + const start = new Date('Tue Jan 31 2023 00:00:00'); + const end = new Date('Wed Feb 01 2023 00:00:00'); + + component.activateDate = { start, end }; + + spyOnProperty(component, 'isRange').and.returnValue(true); + + component.onHeaderChange({ month: 2, year: 0 }, 'start'); + + expect(component.activateDate.start.getFullYear()).toEqual(0); + expect(component.activateDate.start.getMonth()).toEqual(1); + expect(component.activateDate.start.getDate()).toEqual(29); + }); + + it(`onHeaderChange: should keep year between 0 and 99 when changing the end header`, () => { + const start = new Date('Fri Nov 03 2023 00:00:00'); + const end = new Date('Fri Dec 01 2023 00:00:00'); + + component.activateDate = { start, end }; + + spyOnProperty(component, 'isRange').and.returnValue(true); + + component.onHeaderChange({ month: 6, year: 50 }, 'end'); + + expect(component.activateDate.end.getFullYear()).toEqual(50); + expect(component.activateDate.end.getMonth()).toEqual(5); + expect(component.activateDate.end.getDate()).toEqual(1); + }); + it(`onHeaderChange: `, () => { const start = new Date(2011, 0, 10); const end = new Date(2011, 1, 10); @@ -1364,6 +1394,20 @@ describe('PoCalendarComponent:', () => { expect(component.isMonthDisabled(0)).toBeTrue(); }); + + it('should validate months when selectedYear is 0', () => { + component.minDate = new Date(2025, 0, 1); + component['selectedYear'] = 0; + + expect(component.isMonthDisabled(0)).toBeTrue(); + }); + + it('should return false when selectedYear is null', () => { + component.minDate = new Date(2025, 0, 1); + component['selectedYear'] = null; + + expect(component.isMonthDisabled(0)).toBeFalse(); + }); }); describe('isYearDisabled:', () => { @@ -1429,6 +1473,23 @@ describe('PoCalendarComponent:', () => { expect(component.isYearDisabled(2025)).toBeFalse(); }); + + it('should return true when selectedMonth is set and year+month is before minDate in year 0', () => { + const minDate = new Date(2025, 5, 1); + minDate.setFullYear(0); + component.minDate = minDate; + component['selectedMonth'] = 3; + + expect(component.isYearDisabled(0)).toBeTrue(); + }); + + it('should return false when selectedMonth is set and there is no minDate or maxDate', () => { + component.minDate = undefined; + component.maxDate = undefined; + component['selectedMonth'] = 3; + + expect(component.isYearDisabled(0)).toBeFalse(); + }); }); describe('selectMonth:', () => { @@ -1538,6 +1599,39 @@ describe('PoCalendarComponent:', () => { expect(component['updateModel']).not.toHaveBeenCalled(); }); + + [-1, 10000, 2025.5, NaN, Infinity].forEach(year => { + it(`should ignore invalid selected year ${String(year)} without changing state or emitting`, () => { + const onChange = jasmine.createSpy('onChange'); + component['selectedIndexYear'] = 5; + component['selectedYear'] = 2025; + component.registerOnChange(onChange); + spyOn(component.change, 'emit'); + + component.selectYear(0, undefined, true, year); + + expect(component['selectedIndexYear']).toBe(5); + expect(component['selectedYear']).toBe(2025); + expect(component.change.emit).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + }); + }); + + [0, 9999].forEach(year => { + it(`should select and emit the valid year boundary ${year}`, () => { + const onChange = jasmine.createSpy('onChange'); + component.registerOnChange(onChange); + spyOn(component.change, 'emit'); + + component.selectYear(0, undefined, true, year); + + const emittedDate = (component.change.emit as jasmine.Spy).calls.mostRecent().args[0] as Date; + expect(component['selectedIndexYear']).toBe(0); + expect(component['selectedYear']).toBe(year); + expect(emittedDate.getFullYear()).toBe(year); + expect(onChange).toHaveBeenCalledOnceWith(emittedDate); + }); + }); }); describe('onKeydownMonth:', () => { @@ -1830,6 +1924,33 @@ describe('PoCalendarComponent:', () => { expect(component['propagateChange']).toHaveBeenCalled(); }); + + it('should emit Date with year 0 when selectedYear is 0 in month-year mode', () => { + component.mode = PoCalendarMode.MonthYear; + component['selectedMonth'] = 3; + component['selectedYear'] = 0; + + spyOn(component.change, 'emit'); + + component['updateModel'](3); + + const emittedValue = (component.change.emit as jasmine.Spy).calls.mostRecent().args[0]; + expect(emittedValue.getFullYear()).toBe(0); + expect(emittedValue.getMonth()).toBe(2); + }); + + it('should emit Date with year 0 when selectedYear is 0 in year mode', () => { + component.mode = PoCalendarMode.Year; + component['selectedYear'] = 0; + + spyOn(component.change, 'emit'); + + component['updateModel'](0); + + const emittedValue = (component.change.emit as jasmine.Spy).calls.mostRecent().args[0]; + expect(emittedValue.getFullYear()).toBe(0); + expect(emittedValue.getMonth()).toBe(0); + }); }); describe('writeDate for month-year/year modes:', () => { @@ -1890,6 +2011,247 @@ describe('PoCalendarComponent:', () => { const expectedIndex = component.displayYears.indexOf(targetYear); expect(component['selectedIndexYear']).toBe(expectedIndex); }); + + it('should include and select the year outside the yearRangeLimit in month-year mode', () => { + const date = new Date(2025, 5, 1); + date.setFullYear(50); + + component['writeDate'](date); + + expect(component.displayYears.length).toBe(302); + expect(component.displayYears[0]).toBe(50); + expect(component['selectedYear']).toBe(50); + expect(component['selectedIndexYear']).toBe(0); + }); + + it('should include and select the year outside the yearRangeLimit in year mode', () => { + component.mode = PoCalendarMode.Year; + component.ngOnInit(); + fixture.detectChanges(); + const date = new Date(2025, 0, 1); + date.setFullYear(9999); + + component['writeDate'](date); + + expect(component.displayYears.length).toBe(302); + expect(component.displayYears[component.displayYears.length - 1]).toBe(9999); + expect(component['selectedYear']).toBe(9999); + expect(component['selectedIndexYear']).toBe(301); + }); + + [-1, 10000, 2025.5, NaN, Infinity].forEach(year => { + it(`should clear an invalid direct month-year year ${String(year)} without emitting`, () => { + const onChange = jasmine.createSpy('onChange'); + component['selectedIndexYear'] = 10; + component['selectedYear'] = 2025; + component.registerOnChange(onChange); + spyOn(component.change, 'emit'); + + component['writeDate'](year); + + expect(component['selectedIndexYear']).toBeNull(); + expect(component['selectedYear']).toBeNull(); + expect(component.change.emit).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + }); + }); + + [0, 9999].forEach(year => { + it(`should preserve the valid month-year boundary ${year} without emitting`, () => { + const date = new Date(2025, 5, 1); + const onChange = jasmine.createSpy('onChange'); + date.setFullYear(year); + component.registerOnChange(onChange); + spyOn(component.change, 'emit'); + + component['writeDate'](date); + + expect(component['selectedYear']).toBe(year); + expect(component['selectedIndexYear']).toBe(component.displayYears.indexOf(year)); + expect(component.displayYears).toContain(year); + expect(component.change.emit).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + }); + }); + }); + + describe('updateDisplayYears:', () => { + it('should not duplicate the year when it is inside the yearRangeLimit', () => { + const currentYear = new Date().getFullYear(); + + component['updateDisplayYears'](currentYear); + + expect(component.displayYears.length).toBe(301); + expect(component.displayYears.filter(year => year === currentYear).length).toBe(1); + }); + + it('should not include the year when it is not a finite number', () => { + component['updateDisplayYears'](NaN); + + expect(component.displayYears.length).toBe(301); + }); + + it('should not include the year when it is greater than 9999', () => { + component['updateDisplayYears'](10000); + + expect(component.displayYears.length).toBe(301); + expect(component.displayYears).not.toContain(10000); + }); + + it('should include the year 9999 when it is outside the yearRangeLimit', () => { + component['updateDisplayYears'](9999); + + expect(component.displayYears.length).toBe(302); + expect(component.displayYears[component.displayYears.length - 1]).toBe(9999); + }); + + it('should keep displayYears sorted when including a year outside the yearRangeLimit', () => { + component['updateDisplayYears'](0); + + const sortedYears = [...component.displayYears].sort((a, b) => a - b); + expect(component.displayYears).toEqual(sortedYears); + expect(component.displayYears[0]).toBe(0); + }); + + it('should clamp the lower end at year 0', () => { + const currentYear = new Date().getFullYear(); + component.yearRangeLimit = currentYear + 1; + + component['updateDisplayYears'](); + + expect(component.displayYears[0]).toBe(0); + expect(component.displayYears).toEqual([...component.displayYears].sort((a, b) => a - b)); + expect(component.displayYears.every(year => year >= 0)).toBeTrue(); + }); + + it('should clamp an extremely large finite range to exactly years 0 through 9999', () => { + component.yearRangeLimit = Number.MAX_SAFE_INTEGER; + + component['updateDisplayYears'](); + + expect(component.displayYears.length).toBe(10000); + expect(component.displayYears[0]).toBe(0); + expect(component.displayYears[9999]).toBe(9999); + }); + + it('should preserve a common range limit', () => { + const currentYear = new Date().getFullYear(); + component.yearRangeLimit = 10; + + component['updateDisplayYears'](); + + expect(component.displayYears.length).toBe(21); + expect(component.displayYears[0]).toBe(currentYear - 10); + expect(component.displayYears[20]).toBe(currentYear + 10); + }); + + [ + { limit: -1, expectedLength: 301 }, + { limit: 25.9, expectedLength: 51 }, + { limit: NaN, expectedLength: 301 }, + { limit: Infinity, expectedLength: 301 } + ].forEach(({ limit, expectedLength }) => { + it(`should normalize range limit ${String(limit)} before building display years`, () => { + component.yearRangeLimit = limit; + + component['updateDisplayYears'](); + + expect(component.displayYears.length).toBe(expectedLength); + expect(component.displayYears.every(year => Number.isInteger(year) && year >= 0 && year <= 9999)).toBeTrue(); + }); + }); + + it('should never append invalid selected years', () => { + [NaN, Infinity, 10000, 2025.5].forEach(year => { + component['updateDisplayYears'](year); + + expect(component.displayYears).not.toContain(year); + expect( + component.displayYears.every( + displayYear => Number.isInteger(displayYear) && displayYear >= 0 && displayYear <= 9999 + ) + ).toBeTrue(); + }); + }); + }); + + describe('formatYearLabel:', () => { + it('should return year formatted with 4 digits when year is less than 1000', () => { + expect(component.formatYearLabel(0)).toBe('0000'); + expect(component.formatYearLabel(50)).toBe('0050'); + }); + + it('should return year without changes when year has 4 digits', () => { + expect(component.formatYearLabel(2025)).toBe('2025'); + }); + + it('should return year as string when it is not supported by formatYear', () => { + expect(component.formatYearLabel(-1)).toBe('-1'); + }); + }); + + describe('scrollToSelectedYear:', () => { + let list: any; + + const createItem = (top: number) => ({ offsetHeight: 40, getBoundingClientRect: () => ({ top }) }); + + beforeEach(() => { + component.mode = PoCalendarMode.Year; + component.displayYears = [2000, 2001, 2002]; + list = { + scrollTop: 0, + clientHeight: 200, + children: [createItem(0), createItem(500), createItem(100)], + getBoundingClientRect: () => ({ top: 0 }) + }; + + spyOn(window, 'requestAnimationFrame').and.callFake((callback: FrameRequestCallback) => { + callback(0); + return 0; + }); + spyOn(component['elementRef'].nativeElement, 'querySelector').and.returnValue(list); + }); + + it('should not schedule the scroll when mode is not year or month-year', () => { + component.mode = PoCalendarMode.Range; + + component.scrollToSelectedYear(); + + expect(window.requestAnimationFrame).not.toHaveBeenCalled(); + }); + + it('should center the selected year when it is not visible', () => { + component.selectedYear = 2001; + + component.scrollToSelectedYear(); + + expect(list.scrollTop).toBe(420); + }); + + it('should center the current year when there is no selected year', () => { + const currentYear = new Date().getFullYear(); + component.displayYears = [currentYear - 1, currentYear, currentYear + 1]; + component.selectedYear = null; + + component.scrollToSelectedYear(); + + expect(list.scrollTop).toBe(420); + }); + + it('should keep the scroll when the selected year is already visible', () => { + component.selectedYear = 2002; + + component.scrollToSelectedYear(); + + expect(list.scrollTop).toBe(0); + }); + + it('should not throw when the year is not in the list', () => { + component.selectedYear = 1999; + + expect(() => component.scrollToSelectedYear()).not.toThrow(); + expect(list.scrollTop).toBe(0); + }); }); describe('writeValue for month-year/year modes:', () => { @@ -1906,6 +2268,44 @@ describe('PoCalendarComponent:', () => { expect(component['selectedIndexMonth']).toBeNull(); expect(component['selectedIndexYear']).toBeNull(); }); + + [-1, 10000, 2025.5, NaN, Infinity].forEach(year => { + it(`should not retain or propagate invalid year ${String(year)} from writeValue`, () => { + const onChange = jasmine.createSpy('onChange'); + component.mode = PoCalendarMode.Year; + component.ngOnInit(); + component['selectedIndexYear'] = 10; + component['selectedYear'] = 2025; + component.registerOnChange(onChange); + spyOn(component.change, 'emit'); + + component.writeValue(year); + + expect(component['selectedIndexYear']).toBeNull(); + expect(component['selectedYear']).toBeNull(); + expect(component.change.emit).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + }); + }); + + ['0000', '9999'].forEach(value => { + it(`should preserve valid year boundary ${value} from writeValue without emitting`, () => { + const year = Number(value); + const onChange = jasmine.createSpy('onChange'); + component.mode = PoCalendarMode.Year; + component.ngOnInit(); + component.registerOnChange(onChange); + spyOn(component.change, 'emit'); + + component.writeValue(value); + + expect(component['selectedYear']).toBe(year); + expect(component['selectedIndexYear']).toBe(component.displayYears.indexOf(year)); + expect(component.displayYears).toContain(year); + expect(component.change.emit).not.toHaveBeenCalled(); + expect(onChange).not.toHaveBeenCalled(); + }); + }); }); describe('isMonthDisabled when selectedYear is undefined:', () => { diff --git a/projects/ui/src/lib/components/po-calendar/po-calendar.component.ts b/projects/ui/src/lib/components/po-calendar/po-calendar.component.ts index 1e6243ec98..7b8f30b69c 100644 --- a/projects/ui/src/lib/components/po-calendar/po-calendar.component.ts +++ b/projects/ui/src/lib/components/po-calendar/po-calendar.component.ts @@ -9,6 +9,7 @@ import { QueryList, ViewChild, forwardRef, + ElementRef, ViewChildren, SimpleChanges, ChangeDetectorRef, @@ -16,7 +17,7 @@ import { } from '@angular/core'; import { AbstractControl, NG_VALIDATORS, NG_VALUE_ACCESSOR } from '@angular/forms'; -import { isMobile } from '../../utils/util'; +import { createDate, formatYear, isMobile, isValidYear } from '../../utils/util'; import { PoButtonComponent } from '../po-button'; import { PoTimerComponent } from '../po-timer/po-timer.component'; import { PoCalendarLangService, PoCalendarService } from './services'; @@ -81,6 +82,7 @@ export class PoCalendarComponent extends PoCalendarBaseComponent implements OnIn @ViewChildren('monthOption') monthOptions: QueryList; private readonly changeDetector = inject(ChangeDetectorRef); + private readonly elementRef = inject(ElementRef); private readonly poCalendarService = inject(PoCalendarService); private readonly poCalendarLangService = inject(PoCalendarLangService); @@ -180,7 +182,7 @@ export class PoCalendarComponent extends PoCalendarBaseComponent implements OnIn } isMonthDisabled(monthIndex: number): boolean { - if (!this.selectedYear) return false; + if (this.selectedYear === null || this.selectedYear === undefined) return false; const year = this.selectedYear; const month = monthIndex + 1; @@ -218,7 +220,7 @@ export class PoCalendarComponent extends PoCalendarBaseComponent implements OnIn if (selectedMonth !== undefined && selectedMonth !== null) { const month = selectedMonth; - if (minYear && (year < minYear || (year === minYear && month < minMonth))) { + if (minYear !== undefined && (year < minYear || (year === minYear && month < minMonth))) { return true; } @@ -315,10 +317,14 @@ export class PoCalendarComponent extends PoCalendarBaseComponent implements OnIn const isSelectAction = event?.key === 'Enter' || event?.code === 'Space' || selected; if (isSelectAction) { - this.selectedIndexYear = index; - this.selectedYear = year; + const selectedYear = year ?? this.displayYears[index]; - this.updateModel(this.selectedYear); + if (isValidYear(selectedYear)) { + this.selectedIndexYear = index; + this.selectedYear = selectedYear; + + this.updateModel(this.selectedYear); + } } if (yearOptions[index]) { @@ -338,10 +344,59 @@ export class PoCalendarComponent extends PoCalendarBaseComponent implements OnIn private initializeYearAndMonthMode(): void { this.displayMonths = this.poCalendarLangService.getMonthsArray(); + this.updateDisplayYears(); + this.scrollToSelectedYear(); + } + + /** + * Centraliza na lista o ano selecionado, ou o ano atual quando não houver seleção, caso ele não esteja visível. + */ + scrollToSelectedYear(): void { + if (this.mode !== 'year' && this.mode !== 'month-year') { + return; + } + + requestAnimationFrame(() => { + const list: HTMLElement = this.elementRef.nativeElement.querySelector('.po-calendar-years'); + const year = this.hasSelectedYear() ? this.selectedYear : new Date().getFullYear(); + const item = list?.children[this.displayYears.indexOf(year)] as HTMLElement; + + if (!item) { + return; + } + + const itemTop = item.getBoundingClientRect().top - list.getBoundingClientRect().top; + const isVisible = itemTop >= 0 && itemTop + item.offsetHeight <= list.clientHeight; + + if (!isVisible) { + list.scrollTop += itemTop - (list.clientHeight - item.offsetHeight) / 2; + } + }); + } + + formatYearLabel(year: number): string { + return formatYear(year) ?? `${year}`; + } + + // O ano 0 é válido, por isso não pode ser verificado como booleano. + private hasSelectedYear(): boolean { + return isValidYear(this.selectedYear); + } + + private updateDisplayYears(selectedYear?: number | null): void { const currentYear = new Date().getFullYear(); const rangeLimit = this.yearRangeLimit ?? 150; + const startYear = Math.max(0, currentYear - rangeLimit); + const endYear = Math.min(9999, currentYear + rangeLimit); + + const years = Array.from({ length: endYear - startYear + 1 }, (_, i) => startYear + i); + + if (isValidYear(selectedYear) && !years.includes(selectedYear)) { + years.push(selectedYear); + years.sort((a, b) => a - b); + } - this.displayYears = Array.from({ length: rangeLimit * 2 + 1 }, (_, i) => currentYear - rangeLimit + i); + this.displayYears = years; } getActivateDate(partType) { @@ -467,6 +522,7 @@ export class PoCalendarComponent extends PoCalendarBaseComponent implements OnIn this.setActivateDate(activateDate); this.changeDetector.markForCheck(); + this.scrollToSelectedYear(); } onPresetSelected(event: { label: string; start: Date; end: Date }): void { @@ -616,8 +672,8 @@ export class PoCalendarComponent extends PoCalendarBaseComponent implements OnIn private buildDateWithMonthYear(baseDate: Date, month: number, year: number): Date { const day = baseDate instanceof Date ? baseDate.getDate() : 1; - const daysInTargetMonth = new Date(year, month + 1, 0).getDate(); - return new Date(year, month, Math.min(day, daysInTargetMonth)); + const daysInTargetMonth = createDate(year, month + 1, 0).getDate(); + return createDate(year, month, Math.min(day, daysInTargetMonth)); } private convertDateToISO(date) { @@ -650,15 +706,15 @@ export class PoCalendarComponent extends PoCalendarBaseComponent implements OnIn let finalValue = value; if (MODE_MONTH_YEAR) { - if (this.selectedMonth && this.selectedYear) { - finalValue = new Date(this.selectedYear, this.selectedMonth - 1, 1); + if (this.selectedMonth && this.hasSelectedYear()) { + finalValue = createDate(this.selectedYear, this.selectedMonth - 1, 1); this.change.emit(finalValue); } else { return; } } else if (MODE_YEAR) { - if (this.selectedYear) { - finalValue = new Date(this.selectedYear, 0, 1); + if (this.hasSelectedYear()) { + finalValue = createDate(this.selectedYear, 0, 1); this.change.emit(finalValue); } else { return; @@ -676,28 +732,20 @@ export class PoCalendarComponent extends PoCalendarBaseComponent implements OnIn } private setYear(index: number, year: number) { - this.selectedIndexYear = index; - this.selectedYear = year; + if (isValidYear(year)) { + this.selectedIndexYear = index; + this.selectedYear = year; + } else { + this.selectedIndexYear = null; + this.selectedYear = null; + } } private writeDate(value: any) { if (this.mode === 'month-year') { - const date = new Date(value); - const monthIndex = date.getMonth(); - const year = date.getFullYear(); - const yearOptions = this.getYearOptions(); - - const yearIndex = yearOptions.findIndex(btn => Number(btn.label()) === year); - - this.setMonth(monthIndex); - this.setYear(yearIndex, year); + this.writeMonthYearDate(value); } else if (this.mode === 'year') { - const year = new Date(value).getFullYear(); - const yearOptions = this.getYearOptions(); - - const yearIndex = yearOptions.findIndex(btn => Number(btn.label()) === year); - - this.setYear(yearIndex, year); + this.writeYearDate(value); } else if (this.isRange) { const start = value?.start; const end = value?.end; @@ -710,4 +758,47 @@ export class PoCalendarComponent extends PoCalendarBaseComponent implements OnIn this.value = value instanceof Date ? new Date(value) : this.convertDateFromIso(value); } } + + private writeMonthYearDate(value: any) { + const isNumericValue = typeof value === 'number' || (typeof value === 'string' && Number.isFinite(Number(value))); + const date = isNumericValue ? null : new Date(value); + const year = date?.getFullYear(); + + if (!isValidYear(year)) { + this.setYear(null, year); + return; + } + + const monthIndex = date.getMonth(); + + this.updateDisplayYears(year); + const yearIndex = this.displayYears.indexOf(year); + + this.setMonth(monthIndex); + this.setYear(yearIndex, year); + } + + private writeYearDate(value: any) { + const year = this.parseYearValue(value); + + if (!isValidYear(year)) { + this.setYear(null, year); + return; + } + + this.updateDisplayYears(year); + const yearIndex = this.displayYears.indexOf(year); + + this.setYear(yearIndex, year); + } + + private parseYearValue(value: any): number { + const isNumericString = typeof value === 'string' && Number.isFinite(Number(value)); + + if (typeof value === 'number' || isNumericString) { + return /^\d{1,4}$/.test(String(value)) ? Number(value) : Number.NaN; + } + + return new Date(value).getFullYear(); + } } diff --git a/projects/ui/src/lib/components/po-calendar/services/po-calendar.service.spec.ts b/projects/ui/src/lib/components/po-calendar/services/po-calendar.service.spec.ts index 26aa0e14b7..275da9e7e0 100644 --- a/projects/ui/src/lib/components/po-calendar/services/po-calendar.service.spec.ts +++ b/projects/ui/src/lib/components/po-calendar/services/po-calendar.service.spec.ts @@ -42,6 +42,27 @@ describe('PoCalendarService:', () => { expect(result).toEqual(new Date(2024, 5, 10)); }); + it('should parse yyyy-mm-dd string with year between 0 and 99 without converting it to 19XX', () => { + const result = (service as any).parseDate('0050-01-01'); + expect(result.getFullYear()).toBe(50); + expect(result.getMonth()).toBe(0); + expect(result.getDate()).toBe(1); + }); + + it('should parse ISO string with year between 0 and 99 without converting it to 19XX', () => { + const result = (service as any).parseDate('0060-12-31T12:00:00'); + expect(result.getFullYear()).toBe(60); + expect(result.getMonth()).toBe(11); + expect(result.getDate()).toBe(31); + }); + + it('should parse Date object with year between 0 and 99 without converting it to 19XX', () => { + const date = new Date(2024, 5, 10); + date.setFullYear(50); + const result = (service as any).parseDate(date); + expect(result.getFullYear()).toBe(50); + }); + it('should return undefined for non-Date, non-string values', () => { expect((service as any).parseDate({})).toBeUndefined(); expect((service as any).parseDate(123)).toBeUndefined(); @@ -78,6 +99,22 @@ describe('PoCalendarService:', () => { }); describe('Methods:', () => { + describe('getYearOptions:', () => { + it('should return years between minDate and maxDate when year is between 0 and 99', () => { + const options = service.getYearOptions('0050-01-01', '0060-12-31'); + + expect(options.length).toBe(11); + expect(options[0]).toEqual({ label: '0050', value: 50 }); + expect(options[options.length - 1]).toEqual({ label: '0060', value: 60 }); + }); + + it('should return labels formatted with 4 digits', () => { + const options = service.getYearOptions('0998-01-01', '1001-12-31'); + + expect(options.map(option => option.label)).toEqual(['0998', '0999', '1000', '1001']); + }); + }); + it('monthDates: should get month days with year is less than 100', () => { expect(service.monthDays(25, 7).length).toBe(6); }); diff --git a/projects/ui/src/lib/components/po-calendar/services/po-calendar.service.ts b/projects/ui/src/lib/components/po-calendar/services/po-calendar.service.ts index 164cc1f5bf..99c0e5d46c 100644 --- a/projects/ui/src/lib/components/po-calendar/services/po-calendar.service.ts +++ b/projects/ui/src/lib/components/po-calendar/services/po-calendar.service.ts @@ -1,5 +1,7 @@ import { Injectable } from '@angular/core'; +import { createDate, formatYear } from '../../../utils/util'; + const poCalendarServiceFirstWeekDayDefault: number = 0; @Injectable({ @@ -13,7 +15,7 @@ export class PoCalendarService { const dateOnlyRegex = /^(\d{4})-(\d{2})-(\d{2})$/; if (dateOnlyRegex.test(dateValue)) { const [, year, month, day] = dateValue.match(dateOnlyRegex).map(Number); - return new Date(year, month - 1, day); + return createDate(year, month - 1, day); } const parsed = new Date(dateValue); @@ -26,9 +28,9 @@ export class PoCalendarService { if (dateValue instanceof Date) { if (dateValue.getHours() !== 0 && dateValue.getUTCHours() === 0 && dateValue.getUTCMinutes() === 0) { - return new Date(dateValue.getUTCFullYear(), dateValue.getUTCMonth(), dateValue.getUTCDate()); + return createDate(dateValue.getUTCFullYear(), dateValue.getUTCMonth(), dateValue.getUTCDate()); } - return new Date(dateValue.getFullYear(), dateValue.getMonth(), dateValue.getDate()); + return createDate(dateValue.getFullYear(), dateValue.getMonth(), dateValue.getDate()); } return undefined; @@ -52,7 +54,7 @@ export class PoCalendarService { const options = []; for (let i = minYear; i <= maxYear; i++) { - options.push({ label: i.toString(), value: i }); + options.push({ label: formatYear(i), value: i }); } return options; diff --git a/projects/ui/src/lib/components/po-field/po-datepicker-range/po-datepicker-range.component.spec.ts b/projects/ui/src/lib/components/po-field/po-datepicker-range/po-datepicker-range.component.spec.ts index cc5eea63f1..d90a3bb4a6 100644 --- a/projects/ui/src/lib/components/po-field/po-datepicker-range/po-datepicker-range.component.spec.ts +++ b/projects/ui/src/lib/components/po-field/po-datepicker-range/po-datepicker-range.component.spec.ts @@ -2107,7 +2107,8 @@ describe('PoDatepickerRangeComponent:', () => { expect(component['hasAttrCalendar'](fakeElement)).toBeFalsy(); }); - it('initializeListeners: should initialize listeners and call `wasClickedOnPicker` and `addEventListener`.', () => { + it('initializeListeners: should initialize listeners and call `listen` of mouseDownGuard, `wasClickedOnPicker` and `addEventListener`.', () => { + const mouseDownGuardListen = spyOn(component['mouseDownGuard'], 'listen'); const wasClickedOnPicker = spyOn(component, 'wasClickedOnPicker'); const addEventListener = spyOn(window, 'addEventListener'); const listen = spyOn(component['renderer'], 'listen').and.callFake((target, eventName, callback) => @@ -2116,11 +2117,24 @@ describe('PoDatepickerRangeComponent:', () => { component['initializeListeners'](); + expect(mouseDownGuardListen).toHaveBeenCalledWith(component['renderer']); expect(wasClickedOnPicker).toHaveBeenCalled(); expect(addEventListener).toHaveBeenCalled(); expect(listen).toHaveBeenCalled(); }); + it('initializeListeners: should remove the previous listeners to not duplicate the `click` listener', () => { + const removeClickListener = jasmine.createSpy('removeClickListener'); + spyOn(component['renderer'], 'listen').and.callFake((target, eventName) => + eventName === 'click' ? removeClickListener : () => {} + ); + + component['initializeListeners'](); + component['initializeListeners'](); + + expect(removeClickListener).toHaveBeenCalledTimes(1); + }); + it('onScroll: should call `controlPosition.adjustPosition()`', () => { component.isCalendarVisible = true; spyOn(component['controlPosition'], 'adjustPosition'); @@ -2324,18 +2338,66 @@ describe('PoDatepickerRangeComponent:', () => { expect(component.isCalendarVisible).toBeFalse(); }); + + it('should not set isCalendarVisible with false if the mouse was pressed on calendar and released outside', () => { + component.isCalendarVisible = true; + spyOn(component as any, 'isInsidePicker').and.returnValue(true); + component['mouseDownGuard'].register(document.createElement('div')); + + spyOn(component as any, 'hasAttrCalendar').and.returnValue(false); + + component['wasClickedOnPicker'](event); + + expect(component.isCalendarVisible).toBeTrue(); + expect(component['mouseDownGuard'].consume()).toBeFalse(); + }); + + it('should set isCalendarVisible with false on the next click outside after ignoring the mouse released outside', () => { + component.isCalendarVisible = true; + spyOn(component as any, 'isInsidePicker').and.returnValue(true); + component['mouseDownGuard'].register(document.createElement('div')); + + spyOn(component as any, 'hasAttrCalendar').and.returnValue(false); + + component['wasClickedOnPicker'](event); + component['wasClickedOnPicker'](event); + + expect(component.isCalendarVisible).toBeFalse(); + }); + }); + + describe('isInsidePicker:', () => { + it('should return true when the target is inside calendar', () => { + component.calendarPicker = { nativeElement: { contains: () => true } } as any; + + expect(component['isInsidePicker'](document.createElement('div'))).toBeTrue(); + }); + + it('should return false when the target is outside calendar', () => { + component.calendarPicker = { nativeElement: { contains: () => false } } as any; + + expect(component['isInsidePicker'](document.createElement('div'))).toBeFalse(); + }); + + it('should return false when not have a `calendarPicker`', () => { + component.calendarPicker = undefined; + + expect(component['isInsidePicker'](document.createElement('div'))).toBeFalse(); + }); }); - it('removeListeners: should remove click, resize and scroll listeners', () => { + it('removeListeners: should remove mousedown, click, resize and scroll listeners', () => { component['clickListener'] = () => {}; component['eventResizeListener'] = () => {}; + spyOn(component['mouseDownGuard'], 'destroy'); spyOn(component, 'clickListener'); spyOn(component, 'eventResizeListener'); spyOn(window, 'removeEventListener'); component['removeListeners'](); + expect(component['mouseDownGuard'].destroy).toHaveBeenCalled(); expect(component['clickListener']).toHaveBeenCalled(); expect(component['eventResizeListener']).toHaveBeenCalled(); expect(window.removeEventListener).toHaveBeenCalled(); diff --git a/projects/ui/src/lib/components/po-field/po-datepicker-range/po-datepicker-range.component.ts b/projects/ui/src/lib/components/po-field/po-datepicker-range/po-datepicker-range.component.ts index 802c484020..9895395ae0 100644 --- a/projects/ui/src/lib/components/po-field/po-datepicker-range/po-datepicker-range.component.ts +++ b/projects/ui/src/lib/components/po-field/po-datepicker-range/po-datepicker-range.component.ts @@ -21,6 +21,7 @@ import { PoControlPositionService } from './../../../services/po-control-positio import { PoLanguageService } from '../../../services/po-language/po-language.service'; import { PoDateService } from './../../../services/po-date/po-date.service'; import { isMobile, replaceFormatSeparator, setHelperSettings, uuid } from './../../../utils/util'; +import { PoPickerMouseDownGuard } from './../../../utils/po-picker-mouse-down-guard'; import { PoDatepickerRange } from './interfaces/po-datepicker-range.interface'; import { PoDatepickerRangeBaseComponent } from './po-datepicker-range-base.component'; import { PoHelperComponent } from '../../po-helper'; @@ -112,6 +113,7 @@ export class PoDatepickerRangeComponent private clickListener; private eventResizeListener; + private readonly mouseDownGuard = new PoPickerMouseDownGuard(target => this.isInsidePicker(target)); private readonly poDatepickerRangeElement: ElementRef; private readonly MIN_CALENDAR_WIDTH_WITH_PRESETS = { medium: 624, @@ -722,6 +724,9 @@ export class PoDatepickerRangeComponent } private initializeListeners() { + this.removeListeners(); + this.mouseDownGuard.listen(this.renderer); + this.clickListener = this.renderer.listen('document', 'click', (event: MouseEvent) => { this.wasClickedOnPicker(event); }); @@ -799,6 +804,8 @@ export class PoDatepickerRangeComponent } private removeListeners() { + this.mouseDownGuard.destroy(); + if (this.clickListener) { this.clickListener(); } @@ -908,6 +915,10 @@ export class PoDatepickerRangeComponent return; } + if (this.mouseDownGuard.consume()) { + return; + } + const calendarEl = this.calendarPicker?.nativeElement; const iconEl = this.iconCalendar?.buttonElement?.nativeElement; const target = event.target as Node; @@ -925,6 +936,12 @@ export class PoDatepickerRangeComponent this.cd.markForCheck(); } + private isInsidePicker(target: EventTarget): boolean { + const calendarEl = this.calendarPicker?.nativeElement; + + return !!calendarEl && calendarEl.contains(target); + } + closeCalendar() { this.isCalendarVisible = false; setTimeout(() => { diff --git a/projects/ui/src/lib/components/po-field/po-datepicker/po-datepicker-base.component.spec.ts b/projects/ui/src/lib/components/po-field/po-datepicker/po-datepicker-base.component.spec.ts index 234c33fad2..e42b9d05dd 100644 --- a/projects/ui/src/lib/components/po-field/po-datepicker/po-datepicker-base.component.spec.ts +++ b/projects/ui/src/lib/components/po-field/po-datepicker/po-datepicker-base.component.spec.ts @@ -506,7 +506,39 @@ describe('PoDatepickerBaseComponent:', () => { it('should NOT return error for null value when mode is month-year', () => { component.mode = 'month-year'; - expect(component.validate(new UntypedFormControl(null))).toEqual(null); + expect(component.validate(new FormControl(null))).toEqual(null); + }); + + ['2025.5', '9999x'].forEach(value => { + it(`should return date error for strictly invalid year "${value}" and request change detection`, () => { + component.mode = 'year'; + component.errorPattern = ''; + component['cd'] = { markForCheck: () => {} } as any; + spyOn(component['cd'], 'markForCheck'); + + expect(component.validate(new FormControl(value))).toEqual(invalidDateError); + expect(component.errorPattern).toBe('Data inválida'); + expect(component['cd'].markForCheck).toHaveBeenCalled(); + }); + }); + + it('should return date error for a month-year with trailing year characters and request change detection', () => { + component.mode = 'month-year'; + component.errorPattern = ''; + component['cd'] = { markForCheck: () => {} } as any; + spyOn(component['cd'], 'markForCheck'); + + expect(component.validate(new FormControl('01/9999x'))).toEqual(invalidDateError); + expect(component.errorPattern).toBe('Data inválida'); + expect(component['cd'].markForCheck).toHaveBeenCalled(); + }); + + ['01/0000', '12/9999'].forEach(value => { + it(`should accept month-year boundary "${value}"`, () => { + component.mode = 'month-year'; + + expect(component.validate(new FormControl(value))).toBeNull(); + }); }); // Tests for year mode validation @@ -515,12 +547,15 @@ describe('PoDatepickerBaseComponent:', () => { expect(component.validate(new UntypedFormControl('2025'))).toEqual(null); }); - it('should return date error for invalid year "0000" when mode is year', () => { + it('should NOT return error for year "0000" when mode is year', () => { component.mode = 'year'; - component['cd'] = { markForCheck: () => {} } as any; - spyOn(component['cd'], 'markForCheck'); - const result = component.validate(new UntypedFormControl('0000')); - expect(result).toEqual({ date: { valid: false } }); + expect(component.validate(new UntypedFormControl('0000'))).toEqual(null); + }); + + it('should NOT return error for year "9999" when mode is year', () => { + component.mode = 'year'; + + expect(component.validate(new FormControl('9999'))).toBeNull(); }); it('should return date error for non-numeric year "abcd" when mode is year', () => { @@ -536,6 +571,21 @@ describe('PoDatepickerBaseComponent:', () => { expect(component.validate(new UntypedFormControl(''))).toEqual(null); }); + ['month-year', 'year'].forEach(mode => { + it(`should return only required error for an empty required value in ${mode} mode`, () => { + component.mode = mode as 'month-year' | 'year'; + component.required = true; + + expect(component.validate(new FormControl(''))).toEqual({ required: { valid: false } }); + }); + + it(`should accept a null optional value in ${mode} mode`, () => { + component.mode = mode as 'month-year' | 'year'; + + expect(component.validate(new FormControl(null))).toBeNull(); + }); + }); + // Tests for month-year date range validation it('should return date error when month-year date is before minDate', () => { component.mode = 'month-year'; @@ -989,6 +1039,36 @@ describe('PoDatepickerBaseComponent:', () => { expect(component.date.getFullYear()).toBe(2025); expect(component.date.getMonth()).toBe(0); }); + + it('should parse month-year string with year between 0 and 99 without converting it to 19XX', () => { + component['mode'] = 'month-year'; + component.date = '04/0050'; + expect(component.date.getFullYear()).toBe(50); + expect(component.date.getMonth()).toBe(3); + }); + + it('should parse year string with year 0 without converting it to 1900', () => { + component['mode'] = 'year'; + component.date = '0000'; + expect(component.date.getFullYear()).toBe(0); + expect(component.date.getMonth()).toBe(0); + }); + + ['ab/2025', '04/2025/01', '13/2025', '04/20255'].forEach(value => { + it(`should set date as undefined for invalid month-year string "${value}"`, () => { + component['mode'] = 'month-year'; + component.date = value; + expect(component.date).toBeUndefined(); + }); + }); + + ['2025x', '20255'].forEach(value => { + it(`should set date as undefined for invalid year string "${value}"`, () => { + component['mode'] = 'year'; + component.date = value; + expect(component.date).toBeUndefined(); + }); + }); }); describe('ngOnInit for month-year/year modes:', () => { @@ -1036,9 +1116,9 @@ describe('PoDatepickerBaseComponent:', () => { expect(component['isMonthYearOrYearInvalid']('2025')).toBeFalse(); }); - it('should return true for year 0 in year mode', () => { + it('should return false for year 0 in year mode', () => { component['mode'] = 'year'; - expect(component['isMonthYearOrYearInvalid']('0')).toBeTrue(); + expect(component['isMonthYearOrYearInvalid']('0000')).toBeFalse(); }); it('should return false for empty string', () => { @@ -1076,9 +1156,9 @@ describe('PoDatepickerBaseComponent:', () => { expect(component['isMonthYearOrYearInvalid']('xx/2025')).toBeTrue(); }); - it('should return true when month-year year is zero', () => { + it('should return false when month-year year is zero', () => { component['mode'] = 'month-year'; - expect(component['isMonthYearOrYearInvalid']('04/0')).toBeTrue(); + expect(component['isMonthYearOrYearInvalid']('04/0000')).toBeFalse(); }); it('should return true when month-year year is negative', () => { @@ -1091,6 +1171,21 @@ describe('PoDatepickerBaseComponent:', () => { expect(component['isMonthYearOrYearInvalid']('-1')).toBeTrue(); }); + it('should return false for year 9999 in year mode', () => { + component['mode'] = 'year'; + expect(component['isMonthYearOrYearInvalid']('9999')).toBeFalse(); + }); + + it('should return true for year greater than 9999 in year mode', () => { + component['mode'] = 'year'; + expect(component['isMonthYearOrYearInvalid']('10000')).toBeTrue(); + }); + + it('should return true when month-year year is greater than 9999', () => { + component['mode'] = 'month-year'; + expect(component['isMonthYearOrYearInvalid']('04/10000')).toBeTrue(); + }); + it('should return false for valid boundary month 1 in month-year mode', () => { component['mode'] = 'month-year'; expect(component['isMonthYearOrYearInvalid']('01/2025')).toBeFalse(); diff --git a/projects/ui/src/lib/components/po-field/po-datepicker/po-datepicker-base.component.ts b/projects/ui/src/lib/components/po-field/po-datepicker/po-datepicker-base.component.ts index b1b285df0c..94ce595310 100644 --- a/projects/ui/src/lib/components/po-field/po-datepicker/po-datepicker-base.component.ts +++ b/projects/ui/src/lib/components/po-field/po-datepicker/po-datepicker-base.component.ts @@ -15,8 +15,10 @@ import { AbstractControl, ControlValueAccessor, Validator, Validators } from '@a import { convertIsoToDate, convertToBoolean, + createDate, getDefaultSizeFn, isTypeof, + isValidYear, replaceFormatSeparator, validateSizeFn, PoUtils, @@ -59,8 +61,11 @@ const poDatepickerFormatDefault: string = 'dd/mm/yyyy'; * * **Importante:** * - * - Para utilizar datas com ano inferior a 100, verificar o comportamento do [`new Date`](https://www.w3schools.com/js/js_dates.asp) - * e utilizar o método [`setFullYear`](https://www.w3schools.com/jsref/jsref_setfullyear.asp). + * - São aceitas datas com ano entre 0000 e 9999. No calendário, além dos anos listados no seletor, é possível + * digitar qualquer ano com 4 dígitos. + * - Datas no formato ISO (ex.: `'0050-05-10'`) com ano inferior a 100 são mantidas. Ao informar um objeto `Date` + * com ano inferior a 100, utilize o método [`setFullYear`](https://www.w3schools.com/jsref/jsref_setfullyear.asp), + * pois o [`new Date`](https://www.w3schools.com/js/js_dates.asp) converte esses anos para 19XX. * - Caso a data esteja inválida, o `model` receberá **'Data inválida'**. * - Caso o `input` esteja passando um `[(ngModel)]`, mas não tenha um `name`, então irá ocorrer um erro * do próprio Angular (`[ngModelOptions]="{standalone: true}"`). @@ -122,8 +127,10 @@ export abstract class PoDatepickerBaseComponent implements ControlValueAccessor, * Define o modo de operação do datepicker. * * Permite configurar o componente para seleção de: - * - Mês e ano (`month-year`); - * - Apenas ano (`year`). + * - Mês e ano (`month-year`), com o *model* no formato `mm/yyyy` (ex.: `'03/2025'`); + * - Apenas ano (`year`), com o *model* no formato `yyyy` (ex.: `'2025'`). + * + * > O ano é sempre representado com 4 dígitos, inclusive para anos inferiores a 1000 (ex.: `'03/0050'`). */ @Input('p-mode') mode?: 'month-year' | 'year' = undefined; @@ -134,6 +141,9 @@ export abstract class PoDatepickerBaseComponent implements ControlValueAccessor, * O valor informado determina o intervalo de anos anterior e posterior * à data corrente que será disponibilizado para seleção. * + * > Caso o ano selecionado esteja fora desse intervalo, ele também é exibido na lista. Esta propriedade + * não se aplica ao seletor de ano do calendário padrão (sem `p-mode`). + * * @default 150 */ @Input('p-year-range-limit') set yearRangeLimit(value: number) { @@ -678,18 +688,7 @@ export abstract class PoDatepickerBaseComponent implements ControlValueAccessor, return; } - if (typeof value === 'string') { - if (this.mode === 'month-year') { - const [month, year] = value.split('/').map(Number); - this._date = new Date(year, month - 1, 1); - } else if (this.mode === 'year') { - this._date = new Date(Number(value), 0, 1); - } else { - this._date = convertIsoToDate(value, false, false); - } - } else { - this._date = value; - } + this._date = typeof value === 'string' ? this.convertStringToDate(value) : value; } get date() { @@ -883,22 +882,50 @@ export abstract class PoDatepickerBaseComponent implements ControlValueAccessor, if (this.mode === 'month-year') { const parts = value.split('/'); - if (parts.length !== 2) { + if (parts.length !== 2 || !/^\d{1,2}$/.test(parts[0])) { return true; } - const month = parseInt(parts[0], 10); - const year = parseInt(parts[1], 10); - return isNaN(month) || isNaN(year) || month < 1 || month > 12 || year <= 0; + const month = Number(parts[0]); + return month < 1 || month > 12 || this.parseYearToken(parts[1]) === null; } if (this.mode === 'year') { - const year = parseInt(value, 10); - return isNaN(year) || year <= 0; + return this.parseYearToken(value) === null; } return false; } + protected parseYearToken(value: string): number | null { + if (!/^\d{1,4}$/.test(value)) { + return null; + } + + const year = Number(value); + return isValidYear(year) ? year : null; + } + + private convertStringToDate(value: string): Date { + if (this.mode === 'month-year') { + return this.convertMonthYearToDate(value); + } + + if (this.mode === 'year') { + const year = this.parseYearToken(value); + return year === null ? undefined : createDate(year, 0, 1); + } + + return convertIsoToDate(value, false, false); + } + + private convertMonthYearToDate(value: string): Date { + const parts = value.split('/'); + const month = /^\d{1,2}$/.test(parts[0]) ? Number(parts[0]) : Number.NaN; + const year = parts.length === 2 ? this.parseYearToken(parts[1]) : null; + + return month >= 1 && month <= 12 && year !== null ? createDate(year, month - 1, 1) : undefined; + } + // Valida range de data considerando o modo private isDateRangeValidForMode(): boolean { if (this.mode === 'month-year') { diff --git a/projects/ui/src/lib/components/po-field/po-datepicker/po-datepicker.component.spec.ts b/projects/ui/src/lib/components/po-field/po-datepicker/po-datepicker.component.spec.ts index 5ecb39ebf5..d99cd88906 100644 --- a/projects/ui/src/lib/components/po-field/po-datepicker/po-datepicker.component.spec.ts +++ b/projects/ui/src/lib/components/po-field/po-datepicker/po-datepicker.component.spec.ts @@ -2,6 +2,7 @@ import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testin import { ElementRef, EventEmitter, SimpleChanges } from '@angular/core'; import { PoUtils as UtilsFunctions } from '../../../utils/util'; +import { PoPickerMouseDownGuard } from '../../../utils/po-picker-mouse-down-guard'; import { PoCalendarService } from '../../po-calendar/services/po-calendar.service'; import { PoCalendarLangService } from '../../po-calendar/services/po-calendar.lang.service'; @@ -1080,6 +1081,7 @@ describe('PoDatepickerComponent:', () => { }, hasOverlayClass: () => hasOverlayClass, hasAttrCalendar: () => hasAttrCalendar, + mouseDownGuard: new PoPickerMouseDownGuard(() => true), closeCalendar: () => {}, calendar: { visible: true @@ -1163,6 +1165,90 @@ describe('PoDatepickerComponent:', () => { expect(fakeThis.closeCalendar).not.toHaveBeenCalled(); }); + + it('shouldn`t call `closeCalendar` when the clicked element was removed from DOM but `composedPath` contains `dialogPicker`', () => { + const fakeThis = getFakeThis(false, false, false, false); + const eventWithComposedPath = { ...fakeEvent, composedPath: () => [fakeThis.dialogPicker.nativeElement] }; + + spyOn(fakeThis, 'closeCalendar'); + + component.wasClickedOnPicker.call(fakeThis, eventWithComposedPath); + + expect(fakeThis.closeCalendar).not.toHaveBeenCalled(); + }); + + it('should call `closeCalendar` when click outside and `composedPath` doesn`t contain `dialogPicker`', () => { + const fakeThis = getFakeThis(false, false, false, false); + const eventWithComposedPath = { ...fakeEvent, composedPath: () => [] }; + + spyOn(fakeThis, 'closeCalendar'); + + component.wasClickedOnPicker.call(fakeThis, eventWithComposedPath); + + expect(fakeThis.closeCalendar).toHaveBeenCalled(); + }); + + it('shouldn`t call `closeCalendar` when the mouse was pressed on picker and released outside', () => { + const fakeThis: any = getFakeThis(false, false, false, false); + fakeThis.mouseDownGuard.register(document.createElement('div')); + + spyOn(fakeThis, 'closeCalendar'); + + component.wasClickedOnPicker.call(fakeThis, fakeEvent); + + expect(fakeThis.closeCalendar).not.toHaveBeenCalled(); + expect(fakeThis.mouseDownGuard.consume()).toBeFalse(); + }); + + it('should call `closeCalendar` on the next click outside after ignoring the mouse released outside', () => { + const fakeThis: any = getFakeThis(false, false, false, false); + fakeThis.mouseDownGuard.register(document.createElement('div')); + + spyOn(fakeThis, 'closeCalendar'); + + component.wasClickedOnPicker.call(fakeThis, fakeEvent); + component.wasClickedOnPicker.call(fakeThis, fakeEvent); + + expect(fakeThis.closeCalendar).toHaveBeenCalledTimes(1); + }); + }); + + describe('isInsidePicker:', () => { + it('should return true when the target is inside picker', () => { + component.dialogPicker = { nativeElement: { contains: () => true } } as any; + spyOn(component, 'hasOverlayClass').and.returnValue(false); + + expect(component['isInsidePicker'](document.createElement('div'))).toBeTrue(); + }); + + it('should return false when the target is the overlay', () => { + component.dialogPicker = { nativeElement: { contains: () => true } } as any; + spyOn(component, 'hasOverlayClass').and.returnValue(true); + + expect(component['isInsidePicker'](document.createElement('div'))).toBeFalse(); + }); + + it('should return false when the target is outside picker', () => { + component.dialogPicker = { nativeElement: { contains: () => false } } as any; + + expect(component['isInsidePicker'](document.createElement('div'))).toBeFalse(); + }); + + it('should return false when not have a `dialogPicker`', () => { + component.dialogPicker = undefined; + + expect(component['isInsidePicker'](document.createElement('div'))).toBeFalse(); + }); + + it('should be used by `mouseDownGuard` to register the mousedown target', () => { + const target = document.createElement('div'); + spyOn(component as any, 'isInsidePicker').and.returnValue(true); + + component['mouseDownGuard'].register(target); + + expect(component['isInsidePicker']).toHaveBeenCalledWith(target); + expect(component['mouseDownGuard'].consume()).toBeTrue(); + }); }); it('hasAttrCalendar: should return true when element contain `attr-calendar` atribute', () => { @@ -1274,7 +1360,8 @@ describe('PoDatepickerComponent:', () => { expect(component.isValidExtendedIso(isoDate)).toBe(expectedValue); }); - it('initializeListeners: should initialize listeners and call `wasClickedOnPicker`, `closeCalendar` and `addEventListener`.', () => { + it('initializeListeners: should initialize listeners and call `listen` of mouseDownGuard, `wasClickedOnPicker`, `closeCalendar` and `addEventListener`.', () => { + const mouseDownGuardListen = spyOn(component['mouseDownGuard'], 'listen'); const wasClickedOnPicker = spyOn(component, 'wasClickedOnPicker'); const closeCalendar = spyOn(component, 'closeCalendar'); const addEventListener = spyOn(window, 'addEventListener'); @@ -1284,6 +1371,7 @@ describe('PoDatepickerComponent:', () => { component['initializeListeners'](); + expect(mouseDownGuardListen).toHaveBeenCalledWith(component['renderer']); expect(wasClickedOnPicker).toHaveBeenCalled(); expect(closeCalendar).toHaveBeenCalled(); expect(addEventListener).toHaveBeenCalled(); @@ -1562,6 +1650,25 @@ describe('PoDatepickerComponent:', () => { expect(component['initializeListeners']).toHaveBeenCalled(); }); + it('togglePicker: should call `scrollToSelectedYear` of calendar when opening', () => { + component.readonly = false; + component.disabled = false; + component.visible = false; + component.iconDatepicker = { + buttonElement: { + nativeElement: document.createElement('button') + } + } as PoButtonComponent; + component.calendar = jasmine.createSpyObj('calendar', ['scrollToSelectedYear']); + + spyOn(component, 'setCalendarPosition'); + spyOn(component, 'initializeListeners'); + + component.togglePicker(); + + expect(component.calendar.scrollToSelectedYear).toHaveBeenCalled(); + }); + it('setCalendarPosition: should call setDialogPickerStyleDisplay and adjustCalendarPosition', () => { const setDialogPickerStyleDisplaySpy = spyOn(component, 'setDialogPickerStyleDisplay'); const adjustCalendarPositionSpy = spyOn(component, 'adjustCalendarPosition'); @@ -1950,6 +2057,14 @@ describe('PoDatepickerComponent:', () => { expect(clickListenerSpy).toHaveBeenCalledTimes(1); }); + it('should call `destroy` of mouseDownGuard', () => { + const destroySpy = spyOn(component['mouseDownGuard'], 'destroy'); + + component['closeCalendar'](); + + expect(destroySpy).toHaveBeenCalledTimes(1); + }); + it('should focus iconDatepicker buttonElement via requestAnimationFrame', () => { const focusSpy = jasmine.createSpy('focus'); @@ -2096,6 +2211,81 @@ describe('PoDatepickerComponent:', () => { clearTimeout(component['timeoutChange']); }); + describe('writeValue:', () => { + [ + { mode: 'year' as const, value: '2025.5' }, + { mode: 'year' as const, value: '9999x' }, + { mode: 'month-year' as const, value: '01/9999x' } + ].forEach(({ mode, value }) => { + it(`should reject the strictly invalid ${mode} value "${value}" without propagating it`, () => { + const onChange = jasmine.createSpy('onChange'); + component.mode = mode; + component.date = undefined; + fixture.detectChanges(); + component.registerOnChange(onChange); + + component.writeValue(value); + + expect(component.inputEl.nativeElement.value).toBe(''); + expect(component.date).toBeUndefined(); + expect(onChange).not.toHaveBeenCalled(); + }); + }); + + [ + { value: '0000', year: 0 }, + { value: '9999', year: 9999 } + ].forEach(({ value, year }) => { + it(`should preserve and propagate the year boundary "${value}"`, () => { + const onChange = jasmine.createSpy('onChange'); + component.mode = 'year'; + fixture.detectChanges(); + component.registerOnChange(onChange); + + component.writeValue(value); + + expect(component.inputEl.nativeElement.value).toBe(value); + expect(component.date.getFullYear()).toBe(year); + expect(onChange).toHaveBeenCalledOnceWith(value); + }); + }); + + [ + { value: '01/0000', month: 0, year: 0 }, + { value: '12/9999', month: 11, year: 9999 } + ].forEach(({ value, month, year }) => { + it(`should preserve and propagate the month-year boundary "${value}"`, () => { + const onChange = jasmine.createSpy('onChange'); + component.mode = 'month-year'; + fixture.detectChanges(); + component.registerOnChange(onChange); + + component.writeValue(value); + + expect(component.inputEl.nativeElement.value).toBe(value); + expect(component.date.getMonth()).toBe(month); + expect(component.date.getFullYear()).toBe(year); + expect(onChange).toHaveBeenCalledOnceWith(value); + }); + }); + + [null, undefined, ''].forEach(value => { + it(`should reset state and call callOnChange for empty value ${String(value)}`, () => { + component.mode = 'year'; + component.date = new Date(2025, 0, 1); + component.inputEl.nativeElement.value = '2025'; + spyOn(component, 'callOnChange').and.callThrough(); + fixture.detectChanges(); + + component.writeValue(value); + + expect(component.inputEl.nativeElement.value).toBe(''); + expect(component.date).toBeUndefined(); + expect(component.callOnChange).toHaveBeenCalledWith(undefined, false); + }); + }); + }); + describe('handleMonthYearKeyup:', () => { it('should call callOnChange with parsed value when input is complete MM/YYYY', () => { component.mode = 'month-year'; @@ -2169,7 +2359,7 @@ describe('PoDatepickerComponent:', () => { expect(component.callOnChange).toHaveBeenCalledWith(''); }); - it('should call callOnChange with invalid date message for year 0', () => { + it('should call callOnChange with year 0 formatted with 4 digits', () => { component.mode = 'year'; component.ngOnInit(); fixture.detectChanges(); @@ -2180,6 +2370,20 @@ describe('PoDatepickerComponent:', () => { component['handleYearKeyup'](); + expect(component.callOnChange).toHaveBeenCalledWith('0000'); + }); + + it('should call callOnChange with invalid date message for non-numeric year', () => { + component.mode = 'year'; + component.ngOnInit(); + fixture.detectChanges(); + + spyOn(component, 'callOnChange'); + component.inputEl.nativeElement.value = 'abcd'; + component['objMask'] = { valueToModel: 'abcd', keyup: () => {}, blur: () => {} } as any; + + component['handleYearKeyup'](); + expect(component.callOnChange).toHaveBeenCalledWith('Data inválida'); }); }); @@ -2282,8 +2486,28 @@ describe('PoDatepickerComponent:', () => { expect(result).toBeNull(); }); - it('should return null for year 0', () => { + it('should return formatted MM/YYYY for year 0', () => { const result = component['parseMonthYearInput']('01/0000', '/'); + expect(result).toBe('01/0000'); + }); + + it('should return null for year greater than 9999', () => { + const result = component['parseMonthYearInput']('01/10000', '/'); + expect(result).toBeNull(); + }); + + it('should return year formatted with 4 digits for year between 1 and 999', () => { + const result = component['parseMonthYearInput']('01/50', '/'); + expect(result).toBe('01/0050'); + }); + + it('should return null for incomplete year that results in 0', () => { + const result = component['parseMonthYearInput']('01/00', '/'); + expect(result).toBeNull(); + }); + + it('should return null for negative year', () => { + const result = component['parseMonthYearInput']('01/-1', '/'); expect(result).toBeNull(); }); @@ -2324,6 +2548,16 @@ describe('PoDatepickerComponent:', () => { it('should not throw when value has wrong format', () => { expect(() => component['syncCalendarMonthYear']('2025')).not.toThrow(); }); + + it('should set date with year between 0 and 99 without converting it to 19XX', () => { + component.mode = 'month-year'; + fixture.detectChanges(); + + component['syncCalendarMonthYear']('06/0050'); + + expect(component.date.getMonth()).toBe(5); + expect(component.date.getFullYear()).toBe(50); + }); }); describe('syncCalendarYear:', () => { @@ -2334,6 +2568,13 @@ describe('PoDatepickerComponent:', () => { expect(component.date.getFullYear()).toBe(2025); expect(component.date.getMonth()).toBe(0); }); + + it('should set date with year 0 without converting it to 1900', () => { + component['syncCalendarYear'](0); + + expect(component.date.getFullYear()).toBe(0); + expect(component.date.getMonth()).toBe(0); + }); }); describe('writeMonthYearValue:', () => { @@ -2406,6 +2647,32 @@ describe('PoDatepickerComponent:', () => { expect(component.inputEl.nativeElement.value).toBe(''); }); + + it('should set input value and call callOnChange with year formatted with 4 digits', () => { + component.mode = 'month-year'; + component.locale = 'pt'; + fixture.detectChanges(); + + spyOn(component, 'callOnChange'); + + component['writeMonthYearValue']('03/0000'); + + expect(component.inputEl.nativeElement.value).toBe('03/0000'); + expect(component.callOnChange).toHaveBeenCalledWith('03/0000', false); + }); + + it('should clear input for year greater than 9999', () => { + component.mode = 'month-year'; + component.locale = 'pt'; + fixture.detectChanges(); + + spyOn(component, 'callOnChange'); + + component['writeMonthYearValue']('03/10000'); + + expect(component.inputEl.nativeElement.value).toBe(''); + expect(component.callOnChange).not.toHaveBeenCalled(); + }); }); describe('writeYearValue:', () => { @@ -2442,12 +2709,51 @@ describe('PoDatepickerComponent:', () => { expect(component.inputEl.nativeElement.value).toBe(''); }); - it('should clear input for year 0', () => { + it('should set input value and call callOnChange with year 0 formatted with 4 digits', () => { component.mode = 'year'; fixture.detectChanges(); + spyOn(component, 'callOnChange'); + component['writeYearValue']('0'); + expect(component.inputEl.nativeElement.value).toBe('0000'); + expect(component.callOnChange).toHaveBeenCalledWith('0000', false); + }); + + it('should clear input for negative year', () => { + component.mode = 'year'; + fixture.detectChanges(); + + component['writeYearValue'](-1); + + expect(component.inputEl.nativeElement.value).toBe(''); + }); + + it('should clear input for year greater than 9999', () => { + component.mode = 'year'; + fixture.detectChanges(); + + component['writeYearValue']('10000'); + + expect(component.inputEl.nativeElement.value).toBe(''); + }); + + it('should clear input for year that is not an integer', () => { + component.mode = 'year'; + fixture.detectChanges(); + + component['writeYearValue'](2025.5); + + expect(component.inputEl.nativeElement.value).toBe(''); + }); + + it('should clear input for infinite year', () => { + component.mode = 'year'; + fixture.detectChanges(); + + component['writeYearValue'](Infinity); + expect(component.inputEl.nativeElement.value).toBe(''); }); }); @@ -2597,6 +2903,39 @@ describe('PoDatepickerComponent:', () => { expect(component['controlChangeEmitter']).toHaveBeenCalled(); expect(component.togglePicker).toHaveBeenCalled(); }); + + it('should format year with 4 digits for month-year mode with Date event', () => { + component.mode = 'month-year'; + component.locale = 'pt'; + fixture.detectChanges(); + + spyOn(component, 'callOnChange'); + spyOn(component, 'controlChangeEmitter'); + spyOn(component, 'togglePicker'); + + const dateEvent = new Date(2025, 2, 1); + dateEvent.setFullYear(50); + component.dateSelected(dateEvent); + + expect(component.inputEl.nativeElement.value).toBe('03/0050'); + expect(component.callOnChange).toHaveBeenCalledWith('03/0050'); + }); + + it('should format year with 4 digits for year mode with Date event', () => { + component.mode = 'year'; + fixture.detectChanges(); + + spyOn(component, 'callOnChange'); + spyOn(component, 'controlChangeEmitter'); + spyOn(component, 'togglePicker'); + + const dateEvent = new Date(2025, 0, 1); + dateEvent.setFullYear(0); + component.dateSelected(dateEvent); + + expect(component.inputEl.nativeElement.value).toBe('0000'); + expect(component.callOnChange).toHaveBeenCalledWith('0000'); + }); }); describe('isValidInputForMode:', () => { @@ -2631,9 +2970,35 @@ describe('PoDatepickerComponent:', () => { expect(component['isValidInputForMode']('2025')).toBeTrue(); }); + it('should return true for year 0 input', () => { + component.mode = 'year'; + expect(component['isValidInputForMode']('0000')).toBeTrue(); + }); + it('should return false for invalid year input', () => { component.mode = 'year'; - expect(component['isValidInputForMode']('0000')).toBeFalse(); + expect(component['isValidInputForMode']('abcd')).toBeFalse(); + }); + + it('should return false for month-year input with year greater than 9999', () => { + component.mode = 'month-year'; + component.locale = 'pt'; + fixture.detectChanges(); + expect(component['isValidInputForMode']('06/10000')).toBeFalse(); + }); + + it('should return true for month-year input with year 0', () => { + component.mode = 'month-year'; + component.locale = 'pt'; + fixture.detectChanges(); + expect(component['isValidInputForMode']('06/0000')).toBeTrue(); + }); + + it('should return false for month-year input with incomplete year that results in 0', () => { + component.mode = 'month-year'; + component.locale = 'pt'; + fixture.detectChanges(); + expect(component['isValidInputForMode']('06/00')).toBeFalse(); }); it('should return false for year input with wrong length', () => { diff --git a/projects/ui/src/lib/components/po-field/po-datepicker/po-datepicker.component.ts b/projects/ui/src/lib/components/po-field/po-datepicker/po-datepicker.component.ts index 7cfb9ce542..2a15234255 100644 --- a/projects/ui/src/lib/components/po-field/po-datepicker/po-datepicker.component.ts +++ b/projects/ui/src/lib/components/po-field/po-datepicker/po-datepicker.component.ts @@ -15,7 +15,8 @@ import { } from '@angular/core'; import { NG_VALIDATORS, NG_VALUE_ACCESSOR } from '@angular/forms'; -import { isMobile, setHelperSettings, uuid, PoUtils } from '../../../utils/util'; +import { createDate, formatYear, isMobile, isValidYear, setHelperSettings, uuid, PoUtils } from '../../../utils/util'; +import { PoPickerMouseDownGuard } from '../../../utils/po-picker-mouse-down-guard'; import { PoControlPositionService } from './../../../services/po-control-position/po-control-position.service'; import { isObservable, of, Subscription, switchMap } from 'rxjs'; @@ -87,7 +88,7 @@ export class PoDatepickerComponent extends PoDatepickerBaseComponent implements private readonly controlPosition = inject(PoControlPositionService); private readonly renderer = inject(Renderer2); - @ViewChild('calendar', { static: true }) calendar: PoCalendarComponent; + @ViewChild('calendar') calendar: PoCalendarComponent; @ViewChild('dialogPicker', { read: ElementRef, static: false }) dialogPicker: ElementRef; @ViewChild('iconDatepicker') iconDatepicker: PoButtonComponent; @ViewChild('inp', { read: ElementRef, static: true }) inputEl: ElementRef; @@ -106,6 +107,7 @@ export class PoDatepickerComponent extends PoDatepickerBaseComponent implements eventResizeListener: () => void; private clickListener; + private readonly mouseDownGuard = new PoPickerMouseDownGuard(target => this.isInsidePicker(target)); private readonly dateRegex = new RegExp( '^(?:[0-9])\\d{1}(?:[0-9])\\d{1}-' + '(?:0[1-9]|1[0-2])-' + '(?:0[1-9]|[12]\\d|3[01])$' ); @@ -262,6 +264,7 @@ export class PoDatepickerComponent extends PoDatepickerBaseComponent implements if (!this.visible) { this.visible = true; this.setCalendarPosition(); + this.calendar?.scrollToSelectedYear(); this.initializeListeners(); this.renderer.setAttribute(this.inputEl.nativeElement, 'aria-expanded', 'true'); @@ -312,9 +315,9 @@ export class PoDatepickerComponent extends PoDatepickerBaseComponent implements if (this.mode === 'month-year' && event instanceof Date) { this.date = event; const separator = this.languageService.getDateSeparator(this.locale); - const displayValue = ('0' + (event.getMonth() + 1)).slice(-2) + separator + event.getFullYear(); + const displayValue = ('0' + (event.getMonth() + 1)).slice(-2) + separator + formatYear(event.getFullYear()); this.inputEl.nativeElement.value = displayValue; - const emitString = ('0' + (event.getMonth() + 1)).slice(-2) + '/' + event.getFullYear(); + const emitString = ('0' + (event.getMonth() + 1)).slice(-2) + '/' + formatYear(event.getFullYear()); this.callOnChange(emitString); this.controlChangeEmitter(); this.togglePicker(); @@ -323,8 +326,8 @@ export class PoDatepickerComponent extends PoDatepickerBaseComponent implements if (this.mode === 'year' && event instanceof Date) { this.date = event; - this.inputEl.nativeElement.value = `${event.getFullYear()}`; - this.callOnChange(`${event.getFullYear()}`); + this.inputEl.nativeElement.value = formatYear(event.getFullYear()); + this.callOnChange(formatYear(event.getFullYear())); this.controlChangeEmitter(); this.togglePicker(); return; @@ -342,8 +345,17 @@ export class PoDatepickerComponent extends PoDatepickerBaseComponent implements if (!this.dialogPicker || !this.iconDatepicker) { return; } + + if (this.mouseDownGuard.consume()) { + return; + } + + // O alvo pode ter sido removido do DOM durante o clique; o `composedPath` preserva o caminho original. + const dialogElement = this.dialogPicker.nativeElement; + const isInsideDialog = dialogElement.contains(event.target) || !!event.composedPath?.().includes(dialogElement); + if ( - (!this.dialogPicker.nativeElement.contains(event.target) || this.hasOverlayClass(event.target)) && + (!isInsideDialog || this.hasOverlayClass(event.target)) && !this.iconDatepicker.buttonElement.nativeElement.contains(event.target) && !this.hasAttrCalendar(event.target) ) { @@ -730,10 +742,10 @@ export class PoDatepickerComponent extends PoDatepickerBaseComponent implements } private applyYearValue(value: string, requireFullLength: boolean = false): void { - const year = parseInt(value, 10); - if (!isNaN(year) && year > 0 && (!requireFullLength || value.length === 4)) { + const year = this.parseYearToken(value); + if (year !== null && (!requireFullLength || value.length === 4)) { this.syncCalendarYear(year); - this.callOnChange(`${year}`); + this.callOnChange(formatYear(year)); } else { this.callOnChange('Data inválida'); } @@ -741,15 +753,22 @@ export class PoDatepickerComponent extends PoDatepickerBaseComponent implements private parseMonthYearInput(value: string, separator: string): string | null { const parts = value.split(separator); - if (parts.length !== 2) { + if (parts.length !== 2 || !/^\d{1,2}$/.test(parts[0])) { + return null; + } + const month = Number(parts[0]); + const year = this.parseYearToken(parts[1]); + if (month < 1 || month > 12 || year === null) { return null; } - const month = parseInt(parts[0], 10); - const year = parseInt(parts[1], 10); - if (isNaN(month) || isNaN(year) || month < 1 || month > 12 || year <= 0) { + if (this.isIncompleteZeroYear(parts[1], year)) { return null; } - return ('0' + month).slice(-2) + '/' + year; + return ('0' + month).slice(-2) + '/' + formatYear(year); + } + + private isIncompleteZeroYear(yearText: string, year: number): boolean { + return year === 0 && yearText.length < 4; } private syncCalendarMonthYear(modelValue: string): void { @@ -757,12 +776,12 @@ export class PoDatepickerComponent extends PoDatepickerBaseComponent implements if (parts.length === 2) { const month = parseInt(parts[0], 10); const year = parseInt(parts[1], 10); - this.date = new Date(year, month - 1, 1); + this.date = createDate(year, month - 1, 1); } } private syncCalendarYear(year: number): void { - this.date = new Date(year, 0, 1); + this.date = createDate(year, 0, 1); } private writeMonthYearValue(value: any): void { @@ -777,16 +796,16 @@ export class PoDatepickerComponent extends PoDatepickerBaseComponent implements } else if (typeof value === 'string') { const normalizedValue = value.replace('-', '/'); const parts = normalizedValue.split('/'); - if (parts.length === 2) { - month = parseInt(parts[0], 10); - year = parseInt(parts[1], 10); + if (parts.length === 2 && /^\d{1,2}$/.test(parts[0])) { + month = Number(parts[0]); + year = this.parseYearToken(parts[1]) ?? Number.NaN; } } - if (!isNaN(month) && !isNaN(year) && month >= 1 && month <= 12 && year > 0) { - const displayValue = ('0' + month).slice(-2) + separator + year; + if (!Number.isNaN(month) && month >= 1 && month <= 12 && isValidYear(year)) { + const displayValue = ('0' + month).slice(-2) + separator + formatYear(year); this.inputEl.nativeElement.value = displayValue; - const modelValue = ('0' + month).slice(-2) + '/' + year; + const modelValue = ('0' + month).slice(-2) + '/' + formatYear(year); this.syncCalendarMonthYear(modelValue); this.callOnChange(modelValue, false); } else { @@ -796,11 +815,11 @@ export class PoDatepickerComponent extends PoDatepickerBaseComponent implements } private writeYearValue(value: any): void { - const year = typeof value === 'string' ? Number.parseInt(value, 10) : value; - if (!Number.isNaN(year) && year > 0) { - this.inputEl.nativeElement.value = `${year}`; + const year = typeof value === 'string' ? this.parseYearToken(value) : value; + if (isValidYear(year)) { + this.inputEl.nativeElement.value = formatYear(year); this.syncCalendarYear(year); - this.callOnChange(`${year}`, false); + this.callOnChange(formatYear(year), false); } else { this.inputEl.nativeElement.value = ''; } @@ -842,17 +861,16 @@ export class PoDatepickerComponent extends PoDatepickerBaseComponent implements if (this.mode === 'month-year') { const separator = this.languageService.getDateSeparator(this.locale); const parts = value.split(separator); - if (parts.length !== 2) { + if (parts.length !== 2 || !/^\d{1,2}$/.test(parts[0])) { return false; } - const month = parseInt(parts[0], 10); - const year = parseInt(parts[1], 10); - return !isNaN(month) && !isNaN(year) && month >= 1 && month <= 12 && year > 0; + const month = Number(parts[0]); + const year = this.parseYearToken(parts[1]); + return month >= 1 && month <= 12 && year !== null && !this.isIncompleteZeroYear(parts[1], year); } if (this.mode === 'year') { - const year = parseInt(value, 10); - return !isNaN(year) && year > 0 && value.length === 4; + return this.parseYearToken(value) !== null && value.length === 4; } return true; @@ -868,6 +886,8 @@ export class PoDatepickerComponent extends PoDatepickerBaseComponent implements } private initializeListeners() { + this.mouseDownGuard.listen(this.renderer); + this.clickListener = this.renderer.listen('document', 'click', (event: MouseEvent) => { this.wasClickedOnPicker(event); }); @@ -879,6 +899,12 @@ export class PoDatepickerComponent extends PoDatepickerBaseComponent implements window.addEventListener('scroll', this.onScroll, true); } + private isInsidePicker(target: EventTarget): boolean { + const dialogElement = this.dialogPicker?.nativeElement; + + return !!dialogElement && dialogElement.contains(target) && !this.hasOverlayClass(target); + } + private isAdditionalHelpEventTriggered(): boolean { return ( this.additionalHelpEventTrigger === 'event' || @@ -901,6 +927,8 @@ export class PoDatepickerComponent extends PoDatepickerBaseComponent implements }; private removeListeners() { + this.mouseDownGuard.destroy(); + if (this.clickListener) { this.clickListener(); } diff --git a/projects/ui/src/lib/components/po-field/po-datetimepicker/po-datetimepicker-base.component.spec.ts b/projects/ui/src/lib/components/po-field/po-datetimepicker/po-datetimepicker-base.component.spec.ts index 11fd0fb4db..f874f64edf 100644 --- a/projects/ui/src/lib/components/po-field/po-datetimepicker/po-datetimepicker-base.component.spec.ts +++ b/projects/ui/src/lib/components/po-field/po-datetimepicker/po-datetimepicker-base.component.spec.ts @@ -101,6 +101,41 @@ describe('PoDatetimepickerBaseComponent:', () => { expect(onChangeSpy).toHaveBeenCalledTimes(2); expect(onChangeSpy.calls.mostRecent().args[0]).toContain('2026-05-12T14:30'); }); + + it('should keep the year when Date object has year below 100', () => { + spyOn(component, 'refreshValue'); + const date = new Date(2000, 3, 15, 10, 30); + date.setFullYear(50); + + component.writeValue(date); + + expect(component['date'].getFullYear()).toBe(50); + expect(component['date'].getMonth()).toBe(3); + expect(component['date'].getDate()).toBe(15); + expect(component['date'].getHours()).toBe(0); + expect(component['timeValue']).toBe('10:30'); + expect(component.getModelValue()).toContain('0050-04-15T10:30'); + }); + + it('should keep the year when ISO string with time has year below 100', () => { + spyOn(component, 'refreshValue'); + + component.writeValue('0050-04-15T10:30:00'); + + expect(component['date'].getFullYear()).toBe(50); + expect(component['date'].getMonth()).toBe(3); + expect(component['date'].getDate()).toBe(15); + expect(component['timeValue']).toBe('10:30'); + }); + + it('should keep the year when date-only ISO string has year below 100', () => { + spyOn(component, 'refreshValue'); + + component.writeValue('0050-04-15'); + + expect(component['date'].getFullYear()).toBe(50); + expect(component['timeValue']).toBe('00:00'); + }); }); describe('registerOnChange:', () => { @@ -852,4 +887,32 @@ describe('PoDatetimepickerBaseComponent - Effects (with fixture):', () => { expect(component.resolvedMaxDate).toBeUndefined(); }); }); + + describe('years below 100:', () => { + const createLowYearDate = (year: number, month: number, day: number, hours = 0, minutes = 0) => { + const date = new Date(2000, month, day, hours, minutes); + date.setFullYear(year); + return date; + }; + + it('should keep the year of p-min-date when Date object has year below 100', () => { + fixture.componentRef.setInput('p-min-date', createLowYearDate(50, 3, 15, 10)); + fixture.detectChanges(); + + expect(component.resolvedMinDate.getFullYear()).toBe(50); + expect(component.resolvedMinDate.getMonth()).toBe(3); + expect(component.resolvedMinDate.getDate()).toBe(15); + expect(component.resolvedMinDate.getHours()).toBe(0); + }); + + it('should keep the year of p-max-date when Date object has year below 100', () => { + fixture.componentRef.setInput('p-max-date', createLowYearDate(50, 3, 15, 10)); + fixture.detectChanges(); + + expect(component.resolvedMaxDate.getFullYear()).toBe(50); + expect(component.resolvedMaxDate.getHours()).toBe(23); + expect(component.resolvedMaxDate.getMinutes()).toBe(59); + expect(component.resolvedMaxDate.getSeconds()).toBe(59); + }); + }); }); diff --git a/projects/ui/src/lib/components/po-field/po-datetimepicker/po-datetimepicker-base.component.ts b/projects/ui/src/lib/components/po-field/po-datetimepicker/po-datetimepicker-base.component.ts index 808e78b652..6b11a5b8ae 100644 --- a/projects/ui/src/lib/components/po-field/po-datetimepicker/po-datetimepicker-base.component.ts +++ b/projects/ui/src/lib/components/po-field/po-datetimepicker/po-datetimepicker-base.component.ts @@ -630,7 +630,7 @@ export abstract class PoDatetimepickerBaseComponent implements ControlValueAcces if (!value) { this._resolvedMinDate = undefined; } else if (value instanceof Date) { - this._resolvedMinDate = new Date(value.getFullYear(), value.getMonth(), value.getDate(), 0, 0, 0); + this._resolvedMinDate = this.copyDateWithTime(value, 0, 0, 0); } else { this._resolvedMinDate = convertIsoToDate(value, true, false); } @@ -642,7 +642,7 @@ export abstract class PoDatetimepickerBaseComponent implements ControlValueAcces if (!value) { this._resolvedMaxDate = undefined; } else if (value instanceof Date) { - this._resolvedMaxDate = new Date(value.getFullYear(), value.getMonth(), value.getDate(), 23, 59, 59); + this._resolvedMaxDate = this.copyDateWithTime(value, 23, 59, 59); } else { this._resolvedMaxDate = convertIsoToDate(value, false, true); } @@ -660,7 +660,7 @@ export abstract class PoDatetimepickerBaseComponent implements ControlValueAcces } if (value instanceof Date) { - this._date = new Date(value.getFullYear(), value.getMonth(), value.getDate()); + this._date = this.copyDateWithTime(value); this._timeValue = this.extractTimeFromDate(value); } else if (typeof value === 'string') { this.processStringValue(value); @@ -969,7 +969,7 @@ export abstract class PoDatetimepickerBaseComponent implements ControlValueAcces const fullDate = new Date(value); if (!isNaN(fullDate.getTime())) { - this._date = new Date(fullDate.getFullYear(), fullDate.getMonth(), fullDate.getDate()); + this._date = this.copyDateWithTime(fullDate); this._timeValue = this.extractTimeFromDate(fullDate); } else { const parsedDate = convertIsoToDate(datePart, false, false); @@ -983,6 +983,14 @@ export abstract class PoDatetimepickerBaseComponent implements ControlValueAcces } } + // Copia a partir do timestamp, pois o `new Date(ano, mês, dia)` transforma os anos entre 0 e 99 para 19XX. + private copyDateWithTime(value: Date, hours = 0, minutes = 0, seconds = 0): Date { + const date = new Date(value); + date.setHours(hours, minutes, seconds, 0); + + return date; + } + private normalizeTimePart(timePart: string): string { const timeOnly = timePart.replace(/Z$/, '').replace(/[+-]\d{2}:?\d{2}$/, ''); return timeOnly.length >= 5 ? timeOnly.substring(0, 8) : timeOnly; diff --git a/projects/ui/src/lib/components/po-field/po-datetimepicker/po-datetimepicker.component.spec.ts b/projects/ui/src/lib/components/po-field/po-datetimepicker/po-datetimepicker.component.spec.ts index 7f4959a732..3ed518e0f0 100644 --- a/projects/ui/src/lib/components/po-field/po-datetimepicker/po-datetimepicker.component.spec.ts +++ b/projects/ui/src/lib/components/po-field/po-datetimepicker/po-datetimepicker.component.spec.ts @@ -746,6 +746,60 @@ describe('PoDatetimepickerComponent:', () => { expect(component.closeCalendar).toHaveBeenCalled(); component['removeListeners'](); }); + + it('should not close when the mouse is pressed on picker and released outside', () => { + component.visible = true; + fixture.detectChanges(); + + const dialogEl = document.createElement('div'); + const insideEl = document.createElement('input'); + dialogEl.appendChild(insideEl); + document.body.appendChild(dialogEl); + component['dialogPicker'] = { nativeElement: dialogEl } as any; + component['initializeListeners'](); + + spyOn(component, 'closeCalendar'); + + insideEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(component.closeCalendar).not.toHaveBeenCalled(); + + document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(component.closeCalendar).toHaveBeenCalledTimes(1); + component['removeListeners'](); + dialogEl.remove(); + }); + + it('should close when the mouse is pressed on overlay and released outside', () => { + component.visible = true; + fixture.detectChanges(); + + const dialogEl = document.createElement('div'); + const overlayEl = document.createElement('div'); + overlayEl.classList.add('po-datetimepicker-calendar-overlay'); + dialogEl.appendChild(overlayEl); + document.body.appendChild(dialogEl); + component['dialogPicker'] = { nativeElement: dialogEl } as any; + component['initializeListeners'](); + + spyOn(component, 'closeCalendar'); + + overlayEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(component.closeCalendar).toHaveBeenCalled(); + component['removeListeners'](); + dialogEl.remove(); + }); + + it('should not consider the target inside picker when dialogPicker is undefined', () => { + component['dialogPicker'] = undefined; + + expect(component['isInsidePicker'](document.createElement('div'))).toBeFalse(); + }); }); describe('onKeyup:', () => { @@ -1530,6 +1584,28 @@ describe('PoDatetimepickerComponent:', () => { component['removeListeners'](); }); + it('should register mousedown listener that calls isInsidePicker', () => { + spyOn(component as any, 'isInsidePicker').and.returnValue(true); + component['initializeListeners'](); + + document.dispatchEvent(new MouseEvent('mousedown')); + + expect(component['isInsidePicker']).toHaveBeenCalled(); + expect(component['mouseDownGuard'].consume()).toBeTrue(); + component['removeListeners'](); + }); + + it('should remove mousedown listener on removeListeners', () => { + spyOn(component as any, 'isInsidePicker').and.returnValue(true); + component['initializeListeners'](); + component['removeListeners'](); + + document.dispatchEvent(new MouseEvent('mousedown')); + + expect(component['isInsidePicker']).not.toHaveBeenCalled(); + expect(component['mouseDownGuard'].consume()).toBeFalse(); + }); + it('should register resize listener that calls closeCalendar', () => { spyOn(component, 'closeCalendar'); component['initializeListeners'](); diff --git a/projects/ui/src/lib/components/po-field/po-datetimepicker/po-datetimepicker.component.ts b/projects/ui/src/lib/components/po-field/po-datetimepicker/po-datetimepicker.component.ts index 0959e0849b..44b0d19f07 100644 --- a/projects/ui/src/lib/components/po-field/po-datetimepicker/po-datetimepicker.component.ts +++ b/projects/ui/src/lib/components/po-field/po-datetimepicker/po-datetimepicker.component.ts @@ -18,6 +18,7 @@ import { PoLanguageService } from '../../../services'; import { PoDatetimepickerLiterals } from './po-datetimepicker.literals'; import { PoCalendarComponent } from '../../po-calendar/po-calendar.component'; import { isMobile, setHelperSettings, uuid, PoUtils } from '../../../utils/util'; +import { PoPickerMouseDownGuard } from '../../../utils/po-picker-mouse-down-guard'; import { PoDatetimepickerBaseComponent } from './po-datetimepicker-base.component'; import { PoControlPositionService } from '../../../services/po-control-position/po-control-position.service'; @@ -94,6 +95,7 @@ export class PoDatetimepickerComponent extends PoDatetimepickerBaseComponent imp private clickListener: () => void; private eventResizeListener: () => void; + private readonly mouseDownGuard = new PoPickerMouseDownGuard(target => this.isInsidePicker(target)); private valueBeforeChange: string = ''; private hadValueOnOpen: boolean = false; private isTodayPresetInProgress: boolean = false; @@ -179,6 +181,11 @@ export class PoDatetimepickerComponent extends PoDatetimepickerBaseComponent imp if (!this.dialogPicker || !this.iconDatepicker) { return; } + + if (this.mouseDownGuard.consume()) { + return; + } + if ( (!this.dialogPicker.nativeElement.contains(event.target) || this.hasOverlayClass(event.target)) && !this.iconDatepicker.buttonElement.nativeElement.contains(event.target) && @@ -863,6 +870,8 @@ export class PoDatetimepickerComponent extends PoDatetimepickerBaseComponent imp } private initializeListeners(): void { + this.mouseDownGuard.listen(this.renderer); + this.clickListener = this.renderer.listen('document', 'click', (event: MouseEvent) => { this.wasClickedOnPicker(event); }); @@ -874,7 +883,15 @@ export class PoDatetimepickerComponent extends PoDatetimepickerBaseComponent imp window.addEventListener('scroll', this.onScroll, true); } + private isInsidePicker(target: EventTarget): boolean { + const dialogElement = this.dialogPicker?.nativeElement; + + return !!dialogElement && dialogElement.contains(target) && !this.hasOverlayClass(target); + } + private removeListeners(): void { + this.mouseDownGuard.destroy(); + if (this.clickListener) { this.clickListener(); this.clickListener = undefined; diff --git a/projects/ui/src/lib/components/po-listbox/po-item-list/po-item-list.component.spec.ts b/projects/ui/src/lib/components/po-listbox/po-item-list/po-item-list.component.spec.ts index 9fc3106ca8..56e884de6b 100644 --- a/projects/ui/src/lib/components/po-listbox/po-item-list/po-item-list.component.spec.ts +++ b/projects/ui/src/lib/components/po-listbox/po-item-list/po-item-list.component.spec.ts @@ -180,6 +180,21 @@ describe('PoItemListComponent', () => { expect(html['changingThisBreaksApplicationSecurity']).toBe('values'); }); + it('safeHtml: should return the same instance when called again with the same value', () => { + const firstHtml = component.safeHtml('values'); + const secondHtml = component.safeHtml('values'); + + expect(secondHtml).toBe(firstHtml); + }); + + it('safeHtml: should return a new instance when called with a different value', () => { + const firstHtml = component.safeHtml('values'); + const secondHtml = component.safeHtml('other values'); + + expect(secondHtml).not.toBe(firstHtml); + expect(secondHtml['changingThisBreaksApplicationSecurity']).toBe('other values'); + }); + it('sanitizeTagHTML: should replace < and > with < and > respectively', () => { const expectedValue = '<input> Testando'; const value = ' Testando'; diff --git a/projects/ui/src/lib/components/po-listbox/po-item-list/po-item-list.component.ts b/projects/ui/src/lib/components/po-listbox/po-item-list/po-item-list.component.ts index c988dd48a2..667a026353 100644 --- a/projects/ui/src/lib/components/po-listbox/po-item-list/po-item-list.component.ts +++ b/projects/ui/src/lib/components/po-listbox/po-item-list/po-item-list.component.ts @@ -21,6 +21,8 @@ export class PoItemListComponent extends PoItemListBaseComponent implements OnCh protected param; protected clickListener: () => void; + private lastSafeHtml: { value: string; result: SafeHtml }; + private shouldUpdateSelected(changes: SimpleChanges): boolean { return !!(changes.searchValue && changes.label && changes.searchValue.currentValue === changes.label.currentValue); } @@ -114,7 +116,12 @@ export class PoItemListComponent extends PoItemListBaseComponent implements OnCh } safeHtml(value): SafeHtml { - return this.sanitized.bypassSecurityTrustHtml(value); + // Um novo SafeHtml a cada change detection recria o elemento e faz o `click` se perder. + if (this.lastSafeHtml?.value !== value) { + this.lastSafeHtml = { value, result: this.sanitized.bypassSecurityTrustHtml(value) }; + } + + return this.lastSafeHtml.result; } private sanitizeTagHTML(value: string = '') { 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 d6929da15b..3b06bd6c53 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 @@ -1532,6 +1532,112 @@ describe('PoListBoxComponent', () => { expect(() => component['onListboxFocusIn']({ target: document.createElement('li') } as any)).not.toThrow(); expect(setActiveOptionSpy).not.toHaveBeenCalled(); }); + + it('should focus the first option when the listbox element is focused and no option is active', () => { + component.type = 'option'; + component.listboxSubitems = false; + const listboxElement = document.createElement('ul'); + const firstOption = { element: document.createElement('li'), focus: jasmine.createSpy('focus') } as any; + component.listboxItemList = { nativeElement: listboxElement } as any; + component.cdkOptions = { + find: (cb: any) => [firstOption].find(cb), + some: (cb: any) => [firstOption].some(cb), + first: firstOption + } as any; + + component['onListboxFocusIn']({ target: listboxElement } as any); + + expect(firstOption.focus).toHaveBeenCalled(); + }); + + it('should focus the first enabled option when the first option is disabled and no option is active', () => { + component.type = 'option'; + component.listboxSubitems = false; + const listboxElement = document.createElement('ul'); + const disabledOption = { + element: document.createElement('li'), + disabled: true, + focus: jasmine.createSpy('focus') + } as any; + const enabledOption = { element: document.createElement('li'), focus: jasmine.createSpy('focus') } as any; + const options = [disabledOption, enabledOption]; + component.listboxItemList = { nativeElement: listboxElement } as any; + component.cdkOptions = { + find: (cb: any) => options.find(cb), + some: (cb: any) => options.some(cb), + first: disabledOption + } as any; + + component['onListboxFocusIn']({ target: listboxElement } as any); + + expect(disabledOption.focus).not.toHaveBeenCalled(); + expect(enabledOption.focus).toHaveBeenCalled(); + }); + + it('should not focus any option when all options are disabled and no option is active', () => { + component.type = 'option'; + component.listboxSubitems = false; + const listboxElement = document.createElement('ul'); + const disabledOption = { + element: document.createElement('li'), + disabled: true, + focus: jasmine.createSpy('focus') + } as any; + component.listboxItemList = { nativeElement: listboxElement } as any; + component.cdkOptions = { + find: (cb: any) => [disabledOption].find(cb), + some: (cb: any) => [disabledOption].some(cb), + first: disabledOption + } as any; + + component['onListboxFocusIn']({ target: listboxElement } as any); + + expect(disabledOption.focus).not.toHaveBeenCalled(); + }); + + it('should not focus the first option when the listbox element is focused and an option is active', () => { + component.type = 'option'; + component.listboxSubitems = false; + isActiveSpy.and.returnValue(true); + const listboxElement = document.createElement('ul'); + const firstOption = { element: document.createElement('li'), focus: jasmine.createSpy('focus') } as any; + component.listboxItemList = { nativeElement: listboxElement } as any; + component.cdkOptions = { + find: () => undefined, + some: (cb: any) => [firstOption].some(cb), + first: firstOption + } as any; + + component['onListboxFocusIn']({ target: listboxElement } as any); + + expect(firstOption.focus).not.toHaveBeenCalled(); + }); + + it('should not focus the first option when the focused element is not the listbox element', () => { + component.type = 'option'; + component.listboxSubitems = false; + const firstOption = { element: document.createElement('li'), focus: jasmine.createSpy('focus') } as any; + component.listboxItemList = { nativeElement: document.createElement('ul') } as any; + component.cdkOptions = { + find: () => undefined, + some: (cb: any) => [firstOption].some(cb), + first: firstOption + } as any; + + component['onListboxFocusIn']({ target: document.createElement('li') } as any); + + expect(firstOption.focus).not.toHaveBeenCalled(); + }); + + it('should not throw when the listbox element is focused and `cdkOptions` is undefined', () => { + component.type = 'option'; + component.listboxSubitems = false; + const listboxElement = document.createElement('ul'); + component.listboxItemList = { nativeElement: listboxElement } as any; + component.cdkOptions = undefined; + + expect(() => component['onListboxFocusIn']({ target: listboxElement } as any)).not.toThrow(); + }); }); describe('optionClicked:', () => { 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 a3cda39feb..14efe8d3fd 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 @@ -355,6 +355,21 @@ export class PoListBoxComponent extends PoListBoxBaseComponent implements OnInit if (focusedOption && !this.cdkListbox.isActive(focusedOption)) { this.cdkListbox._setActiveOption(focusedOption); } + + this.focusFirstOptionWhenNoneActive(focusedElement); + } + + // Com a seleção fora da lista filtrada, o cdkListbox não ativa nenhuma opção e o foco ficaria no `ul`. + private focusFirstOptionWhenNoneActive(focusedElement: HTMLElement): void { + if (focusedElement !== this.listboxItemList?.nativeElement) { + return; + } + + const hasActiveOption = this.cdkOptions?.some(option => this.cdkListbox.isActive(option)); + + if (!hasActiveOption) { + this.cdkOptions?.find(option => !option.disabled)?.focus(); + } } protected checkInfiniteScroll(): void { diff --git a/projects/ui/src/lib/services/po-date/po-date.service.spec.ts b/projects/ui/src/lib/services/po-date/po-date.service.spec.ts index 90912ff9dc..863ae45b04 100644 --- a/projects/ui/src/lib/services/po-date/po-date.service.spec.ts +++ b/projects/ui/src/lib/services/po-date/po-date.service.spec.ts @@ -48,6 +48,21 @@ describe('PoDateTimeService:', () => { expect(result).toEqual('2017-10-30T23:59:59'); }); + + it('should return local date at midnight if value is a date-only ISO and minDate and maxDate are false', () => { + const result = dateService.convertIsoToDate('2017-10-30', false, false); + + expect(result).toEqual(new Date(2017, 9, 30, 0, 0, 0)); + }); + + it('should keep the year and day of a date-only ISO with year below 100', () => { + const result = dateService.convertIsoToDate('0050-01-01', false, false); + + expect(result.getFullYear()).toBe(50); + expect(result.getMonth()).toBe(0); + expect(result.getDate()).toBe(1); + expect(result.getHours()).toBe(0); + }); }); describe('convertDateToISO:', () => { diff --git a/projects/ui/src/lib/services/po-date/po-date.service.ts b/projects/ui/src/lib/services/po-date/po-date.service.ts index f2f32065af..5dc8bff99b 100644 --- a/projects/ui/src/lib/services/po-date/po-date.service.ts +++ b/projects/ui/src/lib/services/po-date/po-date.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; -import { sortValues } from '../../utils/util'; +import { createDate } from '../../utils/util'; /** * @docsPrivate @@ -45,6 +45,9 @@ export class PoDateService { const date = new Date(year, month - 1, day, 23, 59, 59); this.setYearFrom0To100(date, year); return date; + } else if (this.dateRegex.test(dateString)) { + // Somar o fuso atual não funciona para datas cujo fuso histórico é diferente do atual (ex.: anos inferiores a 100). + return createDate(year, month - 1, day); } else { const miliseconds = Date.parse(dateString); const timezone = new Date().getTimezoneOffset() * 60000; diff --git a/projects/ui/src/lib/utils/po-picker-mouse-down-guard.spec.ts b/projects/ui/src/lib/utils/po-picker-mouse-down-guard.spec.ts new file mode 100644 index 0000000000..69fd674549 --- /dev/null +++ b/projects/ui/src/lib/utils/po-picker-mouse-down-guard.spec.ts @@ -0,0 +1,96 @@ +import { PoPickerMouseDownGuard } from './po-picker-mouse-down-guard'; + +describe('PoPickerMouseDownGuard:', () => { + let isInsidePicker: jasmine.Spy; + let guard: PoPickerMouseDownGuard; + + beforeEach(() => { + isInsidePicker = jasmine.createSpy('isInsidePicker').and.returnValue(true); + guard = new PoPickerMouseDownGuard(isInsidePicker); + }); + + describe('register:', () => { + it('should make `consume` return true when the target is inside the picker', () => { + const target = document.createElement('div'); + + guard.register(target); + + expect(isInsidePicker).toHaveBeenCalledWith(target); + expect(guard.consume()).toBeTrue(); + }); + + it('should make `consume` return false when the target is outside the picker', () => { + isInsidePicker.and.returnValue(false); + + guard.register(document.createElement('div')); + + expect(guard.consume()).toBeFalse(); + }); + + it('should make `consume` return false without calling `isInsidePicker` when target is undefined', () => { + guard.register(undefined); + + expect(isInsidePicker).not.toHaveBeenCalled(); + expect(guard.consume()).toBeFalse(); + }); + }); + + describe('consume:', () => { + it('should return false by default', () => { + expect(guard.consume()).toBeFalse(); + }); + + it('should reset the state so it is valid only for the next click', () => { + guard.register(document.createElement('div')); + + expect(guard.consume()).toBeTrue(); + expect(guard.consume()).toBeFalse(); + }); + }); + + describe('listen:', () => { + it('should listen `mousedown` on document and register the event target', () => { + const target = document.createElement('div'); + const renderer: any = { + listen: jasmine.createSpy('listen').and.callFake((_element, _eventName, callback) => { + callback({ target }); + return () => {}; + }) + }; + + guard.listen(renderer); + + expect(renderer.listen).toHaveBeenCalledWith('document', 'mousedown', jasmine.any(Function)); + expect(guard.consume()).toBeTrue(); + }); + + it('should remove the previous listener before listening again', () => { + const removeListener = jasmine.createSpy('removeListener'); + const renderer: any = { listen: jasmine.createSpy('listen').and.returnValue(removeListener) }; + + guard.listen(renderer); + guard.listen(renderer); + + expect(removeListener).toHaveBeenCalledTimes(1); + expect(renderer.listen).toHaveBeenCalledTimes(2); + }); + }); + + describe('destroy:', () => { + it('should remove the listener and reset the state', () => { + const removeListener = jasmine.createSpy('removeListener'); + const renderer: any = { listen: () => removeListener }; + + guard.listen(renderer); + guard.register(document.createElement('div')); + guard.destroy(); + + expect(removeListener).toHaveBeenCalledTimes(1); + expect(guard.consume()).toBeFalse(); + }); + + it('should not throw when there is no listener', () => { + expect(() => guard.destroy()).not.toThrow(); + }); + }); +}); diff --git a/projects/ui/src/lib/utils/po-picker-mouse-down-guard.ts b/projects/ui/src/lib/utils/po-picker-mouse-down-guard.ts new file mode 100644 index 0000000000..24f77bdd7b --- /dev/null +++ b/projects/ui/src/lib/utils/po-picker-mouse-down-guard.ts @@ -0,0 +1,46 @@ +import { Renderer2 } from '@angular/core'; + +/** + * @docsPrivate + * + * @description + * + * Registra se o último `mousedown` do documento ocorreu dentro do picker (calendário). + * + * Ao pressionar o mouse dentro do calendário e soltar fora dele, o navegador dispara o `click` fora do calendário. + * Esse `click` não deve fechar o calendário, por isso o componente deve consultar `consume` antes de fechá-lo. + */ +export class PoPickerMouseDownGuard { + private isMouseDownOnPicker = false; + private removeListener: () => void; + + /** + * @param isInsidePicker Função que indica se o alvo do `mousedown` pertence ao picker. + */ + constructor(private readonly isInsidePicker: (target: EventTarget) => boolean) {} + + listen(renderer: Renderer2): void { + this.destroy(); + this.removeListener = renderer.listen('document', 'mousedown', (event: MouseEvent) => this.register(event?.target)); + } + + register(target: EventTarget): void { + this.isMouseDownOnPicker = !!target && this.isInsidePicker(target); + } + + /** + * Retorna se o último `mousedown` ocorreu no picker e reinicia o estado, valendo apenas para o `click` seguinte. + */ + consume(): boolean { + const isMouseDownOnPicker = this.isMouseDownOnPicker; + this.isMouseDownOnPicker = false; + + return isMouseDownOnPicker; + } + + destroy(): void { + this.removeListener?.(); + this.removeListener = undefined; + this.isMouseDownOnPicker = false; + } +} diff --git a/projects/ui/src/lib/utils/util.spec.ts b/projects/ui/src/lib/utils/util.spec.ts index 4d23d6ceb5..a735e0d718 100644 --- a/projects/ui/src/lib/utils/util.spec.ts +++ b/projects/ui/src/lib/utils/util.spec.ts @@ -15,6 +15,7 @@ import { convertNumberToDecimal, convertToBoolean, convertToInt, + createDate, formatYear, getDensityMode, getFormattedLink, @@ -22,6 +23,7 @@ import { isExternalLink, isKeyCodeEnter, isKeyCodeSpace, + isValidYear, isTypeof, isValidImageBase64, isValidUrl, @@ -343,6 +345,61 @@ describe('Function convertIsoToDate:', () => { date.setFullYear(1); expect(convertIsoToDate(date.toISOString(), false, true).getFullYear()).toBe(1); }); + + it('should set date with year 50 when start and end are false', () => { + expect(convertIsoToDate('0050-05-10', false, false).getFullYear()).toBe(50); + }); +}); + +describe('Function createDate:', () => { + it('should create a date with the given year, month and day', () => { + const date = createDate(2025, 4, 10); + + expect(date.getFullYear()).toBe(2025); + expect(date.getMonth()).toBe(4); + expect(date.getDate()).toBe(10); + }); + + it('should keep year 0 instead of converting it to 1900', () => { + const date = createDate(0, 0, 1); + + expect(date.getFullYear()).toBe(0); + expect(date.getMonth()).toBe(0); + expect(date.getDate()).toBe(1); + }); + + it('should keep year 99 instead of converting it to 1999', () => { + const date = createDate(99, 11, 31); + + expect(date.getFullYear()).toBe(99); + expect(date.getMonth()).toBe(11); + expect(date.getDate()).toBe(31); + }); + + it('should create a date with year 100 without changes', () => { + expect(createDate(100, 0, 1).getFullYear()).toBe(100); + }); +}); + +describe('Function isValidYear:', () => { + it('should return true for years between 0 and 9999', () => { + expect(isValidYear(0)).toBeTrue(); + expect(isValidYear(2025)).toBeTrue(); + expect(isValidYear(9999)).toBeTrue(); + }); + + it('should return false for years out of the range', () => { + expect(isValidYear(-1)).toBeFalse(); + expect(isValidYear(10000)).toBeFalse(); + }); + + it('should return false for values that are not integer numbers', () => { + expect(isValidYear(2025.5)).toBeFalse(); + expect(isValidYear(NaN)).toBeFalse(); + expect(isValidYear(Infinity)).toBeFalse(); + expect(isValidYear(null)).toBeFalse(); + expect(isValidYear(undefined)).toBeFalse(); + }); }); describe('Function callFunction:', () => { diff --git a/projects/ui/src/lib/utils/util.ts b/projects/ui/src/lib/utils/util.ts index dd31552893..92d68edced 100644 --- a/projects/ui/src/lib/utils/util.ts +++ b/projects/ui/src/lib/utils/util.ts @@ -159,11 +159,38 @@ export function convertIsoToDate(value: string, start: boolean, end: boolean) { return date; } else { - return new Date(year, month - 1, day); + return createDate(year, month - 1, day); } } } +/** + * Cria uma data local considerando os anos entre 0 e 99, pois o `new Date` do javascript + * transforma esses anos para 19XX. + * + * @param year Ano + * @param month Mês (0 a 11) + * @param day Dia + */ +export function createDate(year: number, month: number, day: number): Date { + const date = new Date(year, month, day); + + if (year >= 0 && year < 100) { + date.setFullYear(year, month, day); + } + + return date; +} + +/** + * Verifica se o ano é um número inteiro entre 0 e 9999, intervalo suportado pelo formato `yyyy`. + * + * @param year Ano + */ +export function isValidYear(year: number): boolean { + return Number.isInteger(year) && year >= 0 && year <= 9999; +} + export function convertDateToISODate(date: Date) { if (date) { const getMonth = date.getMonth() + 1; @@ -916,6 +943,7 @@ export const PoUtils = { convertImageToBase64, convertToBoolean, convertToInt, + createDate, formatYear, getA11yLevel, getBrowserLanguage,