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 520b41a64f..2148e7cb9a 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 @@ -330,5 +330,98 @@ describe('PoCalendarBaseComponent:', () => { expect((component as any).applySizeBasedOnA11y).toHaveBeenCalled(); }); }); + + describe('p-year-range:', () => { + it('should set yearRange with valid positive value', () => { + component.yearRange = 100; + expect(component.yearRange).toBe(100); + }); + + it('should default to 150 when value is 0', () => { + component.yearRange = 0; + expect(component.yearRange).toBe(150); + }); + + it('should default to 150 when value is negative', () => { + component.yearRange = -10; + expect(component.yearRange).toBe(150); + }); + + it('should default to 150 when value is null', () => { + component.yearRange = null; + expect(component.yearRange).toBe(150); + }); + + it('should default to 150 when value is undefined', () => { + component.yearRange = undefined; + expect(component.yearRange).toBe(150); + }); + }); + + describe('isMonthYear:', () => { + it('should return true when mode is MonthYear', () => { + component['_mode'] = PoCalendarMode.MonthYear; + expect(component.isMonthYear).toBeTrue(); + }); + + it('should return false when mode is Range', () => { + component['_mode'] = PoCalendarMode.Range; + expect(component.isMonthYear).toBeFalse(); + }); + + it('should return false when mode is undefined', () => { + component['_mode'] = undefined; + expect(component.isMonthYear).toBeFalse(); + }); + }); + + describe('isYearOnly:', () => { + it('should return true when mode is Year', () => { + component['_mode'] = PoCalendarMode.Year; + expect(component.isYearOnly).toBeTrue(); + }); + + it('should return false when mode is MonthYear', () => { + component['_mode'] = PoCalendarMode.MonthYear; + expect(component.isYearOnly).toBeFalse(); + }); + + it('should return false when mode is Range', () => { + component['_mode'] = PoCalendarMode.Range; + expect(component.isYearOnly).toBeFalse(); + }); + }); + + describe('p-range-presets:', () => { + it('should accept boolean true', () => { + component.rangePresets = true; + expect(component.rangePresets).toBeTrue(); + }); + + it('should accept string "true"', () => { + component.rangePresets = 'true'; + expect(component.rangePresets).toBeTrue(); + }); + + it('should accept empty string as true', () => { + component.rangePresets = ''; + expect(component.rangePresets).toBeTrue(); + }); + + it('should accept boolean false', () => { + component.rangePresets = false; + expect(component.rangePresets).toBeFalse(); + }); + + it('should accept array of strings', () => { + component.rangePresets = ['today', '7days']; + expect(component.rangePresets).toEqual(['today', '7days']); + }); + + it('should set to false for invalid values', () => { + component.rangePresets = 'invalid' as any; + expect(component.rangePresets).toBeFalse(); + }); + }); }); }); 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 b89d836815..de4b16753a 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 @@ -140,6 +140,7 @@ export class PoCalendarBaseComponent { private _mode: PoCalendarMode; private _size?: string; private _initialSize?: string; + private _yearRange: number = 150; /** * @optional @@ -243,9 +244,34 @@ export class PoCalendarBaseComponent { return this.mode === PoCalendarMode.Range; } + get isMonthYear() { + return this.mode === PoCalendarMode.MonthYear; + } + + get isYearOnly() { + return this.mode === PoCalendarMode.Year; + } + // Propriedade que permite integrar o po-combo no componente de calendar. Implementa o template de header com `PoCombo`. @Input('p-header-template') headerTemplate?: TemplateRef; + /** + * @optional + * + * @description + * + * Define o intervalo de anos exibidos nas variações `MonthYear` e `Year`. + * O valor representa a quantidade de anos anteriores e posteriores ao ano atual. + * + * @default `150` + */ + @Input('p-year-range') set yearRange(value: number) { + this._yearRange = value && value > 0 ? value : 150; + } + get yearRange(): number { + return this._yearRange; + } + /** * @optional * diff --git a/projects/ui/src/lib/components/po-calendar/po-calendar-mode.enum.ts b/projects/ui/src/lib/components/po-calendar/po-calendar-mode.enum.ts index 54516cecd2..aa5283463d 100644 --- a/projects/ui/src/lib/components/po-calendar/po-calendar-mode.enum.ts +++ b/projects/ui/src/lib/components/po-calendar/po-calendar-mode.enum.ts @@ -7,5 +7,11 @@ */ export enum PoCalendarMode { /** Modo de seleção de intervalo (data inicial e final). */ - Range = 'range' + Range = 'range', + + /** Modo de seleção de mês e ano. */ + MonthYear = 'monthYear', + + /** Modo de seleção apenas de ano. */ + Year = 'year' } diff --git a/projects/ui/src/lib/components/po-calendar/po-calendar-month-year/po-calendar-month-year.component.spec.ts b/projects/ui/src/lib/components/po-calendar/po-calendar-month-year/po-calendar-month-year.component.spec.ts new file mode 100644 index 0000000000..23d62de7cd --- /dev/null +++ b/projects/ui/src/lib/components/po-calendar/po-calendar-month-year/po-calendar-month-year.component.spec.ts @@ -0,0 +1,1075 @@ +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA, SimpleChange } from '@angular/core'; + +import { configureTestSuite } from './../../../util-test/util-expect.spec'; + +import { PoCalendarMonthYearComponent } from './po-calendar-month-year.component'; +import { PoCalendarLangService } from '../services/po-calendar.lang.service'; + +describe('PoCalendarMonthYearComponent:', () => { + let component: PoCalendarMonthYearComponent; + let fixture: ComponentFixture; + + configureTestSuite(() => { + TestBed.configureTestingModule({ + declarations: [PoCalendarMonthYearComponent], + providers: [PoCalendarLangService], + schemas: [NO_ERRORS_SCHEMA] + }); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(PoCalendarMonthYearComponent); + component = fixture.componentInstance; + spyOn(component['cdr'], 'markForCheck'); + spyOn(component['cdr'], 'detectChanges'); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + }); + + describe('Properties:', () => { + it('isMonthYearMode: should return true when mode is monthYear', () => { + component.mode = 'monthYear'; + expect(component.isMonthYearMode).toBeTruthy(); + }); + + it('isMonthYearMode: should return false when mode is year', () => { + component.mode = 'year'; + expect(component.isMonthYearMode).toBeFalsy(); + }); + + it('isYearMode: should return true when mode is year', () => { + component.mode = 'year'; + expect(component.isYearMode).toBeTruthy(); + }); + + it('isYearMode: should return false when mode is monthYear', () => { + component.mode = 'monthYear'; + expect(component.isYearMode).toBeFalsy(); + }); + + it('locale: should call setupMonths when locale is set', () => { + spyOn(component as any, 'setupMonths'); + component.locale = 'pt'; + expect(component['setupMonths']).toHaveBeenCalled(); + }); + + it('locale: getter should return the current locale value', () => { + component.locale = 'en'; + expect(component.locale).toBe('en'); + }); + }); + + describe('Methods:', () => { + beforeEach(() => { + component.mode = 'monthYear'; + component.ngOnInit(); + }); + + it('ngOnInit: should call setupMonths, setupYears and setInitialFocus', () => { + spyOn(component as any, 'setupMonths'); + spyOn(component as any, 'setupYears'); + spyOn(component as any, 'setInitialFocus'); + + component.ngOnInit(); + + expect(component['setupMonths']).toHaveBeenCalled(); + expect(component['setupYears']).toHaveBeenCalled(); + expect(component['setInitialFocus']).toHaveBeenCalled(); + }); + + it('ngOnChanges: should call setupMonths when locale changes', () => { + spyOn(component as any, 'setupMonths'); + component.ngOnChanges({ + locale: new SimpleChange('en', 'pt', false) + }); + expect(component['setupMonths']).toHaveBeenCalled(); + }); + + it('ngOnChanges: should not call setupMonths on first change', () => { + spyOn(component as any, 'setupMonths'); + component.ngOnChanges({ + locale: new SimpleChange(undefined, 'pt', true) + }); + expect(component['setupMonths']).not.toHaveBeenCalled(); + }); + + it('ngOnChanges: should call setupYears when yearRange changes', () => { + spyOn(component as any, 'setupYears'); + component.ngOnChanges({ + yearRange: new SimpleChange(100, 150, false) + }); + expect(component['setupYears']).toHaveBeenCalled(); + }); + + it('ngOnChanges: should call setupYears when minDate changes', () => { + spyOn(component as any, 'setupYears'); + component.ngOnChanges({ + minDate: new SimpleChange(null, new Date(), false) + }); + expect(component['setupYears']).toHaveBeenCalled(); + }); + + it('ngOnChanges: should call setupYears when maxDate changes', () => { + spyOn(component as any, 'setupYears'); + component.ngOnChanges({ + maxDate: new SimpleChange(null, new Date(), false) + }); + expect(component['setupYears']).toHaveBeenCalled(); + }); + + it('ngOnChanges: should call setInitialFocus when selectedMonth changes', () => { + spyOn(component as any, 'setInitialFocus'); + component.ngOnChanges({ + selectedMonth: new SimpleChange(null, 5, false) + }); + expect(component['setInitialFocus']).toHaveBeenCalled(); + }); + + it('ngOnChanges: should call setInitialFocus when selectedYear changes', () => { + spyOn(component as any, 'setInitialFocus'); + component.ngOnChanges({ + selectedYear: new SimpleChange(null, 2025, false) + }); + expect(component['setInitialFocus']).toHaveBeenCalled(); + }); + + describe('setupMonths:', () => { + it('should populate displayMonths with 12 months', () => { + component.locale = 'pt'; + expect(component.displayMonths.length).toBe(12); + }); + + it('should populate displayMonths with correct month names in Portuguese', () => { + component.locale = 'pt'; + expect(component.displayMonths[0]).toBe('Janeiro'); + expect(component.displayMonths[11]).toBe('Dezembro'); + }); + + it('should populate displayMonths with correct month names in English', () => { + component.locale = 'en'; + expect(component.displayMonths[0]).toBe('January'); + expect(component.displayMonths[11]).toBe('December'); + }); + + it('should populate displayMonths with correct month names in Spanish', () => { + component.locale = 'es'; + expect(component.displayMonths[0]).toBe('Enero'); + expect(component.displayMonths[11]).toBe('Diciembre'); + }); + + it('should capitalize first letter of month names', () => { + component.locale = 'pt'; + component.displayMonths.forEach(month => { + expect(month.charAt(0)).toBe(month.charAt(0).toUpperCase()); + }); + }); + }); + + describe('setupYears:', () => { + it('should generate years based on yearRange', () => { + component.yearRange = 150; + component['setupYears'](); + const currentYear = new Date().getFullYear(); + expect(component.displayYears).toContain(currentYear); + expect(component.displayYears[0]).toBe(currentYear - 150); + expect(component.displayYears[component.displayYears.length - 1]).toBe(currentYear + 150); + }); + + it('should generate total of (yearRange * 2 + 1) years', () => { + component.yearRange = 10; + component['setupYears'](); + expect(component.displayYears.length).toBe(21); + }); + + it('should respect minDate when generating years', () => { + const currentYear = new Date().getFullYear(); + component.minDate = new Date(currentYear - 10, 0, 1); + component.yearRange = 150; + component['setupYears'](); + expect(component.displayYears[0]).toBe(currentYear - 10); + }); + + it('should respect maxDate when generating years', () => { + const currentYear = new Date().getFullYear(); + component.maxDate = new Date(currentYear + 5, 11, 31); + component.yearRange = 150; + component['setupYears'](); + expect(component.displayYears[component.displayYears.length - 1]).toBe(currentYear + 5); + }); + + it('should respect both minDate and maxDate', () => { + component.minDate = new Date(2020, 0, 1); + component.maxDate = new Date(2030, 11, 31); + component.yearRange = 150; + component['setupYears'](); + expect(component.displayYears[0]).toBe(2020); + expect(component.displayYears[component.displayYears.length - 1]).toBe(2030); + expect(component.displayYears.length).toBe(11); + }); + }); + + describe('setInitialFocus:', () => { + it('should set focusedMonthIndex to selectedMonth when valid', () => { + component.selectedMonth = 5; + component['setInitialFocus'](); + expect(component.focusedMonthIndex).toBe(5); + }); + + it('should set focusedMonthIndex to 0 when selectedMonth is null', () => { + component.selectedMonth = null; + component['setInitialFocus'](); + expect(component.focusedMonthIndex).toBe(0); + }); + + it('should set focusedMonthIndex to 0 when selectedMonth is undefined', () => { + component.selectedMonth = undefined; + component['setInitialFocus'](); + expect(component.focusedMonthIndex).toBe(0); + }); + + it('should set focusedYearIndex to index of selectedYear', () => { + const currentYear = new Date().getFullYear(); + component.selectedYear = currentYear; + component['setInitialFocus'](); + const expectedIndex = component.displayYears.indexOf(currentYear); + expect(component.focusedYearIndex).toBe(expectedIndex); + }); + + it('should set focusedYearIndex to current year index when selectedYear is null', () => { + const currentYear = new Date().getFullYear(); + component.selectedYear = null; + component['setInitialFocus'](); + const expectedIndex = component.displayYears.indexOf(currentYear); + expect(component.focusedYearIndex).toBe(expectedIndex); + }); + + it('should set focusedYearIndex to 0 when selectedYear is not in displayYears and currentYear is also not in range', () => { + component.minDate = new Date(2060, 0, 1); + component.maxDate = new Date(2065, 11, 31); + component['setupYears'](); + component.selectedYear = 2000; + component['setInitialFocus'](); + expect(component.focusedYearIndex).toBe(0); + }); + }); + + describe('onSelectMonth:', () => { + it('should set selectedMonth and focusedMonthIndex', () => { + component.onSelectMonth(3); + expect(component.selectedMonth).toBe(3); + expect(component.focusedMonthIndex).toBe(3); + }); + + it('should emit selection when both month and year are selected in monthYear mode', () => { + component.mode = 'monthYear'; + component.selectedYear = 2025; + spyOn(component.select, 'emit'); + + component.onSelectMonth(5); + + expect(component.select.emit).toHaveBeenCalledWith({ month: 5, year: 2025 }); + }); + + it('should not emit selection when year is not selected in monthYear mode', () => { + component.mode = 'monthYear'; + component.selectedYear = null; + spyOn(component.select, 'emit'); + + component.onSelectMonth(5); + + expect(component.select.emit).not.toHaveBeenCalled(); + }); + + it('should call markForCheck after selection', () => { + (component['cdr'].markForCheck as jasmine.Spy).calls.reset(); + component.onSelectMonth(5); + expect(component['cdr'].markForCheck).toHaveBeenCalled(); + }); + }); + + describe('onSelectYear:', () => { + it('should set selectedYear and focusedYearIndex', () => { + const currentYear = new Date().getFullYear(); + component.onSelectYear(currentYear); + expect(component.selectedYear).toBe(currentYear); + expect(component.focusedYearIndex).toBe(component.displayYears.indexOf(currentYear)); + }); + + it('should emit selection in year mode', () => { + component.mode = 'year'; + spyOn(component.select, 'emit'); + + component.onSelectYear(2025); + + expect(component.select.emit).toHaveBeenCalledWith({ year: 2025 }); + }); + + it('should emit selection with month and year in monthYear mode when month is selected', () => { + component.mode = 'monthYear'; + component.selectedMonth = 3; + spyOn(component.select, 'emit'); + + component.onSelectYear(2025); + + expect(component.select.emit).toHaveBeenCalledWith({ month: 3, year: 2025 }); + }); + + it('should not emit selection in monthYear mode when month is not selected', () => { + component.mode = 'monthYear'; + component.selectedMonth = null; + spyOn(component.select, 'emit'); + + component.onSelectYear(2025); + + expect(component.select.emit).not.toHaveBeenCalled(); + }); + + it('should call markForCheck after selection', () => { + (component['cdr'].markForCheck as jasmine.Spy).calls.reset(); + component.onSelectYear(2025); + expect(component['cdr'].markForCheck).toHaveBeenCalled(); + }); + }); + + describe('isMonthSelected:', () => { + it('should return true when index matches selectedMonth', () => { + component.selectedMonth = 5; + expect(component.isMonthSelected(5)).toBeTruthy(); + }); + + it('should return false when index does not match selectedMonth', () => { + component.selectedMonth = 5; + expect(component.isMonthSelected(3)).toBeFalsy(); + }); + + it('should return false when selectedMonth is null', () => { + component.selectedMonth = null; + expect(component.isMonthSelected(0)).toBeFalsy(); + }); + }); + + describe('isYearSelected:', () => { + it('should return true when year matches selectedYear', () => { + component.selectedYear = 2025; + expect(component.isYearSelected(2025)).toBeTruthy(); + }); + + it('should return false when year does not match selectedYear', () => { + component.selectedYear = 2025; + expect(component.isYearSelected(2020)).toBeFalsy(); + }); + + it('should return false when selectedYear is null', () => { + component.selectedYear = null; + expect(component.isYearSelected(2025)).toBeFalsy(); + }); + }); + + describe('isMonthDisabled:', () => { + it('should return false when selectedYear is not set', () => { + component.selectedYear = null; + expect(component.isMonthDisabled(0)).toBeFalsy(); + }); + + it('should return true when month is before minDate', () => { + component.selectedYear = 2025; + component.minDate = new Date(2025, 5, 1); + expect(component.isMonthDisabled(3)).toBeTruthy(); + }); + + it('should return false when month is equal to minDate month', () => { + component.selectedYear = 2025; + component.minDate = new Date(2025, 5, 1); + expect(component.isMonthDisabled(5)).toBeFalsy(); + }); + + it('should return true when month is after maxDate', () => { + component.selectedYear = 2025; + component.maxDate = new Date(2025, 5, 30); + expect(component.isMonthDisabled(8)).toBeTruthy(); + }); + + it('should return false when month is equal to maxDate month', () => { + component.selectedYear = 2025; + component.maxDate = new Date(2025, 5, 30); + expect(component.isMonthDisabled(5)).toBeFalsy(); + }); + + it('should return true when selectedYear is before minDate year', () => { + component.selectedYear = 2020; + component.minDate = new Date(2025, 0, 1); + expect(component.isMonthDisabled(0)).toBeTruthy(); + }); + + it('should return true when selectedYear is after maxDate year', () => { + component.selectedYear = 2030; + component.maxDate = new Date(2025, 11, 31); + expect(component.isMonthDisabled(0)).toBeTruthy(); + }); + + it('should return false when no min/max constraints', () => { + component.selectedYear = 2025; + component.minDate = undefined; + component.maxDate = undefined; + expect(component.isMonthDisabled(6)).toBeFalsy(); + }); + }); + + describe('isYearDisabled:', () => { + it('should return true when year is before minDate year', () => { + component.minDate = new Date(2020, 0, 1); + expect(component.isYearDisabled(2019)).toBeTruthy(); + }); + + it('should return false when year is equal to minDate year', () => { + component.minDate = new Date(2020, 0, 1); + expect(component.isYearDisabled(2020)).toBeFalsy(); + }); + + it('should return true when year is after maxDate year', () => { + component.maxDate = new Date(2025, 11, 31); + expect(component.isYearDisabled(2026)).toBeTruthy(); + }); + + it('should return false when year is equal to maxDate year', () => { + component.maxDate = new Date(2025, 11, 31); + expect(component.isYearDisabled(2025)).toBeFalsy(); + }); + + it('should return false when no min/max constraints', () => { + component.minDate = undefined; + component.maxDate = undefined; + expect(component.isYearDisabled(2025)).toBeFalsy(); + }); + }); + + describe('getMonthButtonKind:', () => { + it('should return secondary when month is selected', () => { + component.selectedMonth = 3; + expect(component.getMonthButtonKind(3)).toBe('secondary'); + }); + + it('should return tertiary when month is not selected', () => { + component.selectedMonth = 3; + expect(component.getMonthButtonKind(5)).toBe('tertiary'); + }); + }); + + describe('getYearButtonKind:', () => { + it('should return secondary when year is selected', () => { + component.selectedYear = 2025; + expect(component.getYearButtonKind(2025)).toBe('secondary'); + }); + + it('should return tertiary when year is not selected', () => { + component.selectedYear = 2025; + expect(component.getYearButtonKind(2020)).toBe('tertiary'); + }); + }); + + describe('getMonthTabIndex:', () => { + it('should return 0 for focused month index', () => { + component.focusedMonthIndex = 3; + expect(component.getMonthTabIndex(3)).toBe(0); + }); + + it('should return -1 for non-focused month index', () => { + component.focusedMonthIndex = 3; + expect(component.getMonthTabIndex(5)).toBe(-1); + }); + }); + + describe('getYearTabIndex:', () => { + it('should return 0 for focused year index', () => { + component.focusedYearIndex = 5; + expect(component.getYearTabIndex(5)).toBe(0); + }); + + it('should return -1 for non-focused year index', () => { + component.focusedYearIndex = 5; + expect(component.getYearTabIndex(3)).toBe(-1); + }); + }); + + describe('Keyboard Navigation:', () => { + describe('onMonthKeydown:', () => { + it('should navigate down with ArrowDown', () => { + spyOn(component as any, 'navigateMonth'); + const event = new KeyboardEvent('keydown', { key: 'ArrowDown' }); + + component.onMonthKeydown(event, 3); + + expect(component['navigateMonth']).toHaveBeenCalledWith(3, 1); + }); + + it('should navigate up with ArrowUp', () => { + spyOn(component as any, 'navigateMonth'); + const event = new KeyboardEvent('keydown', { key: 'ArrowUp' }); + + component.onMonthKeydown(event, 3); + + expect(component['navigateMonth']).toHaveBeenCalledWith(3, -1); + }); + + it('should select month on Enter', () => { + spyOn(component, 'onSelectMonth'); + const event = new KeyboardEvent('keydown', { key: 'Enter' }); + + component.onMonthKeydown(event, 3); + + expect(component.onSelectMonth).toHaveBeenCalledWith(3); + }); + + it('should select month on Space', () => { + spyOn(component, 'onSelectMonth'); + const event = new KeyboardEvent('keydown', { key: ' ' }); + + component.onMonthKeydown(event, 3); + + expect(component.onSelectMonth).toHaveBeenCalledWith(3); + }); + + it('should not select disabled month on Enter', () => { + spyOn(component, 'isMonthDisabled').and.returnValue(true); + spyOn(component, 'onSelectMonth'); + const event = new KeyboardEvent('keydown', { key: 'Enter' }); + + component.onMonthKeydown(event, 3); + + expect(component.onSelectMonth).not.toHaveBeenCalled(); + }); + + it('should not select disabled month on Space', () => { + spyOn(component, 'isMonthDisabled').and.returnValue(true); + spyOn(component, 'onSelectMonth'); + const event = new KeyboardEvent('keydown', { key: ' ' }); + + component.onMonthKeydown(event, 3); + + expect(component.onSelectMonth).not.toHaveBeenCalled(); + }); + + it('should emit close on Shift+Tab', () => { + spyOn(component.close, 'emit'); + const event = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true }); + + component.onMonthKeydown(event, 0); + + expect(component.close.emit).toHaveBeenCalled(); + }); + + it('should not emit close on Tab without shift', () => { + spyOn(component.close, 'emit'); + const event = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: false }); + + component.onMonthKeydown(event, 0); + + expect(component.close.emit).not.toHaveBeenCalled(); + }); + }); + + describe('onYearKeydown:', () => { + it('should navigate down with ArrowDown', () => { + spyOn(component as any, 'navigateYear'); + const event = new KeyboardEvent('keydown', { key: 'ArrowDown' }); + + component.onYearKeydown(event, 5); + + expect(component['navigateYear']).toHaveBeenCalledWith(5, 1); + }); + + it('should navigate up with ArrowUp', () => { + spyOn(component as any, 'navigateYear'); + const event = new KeyboardEvent('keydown', { key: 'ArrowUp' }); + + component.onYearKeydown(event, 5); + + expect(component['navigateYear']).toHaveBeenCalledWith(5, -1); + }); + + it('should select year on Enter', () => { + const currentYear = new Date().getFullYear(); + const yearIndex = component.displayYears.indexOf(currentYear); + spyOn(component, 'onSelectYear'); + const event = new KeyboardEvent('keydown', { key: 'Enter' }); + + component.onYearKeydown(event, yearIndex); + + expect(component.onSelectYear).toHaveBeenCalledWith(currentYear); + }); + + it('should select year on Space', () => { + const currentYear = new Date().getFullYear(); + const yearIndex = component.displayYears.indexOf(currentYear); + spyOn(component, 'onSelectYear'); + const event = new KeyboardEvent('keydown', { key: ' ' }); + + component.onYearKeydown(event, yearIndex); + + expect(component.onSelectYear).toHaveBeenCalledWith(currentYear); + }); + + it('should not select disabled year on Enter', () => { + spyOn(component, 'isYearDisabled').and.returnValue(true); + spyOn(component, 'onSelectYear'); + const event = new KeyboardEvent('keydown', { key: 'Enter' }); + + component.onYearKeydown(event, 5); + + expect(component.onSelectYear).not.toHaveBeenCalled(); + }); + + it('should emit close on Tab without shift in year-only mode', () => { + component.mode = 'year'; + spyOn(component.close, 'emit'); + const event = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: false }); + + component.onYearKeydown(event, 5); + + expect(component.close.emit).toHaveBeenCalled(); + }); + + it('should emit close on Shift+Tab in year-only mode', () => { + component.mode = 'year'; + spyOn(component.close, 'emit'); + const event = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true }); + + component.onYearKeydown(event, 5); + + expect(component.close.emit).toHaveBeenCalled(); + }); + + it('should not emit close on Tab in monthYear mode', () => { + component.mode = 'monthYear'; + spyOn(component.close, 'emit'); + const event = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: false }); + + component.onYearKeydown(event, 5); + + expect(component.close.emit).not.toHaveBeenCalled(); + }); + + it('should not emit close on Shift+Tab in monthYear mode', () => { + component.mode = 'monthYear'; + spyOn(component.close, 'emit'); + const event = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true }); + + component.onYearKeydown(event, 5); + + expect(component.close.emit).not.toHaveBeenCalled(); + }); + }); + + describe('onYearListKeydown:', () => { + it('should emit close on Tab without shift', () => { + spyOn(component.close, 'emit'); + const event = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: false }); + + component.onYearListKeydown(event); + + expect(component.close.emit).toHaveBeenCalled(); + }); + + it('should not emit close on Shift+Tab', () => { + spyOn(component.close, 'emit'); + const event = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true }); + + component.onYearListKeydown(event); + + expect(component.close.emit).not.toHaveBeenCalled(); + }); + + it('should not emit close on non-Tab keys', () => { + spyOn(component.close, 'emit'); + const event = new KeyboardEvent('keydown', { key: 'Enter' }); + + component.onYearListKeydown(event); + + expect(component.close.emit).not.toHaveBeenCalled(); + }); + }); + }); + + describe('navigateMonth:', () => { + beforeEach(() => { + spyOn(component as any, 'focusMonthButton'); + }); + + it('should update focusedMonthIndex when navigating down', () => { + component.focusedMonthIndex = 0; + component['navigateMonth'](0, 1); + expect(component.focusedMonthIndex).toBe(1); + }); + + it('should update focusedMonthIndex when navigating up', () => { + component.focusedMonthIndex = 5; + component['navigateMonth'](5, -1); + expect(component.focusedMonthIndex).toBe(4); + }); + + it('should not navigate below 0', () => { + component.focusedMonthIndex = 0; + component['navigateMonth'](0, -1); + expect(component.focusedMonthIndex).toBe(0); + }); + + it('should not navigate beyond 11', () => { + component.focusedMonthIndex = 11; + component['navigateMonth'](11, 1); + expect(component.focusedMonthIndex).toBe(11); + }); + + it('should skip disabled months when navigating', () => { + component.selectedYear = 2025; + component.minDate = new Date(2025, 2, 1); + component.focusedMonthIndex = 3; + component['navigateMonth'](3, -1); + expect(component.focusedMonthIndex).toBe(2); + }); + + it('should call focusMonthButton with new index', () => { + component['navigateMonth'](0, 1); + expect(component['focusMonthButton']).toHaveBeenCalledWith(1); + }); + }); + + describe('navigateYear:', () => { + beforeEach(() => { + spyOn(component as any, 'focusYearButton'); + }); + + it('should update focusedYearIndex when navigating down', () => { + component.focusedYearIndex = 5; + component['navigateYear'](5, 1); + expect(component.focusedYearIndex).toBe(6); + }); + + it('should update focusedYearIndex when navigating up', () => { + component.focusedYearIndex = 5; + component['navigateYear'](5, -1); + expect(component.focusedYearIndex).toBe(4); + }); + + it('should not navigate beyond array bounds (end)', () => { + const lastIndex = component.displayYears.length - 1; + component.focusedYearIndex = lastIndex; + component['navigateYear'](lastIndex, 1); + expect(component.focusedYearIndex).toBe(lastIndex); + }); + + it('should not navigate below 0', () => { + component.focusedYearIndex = 0; + component['navigateYear'](0, -1); + expect(component.focusedYearIndex).toBe(0); + }); + + it('should call focusYearButton with new index', () => { + component['navigateYear'](5, 1); + expect(component['focusYearButton']).toHaveBeenCalledWith(6); + }); + + it('should skip disabled years when navigating', () => { + component.minDate = new Date(2020, 0, 1); + component['setupYears'](); + const minIndex = component.displayYears.indexOf(2020); + component.focusedYearIndex = minIndex + 1; + component['navigateYear'](minIndex + 1, -1); + expect(component.focusedYearIndex).toBe(minIndex); + }); + }); + + describe('trackByMonth:', () => { + it('should return index', () => { + expect(component.trackByMonth(5)).toBe(5); + }); + }); + + describe('trackByYear:', () => { + it('should return year', () => { + expect(component.trackByYear(0, 2025)).toBe(2025); + }); + }); + + describe('focusFirstMonth:', () => { + it('should call focusMonthButton with focusedMonthIndex', fakeAsync(() => { + spyOn(component as any, 'focusMonthButton'); + component.focusedMonthIndex = 3; + component.focusFirstMonth(); + tick(100); + expect(component['focusMonthButton']).toHaveBeenCalledWith(3); + })); + }); + + describe('focusFirstYear:', () => { + it('should call focusYearButton with focusedYearIndex', fakeAsync(() => { + spyOn(component as any, 'focusYearButton'); + component.focusedYearIndex = 5; + component.focusFirstYear(); + tick(100); + expect(component['focusYearButton']).toHaveBeenCalledWith(5); + })); + }); + + describe('scrollToSelectedYear:', () => { + it('should call scrollIntoView on selected element', fakeAsync(() => { + const mockSelectedEl = { scrollIntoView: jasmine.createSpy('scrollIntoView') }; + const mockContainer = { + querySelector: jasmine.createSpy('querySelector').and.callFake((selector: string) => { + if (selector === '.po-calendar-month-year-year-item--selected') { + return mockSelectedEl; + } + return null; + }), + querySelectorAll: jasmine.createSpy('querySelectorAll').and.returnValue([]) + }; + spyOn(component['elementRef'].nativeElement, 'querySelector').and.returnValue(mockContainer); + + component.scrollToSelectedYear(); + tick(200); + + expect(mockSelectedEl.scrollIntoView).toHaveBeenCalledWith({ block: 'center', behavior: 'smooth' }); + })); + + it('should scroll to current year when no selected element', fakeAsync(() => { + const currentYear = new Date().getFullYear(); + const currentYearIndex = component.displayYears.indexOf(currentYear); + const mockCurrentYearEl = { scrollIntoView: jasmine.createSpy('scrollIntoView') }; + const mockItems = []; + for (let i = 0; i <= currentYearIndex; i++) { + mockItems.push(i === currentYearIndex ? mockCurrentYearEl : {}); + } + + const mockContainer = { + querySelector: jasmine.createSpy('querySelector').and.returnValue(null), + querySelectorAll: jasmine.createSpy('querySelectorAll').and.returnValue(mockItems) + }; + spyOn(component['elementRef'].nativeElement, 'querySelector').and.returnValue(mockContainer); + + component.scrollToSelectedYear(); + tick(200); + + expect(mockCurrentYearEl.scrollIntoView).toHaveBeenCalledWith({ block: 'center', behavior: 'smooth' }); + })); + + it('should do nothing when container is null', fakeAsync(() => { + spyOn(component['elementRef'].nativeElement, 'querySelector').and.returnValue(null); + + component.scrollToSelectedYear(); + tick(200); + })); + + it('should do nothing when no selected element and currentYear not in display', fakeAsync(() => { + component.minDate = new Date(2060, 0, 1); + component.maxDate = new Date(2065, 11, 31); + component['setupYears'](); + + const mockContainer = { + querySelector: jasmine.createSpy('querySelector').and.returnValue(null), + querySelectorAll: jasmine.createSpy('querySelectorAll').and.returnValue([]) + }; + spyOn(component['elementRef'].nativeElement, 'querySelector').and.returnValue(mockContainer); + + component.scrollToSelectedYear(); + tick(200); + })); + }); + + describe('focusMonthButton:', () => { + it('should call focus on the button at the given index', fakeAsync(() => { + const mockBtn = { focus: jasmine.createSpy('focus') }; + const mockPoButton = { querySelector: jasmine.createSpy('querySelector').and.returnValue(mockBtn) }; + const mockButtons = [mockPoButton]; + + spyOn(component['elementRef'].nativeElement, 'querySelectorAll').and.returnValue(mockButtons); + + component['focusMonthButton'](0); + tick(10); + + expect(mockBtn.focus).toHaveBeenCalled(); + })); + + it('should handle case when button element not found', fakeAsync(() => { + spyOn(component['elementRef'].nativeElement, 'querySelectorAll').and.returnValue([]); + + component['focusMonthButton'](5); + tick(10); + })); + + it('should handle case when inner button is null', fakeAsync(() => { + const mockPoButton = { querySelector: jasmine.createSpy('querySelector').and.returnValue(null) }; + spyOn(component['elementRef'].nativeElement, 'querySelectorAll').and.returnValue([mockPoButton]); + + component['focusMonthButton'](0); + tick(10); + })); + }); + + describe('focusYearButton:', () => { + it('should call focus and scrollIntoView on the year button', fakeAsync(() => { + const mockBtn = { + focus: jasmine.createSpy('focus'), + scrollIntoView: jasmine.createSpy('scrollIntoView') + }; + const mockPoButton = { querySelector: jasmine.createSpy('querySelector').and.returnValue(mockBtn) }; + const mockContainer = { + querySelectorAll: jasmine.createSpy('querySelectorAll').and.returnValue([mockPoButton]) + }; + + spyOn(component['elementRef'].nativeElement, 'querySelector').and.returnValue(mockContainer); + + component['focusYearButton'](0); + tick(10); + + expect(mockBtn.focus).toHaveBeenCalled(); + expect(mockBtn.scrollIntoView).toHaveBeenCalledWith({ block: 'nearest', behavior: 'smooth' }); + })); + + it('should handle case when container is null', fakeAsync(() => { + spyOn(component['elementRef'].nativeElement, 'querySelector').and.returnValue(null); + + component['focusYearButton'](0); + tick(10); + })); + + it('should handle case when buttons array is empty', fakeAsync(() => { + const mockContainer = { + querySelectorAll: jasmine.createSpy('querySelectorAll').and.returnValue([]) + }; + + spyOn(component['elementRef'].nativeElement, 'querySelector').and.returnValue(mockContainer); + + component['focusYearButton'](5); + tick(10); + })); + + it('should handle case when inner button is null', fakeAsync(() => { + const mockPoButton = { querySelector: jasmine.createSpy('querySelector').and.returnValue(null) }; + const mockContainer = { + querySelectorAll: jasmine.createSpy('querySelectorAll').and.returnValue([mockPoButton]) + }; + + spyOn(component['elementRef'].nativeElement, 'querySelector').and.returnValue(mockContainer); + + component['focusYearButton'](0); + tick(10); + })); + }); + + describe('setupMonths:', () => { + it('should populate displayMonths without setting language when locale is not set', () => { + component['_locale'] = undefined; + component['setupMonths'](); + expect(component.displayMonths.length).toBe(12); + }); + }); + + describe('setupYears with string minDate/maxDate:', () => { + it('should handle string minDate by converting to Date', () => { + const currentYear = new Date().getFullYear(); + component.minDate = new Date(currentYear - 5, 0, 1); + component['setupYears'](); + expect(component.displayYears[0]).toBe(currentYear - 5); + }); + + it('should handle string maxDate by converting to Date', () => { + const currentYear = new Date().getFullYear(); + component.maxDate = new Date(currentYear + 3, 11, 31); + component['setupYears'](); + expect(component.displayYears[component.displayYears.length - 1]).toBe(currentYear + 3); + }); + + it('should handle non-Date minDate by converting via new Date()', () => { + const currentYear = new Date().getFullYear(); + component.minDate = `${currentYear - 3}-01-01` as any; + component['setupYears'](); + expect(component.displayYears[0]).toBe(currentYear - 3); + }); + + it('should handle non-Date maxDate by converting via new Date()', () => { + const currentYear = new Date().getFullYear(); + component.maxDate = `${currentYear + 2}-12-31` as any; + component['setupYears'](); + expect(component.displayYears[component.displayYears.length - 1]).toBe(currentYear + 2); + }); + }); + + describe('emitSelection edge cases:', () => { + it('should not emit in year mode when selectedYear is null', () => { + component.mode = 'year'; + component.selectedYear = null; + spyOn(component.select, 'emit'); + component['emitSelection'](); + expect(component.select.emit).not.toHaveBeenCalled(); + }); + + it('should not emit in year mode when selectedYear is undefined', () => { + component.mode = 'year'; + component.selectedYear = undefined; + spyOn(component.select, 'emit'); + component['emitSelection'](); + expect(component.select.emit).not.toHaveBeenCalled(); + }); + }); + + describe('navigateMonth edge cases:', () => { + it('should skip all disabled months and not navigate if all are disabled', () => { + spyOn(component, 'isMonthDisabled').and.returnValue(true); + spyOn(component as any, 'focusMonthButton'); + component.focusedMonthIndex = 5; + + component['navigateMonth'](5, 1); + + expect(component['focusMonthButton']).not.toHaveBeenCalled(); + }); + }); + + describe('navigateYear edge cases:', () => { + it('should skip all disabled years and not navigate if all are disabled', () => { + spyOn(component, 'isYearDisabled').and.returnValue(true); + spyOn(component as any, 'focusYearButton'); + component.focusedYearIndex = 5; + + component['navigateYear'](5, 1); + + expect(component['focusYearButton']).not.toHaveBeenCalled(); + }); + }); + + describe('onYearKeydown edge cases:', () => { + it('should not select year on Space when year is disabled', () => { + spyOn(component, 'isYearDisabled').and.returnValue(true); + spyOn(component, 'onSelectYear'); + const event = new KeyboardEvent('keydown', { key: ' ' }); + + component.onYearKeydown(event, 5); + + expect(component.onSelectYear).not.toHaveBeenCalled(); + }); + }); + + describe('setInitialFocus edge cases:', () => { + it('should handle selectedMonth as negative number', () => { + component.selectedMonth = -1; + component['setInitialFocus'](); + expect(component.focusedMonthIndex).toBe(0); + }); + + it('should handle selectedMonth as 12 (out of range)', () => { + component.selectedMonth = 12; + component['setInitialFocus'](); + expect(component.focusedMonthIndex).toBe(0); + }); + + it('should handle selectedYear not in displayYears but currentYear is', () => { + const currentYear = new Date().getFullYear(); + component.selectedYear = 1800; + component['setInitialFocus'](); + const expectedIndex = component.displayYears.indexOf(currentYear); + expect(component.focusedYearIndex).toBe(expectedIndex); + }); + }); + }); +}); diff --git a/projects/ui/src/lib/components/po-calendar/po-calendar-month-year/po-calendar-month-year.component.ts b/projects/ui/src/lib/components/po-calendar/po-calendar-month-year/po-calendar-month-year.component.ts new file mode 100644 index 0000000000..7c05a774f6 --- /dev/null +++ b/projects/ui/src/lib/components/po-calendar/po-calendar-month-year/po-calendar-month-year.component.ts @@ -0,0 +1,446 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + HostListener, + inject, + Input, + OnChanges, + OnInit, + Output, + QueryList, + SimpleChanges, + ViewChildren +} from '@angular/core'; + +import { PoCalendarLangService } from '../services/po-calendar.lang.service'; +import { PoButtonComponent } from '../../po-button/po-button.component'; + +@Component({ + selector: 'po-calendar-month-year', + template: ` +
+ @if (isMonthYearMode) { +
+ @for (month of displayMonths; track trackByMonth($index); let i = $index) { +
+ + +
+ } +
+ } + +
+ @for (year of displayYears; track trackByYear($index, year); let i = $index) { +
+ + +
+ } +
+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false +}) +export class PoCalendarMonthYearComponent implements OnInit, OnChanges { + private readonly poCalendarLangService = inject(PoCalendarLangService); + private readonly cdr = inject(ChangeDetectorRef); + private readonly elementRef = inject(ElementRef); + + @Input('p-locale') set locale(value: string) { + this._locale = value; + this.setupMonths(); + } + get locale(): string { + return this._locale; + } + + @Input('p-selected-month') selectedMonth: number = null; + @Input('p-selected-year') selectedYear: number = null; + @Input('p-min-date') minDate: Date; + @Input('p-max-date') maxDate: Date; + @Input('p-year-range') yearRange: number = 150; + @Input('p-mode') mode: 'monthYear' | 'year' = 'monthYear'; + @Input('p-size') size: string; + + @Output('p-select') select = new EventEmitter<{ month?: number; year: number }>(); + @Output('p-close') close = new EventEmitter(); + + @ViewChildren('monthButton') monthButtons: QueryList; + @ViewChildren('yearButton') yearButtons: QueryList; + + displayMonths: Array = []; + displayYears: Array = []; + + focusedMonthIndex: number = 0; + focusedYearIndex: number = 0; + + private _locale: string; + private currentYear: number = new Date().getFullYear(); + + get isMonthYearMode(): boolean { + return this.mode === 'monthYear'; + } + + get isYearMode(): boolean { + return this.mode === 'year'; + } + + ngOnInit(): void { + this.setupMonths(); + this.setupYears(); + this.setInitialFocus(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.locale && !changes.locale.firstChange) { + this.setupMonths(); + } + if (changes.yearRange || changes.minDate || changes.maxDate) { + this.setupYears(); + } + if (changes.selectedMonth || changes.selectedYear) { + this.setInitialFocus(); + } + } + + private setupMonths(): void { + if (this._locale) { + this.poCalendarLangService.setLanguage(this._locale); + } + this.displayMonths = this.poCalendarLangService + .getMonthsArray() + .map((month: string) => month.charAt(0).toUpperCase() + month.slice(1)); + this.cdr.markForCheck(); + } + + private setupYears(): void { + const baseYear = this.currentYear; + let minYear = baseYear - this.yearRange; + let maxYear = baseYear + this.yearRange; + + if (this.minDate) { + const minDateYear = + this.minDate instanceof Date ? this.minDate.getFullYear() : new Date(this.minDate).getFullYear(); + if (minDateYear > minYear) { + minYear = minDateYear; + } + } + + if (this.maxDate) { + const maxDateYear = + this.maxDate instanceof Date ? this.maxDate.getFullYear() : new Date(this.maxDate).getFullYear(); + if (maxDateYear < maxYear) { + maxYear = maxDateYear; + } + } + + this.displayYears = []; + for (let i = minYear; i <= maxYear; i++) { + this.displayYears.push(i); + } + this.cdr.markForCheck(); + } + + private setInitialFocus(): void { + if ( + this.selectedMonth !== null && + this.selectedMonth !== undefined && + this.selectedMonth >= 0 && + this.selectedMonth <= 11 + ) { + this.focusedMonthIndex = this.selectedMonth; + } else { + this.focusedMonthIndex = 0; + } + + if (this.selectedYear !== null && this.selectedYear !== undefined) { + const yearIndex = this.displayYears.indexOf(this.selectedYear); + this.focusedYearIndex = yearIndex !== -1 ? yearIndex : this.displayYears.indexOf(this.currentYear); + } else { + this.focusedYearIndex = this.displayYears.indexOf(this.currentYear); + } + + if (this.focusedYearIndex === -1) { + this.focusedYearIndex = 0; + } + } + + onSelectMonth(monthIndex: number): void { + this.selectedMonth = monthIndex; + this.focusedMonthIndex = monthIndex; + this.emitSelection(); + this.cdr.markForCheck(); + } + + onSelectYear(year: number): void { + this.selectedYear = year; + this.focusedYearIndex = this.displayYears.indexOf(year); + this.emitSelection(); + this.cdr.markForCheck(); + } + + private emitSelection(): void { + if (this.isMonthYearMode) { + if ( + this.selectedMonth !== null && + this.selectedMonth !== undefined && + this.selectedYear !== null && + this.selectedYear !== undefined + ) { + this.select.emit({ month: this.selectedMonth, year: this.selectedYear }); + } + } else { + if (this.selectedYear !== null && this.selectedYear !== undefined) { + this.select.emit({ year: this.selectedYear }); + } + } + } + + isMonthSelected(index: number): boolean { + return this.selectedMonth === index; + } + + isYearSelected(year: number): boolean { + return this.selectedYear === year; + } + + isMonthDisabled(monthIndex: number): boolean { + if (!this.selectedYear) { + return false; + } + + if (this.minDate) { + const minYear = this.minDate.getFullYear(); + const minMonth = this.minDate.getMonth(); + if (this.selectedYear < minYear || (this.selectedYear === minYear && monthIndex < minMonth)) { + return true; + } + } + + if (this.maxDate) { + const maxYear = this.maxDate.getFullYear(); + const maxMonth = this.maxDate.getMonth(); + if (this.selectedYear > maxYear || (this.selectedYear === maxYear && monthIndex > maxMonth)) { + return true; + } + } + + return false; + } + + isYearDisabled(year: number): boolean { + if (this.minDate && year < this.minDate.getFullYear()) { + return true; + } + if (this.maxDate && year > this.maxDate.getFullYear()) { + return true; + } + return false; + } + + getMonthButtonKind(index: number): string { + return this.isMonthSelected(index) ? 'secondary' : 'tertiary'; + } + + getYearButtonKind(year: number): string { + return this.isYearSelected(year) ? 'secondary' : 'tertiary'; + } + + getMonthTabIndex(index: number): number { + return index === this.focusedMonthIndex ? 0 : -1; + } + + getYearTabIndex(index: number): number { + return index === this.focusedYearIndex ? 0 : -1; + } + + onMonthKeydown(event: KeyboardEvent, index: number): void { + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + this.navigateMonth(index, 1); + break; + case 'ArrowUp': + event.preventDefault(); + this.navigateMonth(index, -1); + break; + case 'Enter': + case ' ': + event.preventDefault(); + if (!this.isMonthDisabled(index)) { + this.onSelectMonth(index); + } + break; + case 'Tab': + if (event.shiftKey) { + event.preventDefault(); + this.close.emit(); + } + break; + } + } + + onYearKeydown(event: KeyboardEvent, yearIndex: number): void { + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + this.navigateYear(yearIndex, 1); + break; + case 'ArrowUp': + event.preventDefault(); + this.navigateYear(yearIndex, -1); + break; + case 'Enter': + case ' ': + event.preventDefault(); + if (!this.isYearDisabled(this.displayYears[yearIndex])) { + this.onSelectYear(this.displayYears[yearIndex]); + } + break; + case 'Tab': + if (!event.shiftKey && this.isYearMode) { + event.preventDefault(); + this.close.emit(); + } + if (event.shiftKey && !this.isMonthYearMode) { + event.preventDefault(); + this.close.emit(); + } + break; + } + } + + onYearListKeydown(event: KeyboardEvent): void { + if (event.key === 'Tab' && !event.shiftKey) { + event.preventDefault(); + this.close.emit(); + } + } + + private navigateMonth(currentIndex: number, direction: number): void { + let newIndex = currentIndex + direction; + while (newIndex >= 0 && newIndex < this.displayMonths.length) { + if (!this.isMonthDisabled(newIndex)) { + this.focusedMonthIndex = newIndex; + this.cdr.detectChanges(); + this.focusMonthButton(newIndex); + return; + } + newIndex += direction; + } + } + + private navigateYear(currentIndex: number, direction: number): void { + let newIndex = currentIndex + direction; + while (newIndex >= 0 && newIndex < this.displayYears.length) { + if (!this.isYearDisabled(this.displayYears[newIndex])) { + this.focusedYearIndex = newIndex; + this.cdr.detectChanges(); + this.focusYearButton(newIndex); + return; + } + newIndex += direction; + } + } + + private focusMonthButton(index: number): void { + setTimeout(() => { + const buttons = this.elementRef.nativeElement.querySelectorAll('.po-calendar-month-year-month-item po-button'); + if (buttons[index]) { + const btn = buttons[index].querySelector('button'); + btn?.focus(); + } + }, 0); + } + + private focusYearButton(index: number): void { + setTimeout(() => { + const container = this.elementRef.nativeElement.querySelector('.po-calendar-month-year-year-list'); + const buttons = container?.querySelectorAll('.po-calendar-month-year-year-item po-button'); + if (buttons && buttons[index]) { + const btn = buttons[index].querySelector('button'); + btn?.focus(); + btn?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + }, 0); + } + + focusFirstMonth(): void { + setTimeout(() => { + this.focusMonthButton(this.focusedMonthIndex); + }, 50); + } + + focusFirstYear(): void { + setTimeout(() => { + this.focusYearButton(this.focusedYearIndex); + }, 50); + } + + scrollToSelectedYear(): void { + setTimeout(() => { + const container = this.elementRef.nativeElement.querySelector('.po-calendar-month-year-year-list'); + if (container) { + const selectedEl = container.querySelector('.po-calendar-month-year-year-item--selected'); + if (selectedEl) { + selectedEl.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } else { + const currentYearIndex = this.displayYears.indexOf(this.currentYear); + if (currentYearIndex !== -1) { + const items = container.querySelectorAll('.po-calendar-month-year-year-item'); + if (items[currentYearIndex]) { + items[currentYearIndex].scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + } + } + } + }, 100); + } + + trackByMonth(index: number): number { + return index; + } + + trackByYear(index: number, year: number): number { + return year; + } +} 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 17665823cf..4959bd28d8 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 @@ -1,4 +1,21 @@ -@if (isRange) { +@if (isMonthYear || isYearOnly) { +
+ + +
+} @else if (isRange) {
{ expect(component.selectedPresetLabel).toBeNull(); }); + + describe('onMonthYearSelect:', () => { + it('should set value and emit change for monthYear mode', () => { + component.mode = PoCalendarMode.MonthYear; + spyOn(component.change, 'emit'); + spyOn(component, 'updateModel'); + + component.onMonthYearSelect({ month: 5, year: 2025 }); + + expect(component.selectedMonthValue).toBe(5); + expect(component.selectedYearValue).toBe(2025); + expect(component.value).toBe('06/2025'); + expect(component['updateModel']).toHaveBeenCalledWith('06/2025'); + expect(component.change.emit).toHaveBeenCalledWith('06/2025'); + }); + + it('should set value and emit change for yearOnly mode', () => { + component.mode = PoCalendarMode.Year; + spyOn(component.change, 'emit'); + spyOn(component, 'updateModel'); + + component.onMonthYearSelect({ year: 2025 }); + + expect(component.selectedYearValue).toBe(2025); + expect(component.value).toBe('2025'); + expect(component['updateModel']).toHaveBeenCalledWith('2025'); + expect(component.change.emit).toHaveBeenCalledWith('2025'); + }); + + it('should format single digit months with leading zero', () => { + component.mode = PoCalendarMode.MonthYear; + spyOn(component.change, 'emit'); + spyOn(component, 'updateModel'); + + component.onMonthYearSelect({ month: 0, year: 2025 }); + + expect(component.value).toBe('01/2025'); + }); + + it('should not set value when mode is not monthYear or yearOnly', () => { + component.mode = PoCalendarMode.Range; + spyOn(component.change, 'emit'); + + component.onMonthYearSelect({ month: 5, year: 2025 }); + + expect(component.change.emit).not.toHaveBeenCalled(); + }); + }); + + describe('onMonthYearClose:', () => { + it('should emit close event', () => { + spyOn(component.close, 'emit'); + + component.onMonthYearClose(); + + expect(component.close.emit).toHaveBeenCalled(); + }); + }); + + describe('writeValue:', () => { + it('should call writeMonthYearValue when isMonthYear and value is string', () => { + component.mode = PoCalendarMode.MonthYear; + + component.writeValue('06/2025'); + + expect(component.selectedMonthValue).toBe(5); + expect(component.selectedYearValue).toBe(2025); + expect(component.value).toBe('06/2025'); + }); + + it('should call writeYearValue when isYearOnly and value is string', () => { + component.mode = PoCalendarMode.Year; + + component.writeValue('2025'); + + expect(component.selectedYearValue).toBe(2025); + expect(component.value).toBe('2025'); + }); + + it('should call writeYearValue when isYearOnly and value is number', () => { + component.mode = PoCalendarMode.Year; + + component.writeValue(2025); + + expect(component.selectedYearValue).toBe(2025); + expect(component.value).toBe('2025'); + }); + + it('should set value to null and clear month/year when value is falsy', () => { + component.selectedMonthValue = 5; + component.selectedYearValue = 2025; + + component.writeValue(null); + + expect(component.value).toBeNull(); + expect(component.selectedMonthValue).toBeNull(); + expect(component.selectedYearValue).toBeNull(); + }); + + it('should not call setActivateDate when isMonthYear', () => { + component.mode = PoCalendarMode.MonthYear; + spyOn(component, 'setActivateDate'); + + component.writeValue('06/2025'); + + expect(component['setActivateDate']).not.toHaveBeenCalled(); + }); + + it('should not call setActivateDate when isYearOnly', () => { + component.mode = PoCalendarMode.Year; + spyOn(component, 'setActivateDate'); + + component.writeValue('2025'); + + expect(component['setActivateDate']).not.toHaveBeenCalled(); + }); + + it('should handle writeMonthYearValue with invalid format', () => { + component.mode = PoCalendarMode.MonthYear; + + component.writeValue('invalid'); + + expect(component.selectedMonthValue).toBeNull(); + expect(component.selectedYearValue).toBeNull(); + }); + }); + + describe('clampDate:', () => { + it('should return date when within range', () => { + const date = new Date(2025, 5, 15); + const min = new Date(2025, 0, 1); + const max = new Date(2025, 11, 31); + + const result = component['clampDate'](date, min, max); + expect(result.getTime()).toBe(date.getTime()); + }); + + it('should clamp to min when date is before min', () => { + const date = new Date(2024, 11, 31); + const min = new Date(2025, 0, 1); + + const result = component['clampDate'](date, min); + expect(result.getTime()).toBe(min.getTime()); + }); + + it('should clamp to max when date is after max', () => { + const date = new Date(2026, 0, 1); + const max = new Date(2025, 11, 31); + + const result = component['clampDate'](date, undefined, max); + expect(result.getTime()).toBe(max.getTime()); + }); + + it('should return date when no min/max', () => { + const date = new Date(2025, 5, 15); + + const result = component['clampDate'](date); + expect(result.getTime()).toBe(date.getTime()); + }); + }); + + describe('normalizeDate:', () => { + it('should strip time from date', () => { + const date = new Date(2025, 5, 15, 14, 30, 45); + + const result = component['normalizeDate'](date); + + expect(result.getHours()).toBe(0); + expect(result.getMinutes()).toBe(0); + expect(result.getSeconds()).toBe(0); + expect(result.getFullYear()).toBe(2025); + expect(result.getMonth()).toBe(5); + expect(result.getDate()).toBe(15); + }); + }); + + it('ngOnInit: should call setActivateDate and set displayToClean', () => { + spyOn(component, 'setActivateDate'); + + component.ngOnInit(); + + expect(component['setActivateDate']).toHaveBeenCalled(); + expect(component.displayToClean).toBeDefined(); + }); }); describe('Templates:', () => { 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 253a5d1d05..1d72b76564 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,11 +9,13 @@ import { OnInit, signal, SimpleChanges, + ViewChild, inject } from '@angular/core'; import { AbstractControl, NG_VALIDATORS, NG_VALUE_ACCESSOR } from '@angular/forms'; import { PoCalendarBaseComponent } from './po-calendar-base.component'; +import { PoCalendarMonthYearComponent } from './po-calendar-month-year/po-calendar-month-year.component'; import { PoCalendarRangePreset } from './interfaces/po-calendar-range-preset.interface'; import { PO_CALENDAR_DEFAULT_RANGE_PRESETS } from './constants/po-calendar-range-presets.constant'; import { PoDateService } from '../../services/po-date/po-date.service'; @@ -73,7 +75,11 @@ export class PoCalendarComponent extends PoCalendarBaseComponent implements OnIn private readonly changeDetector = inject(ChangeDetectorRef); private readonly poCalendarLangService = inject(PoCalendarLangService); + @ViewChild('monthYearPicker') monthYearPicker: PoCalendarMonthYearComponent; + hoverValue: Date; + selectedMonthValue: number = null; + selectedYearValue: number = null; displayToClean: string; private readonly _isRange = signal(false); @@ -214,6 +220,29 @@ export class PoCalendarComponent extends PoCalendarBaseComponent implements OnIn this.close.emit(); } + onMonthYearSelect(event: { month?: number; year: number }): void { + if (this.isMonthYear) { + this.selectedMonthValue = event.month; + this.selectedYearValue = event.year; + const formattedMonth = ('0' + (event.month + 1)).slice(-2); + const modelValue = `${formattedMonth}/${event.year}`; + this.value = modelValue; + this.updateModel(modelValue); + this.change.emit(modelValue); + } else if (this.isYearOnly) { + this.selectedYearValue = event.year; + const modelValue = `${event.year}`; + this.value = modelValue; + this.updateModel(modelValue); + this.change.emit(modelValue); + } + this.changeDetector.markForCheck(); + } + + onMonthYearClose(): void { + this.close.emit(); + } + registerOnChange(fn: any): void { this.propagateChange = fn; } @@ -227,18 +256,43 @@ export class PoCalendarComponent extends PoCalendarBaseComponent implements OnIn } writeValue(value: any) { - if (value) { + if (this.isMonthYear && value && typeof value === 'string') { + this.writeMonthYearValue(value); + } else if (this.isYearOnly && value) { + this.writeYearValue(value); + } else if (value) { this.writeDate(value); } else { this.value = null; + this.selectedMonthValue = null; + this.selectedYearValue = null; } - const activateDate = this.getValidateStartDate(value); - this.setActivateDate(activateDate); + if (!this.isMonthYear && !this.isYearOnly) { + const activateDate = this.getValidateStartDate(value); + this.setActivateDate(activateDate); + } this.changeDetector.markForCheck(); } + private writeMonthYearValue(value: string): void { + const parts = value.split('/'); + if (parts.length === 2) { + this.selectedMonthValue = parseInt(parts[0], 10) - 1; + this.selectedYearValue = parseInt(parts[1], 10); + this.value = value; + } + } + + private writeYearValue(value: any): void { + const year = typeof value === 'string' ? parseInt(value, 10) : value; + if (!isNaN(year)) { + this.selectedYearValue = year; + this.value = `${year}`; + } + } + onPresetSelected(event: { label: string; start: Date; end: Date }): void { const start = this.clampDate(event.start, this.minDate, this.maxDate); const end = this.clampDate(event.end, this.minDate, this.maxDate); diff --git a/projects/ui/src/lib/components/po-calendar/po-calendar.module.ts b/projects/ui/src/lib/components/po-calendar/po-calendar.module.ts index a63d7ddbe3..d2f86f88fa 100644 --- a/projects/ui/src/lib/components/po-calendar/po-calendar.module.ts +++ b/projects/ui/src/lib/components/po-calendar/po-calendar.module.ts @@ -9,6 +9,7 @@ import { PoCalendarFooterComponent } from './po-calendar-footer/po-calendar-foot import { PoCalendarHeaderComponent } from './po-calendar-header/po-calendar-header.component'; import { PoCalendarPresetListComponent } from './po-calendar-preset-list/po-calendar-preset-list.component'; import { PoCalendarWrapperComponent } from './po-calendar-wrapper/po-calendar-wrapper.component'; +import { PoCalendarMonthYearComponent } from './po-calendar-month-year/po-calendar-month-year.component'; /** * @description @@ -20,6 +21,7 @@ import { PoCalendarWrapperComponent } from './po-calendar-wrapper/po-calendar-wr PoCalendarComponent, PoCalendarFooterComponent, PoCalendarHeaderComponent, + PoCalendarMonthYearComponent, PoCalendarPresetListComponent, PoCalendarWrapperComponent ], 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 d045861d12..6586634cb4 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 @@ -12,6 +12,7 @@ import { PoLanguageService } from '../../../services/po-language/po-language.ser import { PoMask } from '../po-input/po-mask'; import { PoDatepickerIsoFormat } from './enums/po-datepicker-iso-format.enum'; import { PoDatepickerBaseComponent } from './po-datepicker-base.component'; +import { PoCalendarMode } from '../../po-calendar/po-calendar-mode.enum'; @Directive() class PoDatepickerComponent extends PoDatepickerBaseComponent { @@ -773,5 +774,87 @@ describe('PoDatepickerBaseComponent:', () => { expect((component as any).applySizeBasedOnA11y).toHaveBeenCalled(); }); }); + + describe('isMonthYearMode:', () => { + it('should return true when calendarMode is MonthYear', () => { + component.calendarMode = PoCalendarMode.MonthYear; + expect(component.isMonthYearMode).toBeTrue(); + }); + + it('should return false when calendarMode is Year', () => { + component.calendarMode = PoCalendarMode.Year; + expect(component.isMonthYearMode).toBeFalse(); + }); + + it('should return false when calendarMode is undefined', () => { + component.calendarMode = undefined; + expect(component.isMonthYearMode).toBeFalse(); + }); + }); + + describe('isYearMode:', () => { + it('should return true when calendarMode is Year', () => { + component.calendarMode = PoCalendarMode.Year; + expect(component.isYearMode).toBeTrue(); + }); + + it('should return false when calendarMode is MonthYear', () => { + component.calendarMode = PoCalendarMode.MonthYear; + expect(component.isYearMode).toBeFalse(); + }); + + it('should return false when calendarMode is undefined', () => { + component.calendarMode = undefined; + expect(component.isYearMode).toBeFalse(); + }); + }); + + describe('ngOnInit:', () => { + it('should build monthYear mask when calendarMode is MonthYear', () => { + component.calendarMode = PoCalendarMode.MonthYear; + spyOn(component as any, 'buildMonthYearMask').and.callThrough(); + + component.ngOnInit(); + + expect((component as any).buildMonthYearMask).toHaveBeenCalled(); + expect(component['objMask']).toBeDefined(); + }); + + it('should build year mask when calendarMode is Year', () => { + component.calendarMode = PoCalendarMode.Year; + spyOn(component as any, 'buildYearMask').and.callThrough(); + + component.ngOnInit(); + + expect((component as any).buildYearMask).toHaveBeenCalled(); + expect(component['objMask']).toBeDefined(); + }); + + it('should build default mask when calendarMode is undefined', () => { + component.calendarMode = undefined; + spyOn(component as any, 'buildMask').and.callThrough(); + + component.ngOnInit(); + + expect((component as any).buildMask).toHaveBeenCalled(); + }); + }); + + describe('buildMonthYearMask:', () => { + it('should return a PoMask with mm/yyyy pattern', () => { + component.locale = 'pt'; + const mask = component['buildMonthYearMask'](); + expect(mask).toBeInstanceOf(PoMask); + expect(mask.mask).toBe('99/9999'); + }); + }); + + describe('buildYearMask:', () => { + it('should return a PoMask with yyyy pattern', () => { + const mask = component['buildYearMask'](); + expect(mask).toBeInstanceOf(PoMask); + expect(mask.mask).toBe('9999'); + }); + }); }); }); 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 f8dc7d8389..3574b36be9 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 @@ -29,6 +29,7 @@ import { Observable, Subscription, switchMap } from 'rxjs'; import { PoFieldSize } from '../../../enums/po-field-size.enum'; import { poLocaleDefault } from '../../../services/po-language/po-language.constant'; import { PoLanguageService } from '../../../services/po-language/po-language.service'; +import { PoCalendarMode } from '../../po-calendar/po-calendar-mode.enum'; import { PoDatepickerIsoFormat } from './enums/po-datepicker-iso-format.enum'; import { PoHelperOptions } from '../../po-helper'; @@ -613,6 +614,40 @@ export abstract class PoDatepickerBaseComponent implements ControlValueAccessor, */ @Input({ alias: 'p-append-in-body', transform: convertToBoolean }) appendBox: boolean = false; + /** + * @optional + * + * @description + * + * Define o modo de exibição do calendário no datepicker. + * + * Valores válidos: + * - `undefined`: modo padrão de seleção de data completa. + * - `PoCalendarMode.MonthYear`: exibe listas de meses e anos para seleção. + * - `PoCalendarMode.Year`: exibe apenas a lista de anos para seleção. + */ + @Input('p-mode') calendarMode: PoCalendarMode; + + /** + * @optional + * + * @description + * + * Define o intervalo de anos exibidos nas variações `MonthYear` e `Year`. + * O valor representa a quantidade de anos anteriores e posteriores ao ano atual. + * + * @default `150` + */ + @Input('p-year-range') yearRange: number = 150; + + get isMonthYearMode(): boolean { + return this.calendarMode === PoCalendarMode.MonthYear; + } + + get isYearMode(): boolean { + return this.calendarMode === PoCalendarMode.Year; + } + constructor( protected languageService: PoLanguageService, protected cd: ChangeDetectorRef @@ -630,9 +665,15 @@ export abstract class PoDatepickerBaseComponent implements ControlValueAccessor, this.offset = new Date().getTimezoneOffset(); this.formatTimezoneAndHour(this.offset); // Classe de máscara - this.objMask = this.buildMask( - replaceFormatSeparator(this.format, this.languageService.getDateSeparator(this.locale)) - ); + if (this.isMonthYearMode) { + this.objMask = this.buildMonthYearMask(); + } else if (this.isYearMode) { + this.objMask = this.buildYearMask(); + } else { + this.objMask = this.buildMask( + replaceFormatSeparator(this.format, this.languageService.getDateSeparator(this.locale)) + ); + } } ngOnDestroy(): void { @@ -792,6 +833,15 @@ export abstract class PoDatepickerBaseComponent implements ControlValueAccessor, return new PoMask(mask, true); } + protected buildMonthYearMask() { + const separator = this.languageService.getDateSeparator(this.locale); + return new PoMask(`99${separator}9999`, true); + } + + protected buildYearMask() { + return new PoMask('9999', true); + } + formatTimezoneAndHour(offset: number) { const offsetAbsolute = Math.abs(offset); const timezone = diff --git a/projects/ui/src/lib/components/po-field/po-datepicker/po-datepicker.component.html b/projects/ui/src/lib/components/po-field/po-datepicker/po-datepicker.component.html index 658af11239..8b17928645 100644 --- a/projects/ui/src/lib/components/po-field/po-datepicker/po-datepicker.component.html +++ b/projects/ui/src/lib/components/po-field/po-datepicker/po-datepicker.component.html @@ -119,6 +119,8 @@ [p-min-date]="minDate" [p-locale]="locale" [p-size]="size" + [p-mode]="calendarMode" + [p-year-range]="yearRange" (p-change)="dateSelected($event)" (p-change-month-year)="adjustCalendarPosition()" (keydown)="onCalendarKeyDown($event)" 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 51bddf0ca7..fc2e0e85c9 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 @@ -13,6 +13,7 @@ import { PoDatepickerModule } from './po-datepicker.module'; import { of, Subscription } from 'rxjs'; import { PoKeyCodeEnum } from '../../../enums/po-key-code.enum'; import { PoButtonComponent } from '../../po-button'; +import { PoCalendarMode } from '../../po-calendar/po-calendar-mode.enum'; function keyboardEvents(event: string, keyCode: number) { const eventKeyBoard = document.createEvent('KeyboardEvent'); @@ -1792,6 +1793,106 @@ describe('PoDatepickerComponent:', () => { }); }); + describe('isMonthYearOrYearMode:', () => { + it('should return true when calendarMode is MonthYear', () => { + component.calendarMode = PoCalendarMode.MonthYear; + expect(component['isMonthYearOrYearMode']()).toBeTrue(); + }); + + it('should return true when calendarMode is Year', () => { + component.calendarMode = PoCalendarMode.Year; + expect(component['isMonthYearOrYearMode']()).toBeTrue(); + }); + + it('should return false when calendarMode is undefined', () => { + component.calendarMode = undefined; + expect(component['isMonthYearOrYearMode']()).toBeFalse(); + }); + + it('should return false when calendarMode is Range', () => { + component.calendarMode = PoCalendarMode.Range; + expect(component['isMonthYearOrYearMode']()).toBeFalse(); + }); + }); + + describe('focusMonthYearPicker:', () => { + it('should call focusFirstMonth for MonthYear mode', () => { + component.calendarMode = PoCalendarMode.MonthYear; + const mockPicker = { + focusFirstMonth: jasmine.createSpy('focusFirstMonth'), + focusFirstYear: jasmine.createSpy('focusFirstYear'), + scrollToSelectedYear: jasmine.createSpy('scrollToSelectedYear') + }; + component.calendar = { monthYearPicker: mockPicker } as any; + + component['focusMonthYearPicker'](); + + expect(mockPicker.focusFirstMonth).toHaveBeenCalled(); + expect(mockPicker.focusFirstYear).not.toHaveBeenCalled(); + expect(mockPicker.scrollToSelectedYear).toHaveBeenCalled(); + }); + + it('should call focusFirstYear for Year mode', () => { + component.calendarMode = PoCalendarMode.Year; + const mockPicker = { + focusFirstMonth: jasmine.createSpy('focusFirstMonth'), + focusFirstYear: jasmine.createSpy('focusFirstYear'), + scrollToSelectedYear: jasmine.createSpy('scrollToSelectedYear') + }; + component.calendar = { monthYearPicker: mockPicker } as any; + + component['focusMonthYearPicker'](); + + expect(mockPicker.focusFirstYear).toHaveBeenCalled(); + expect(mockPicker.focusFirstMonth).not.toHaveBeenCalled(); + expect(mockPicker.scrollToSelectedYear).toHaveBeenCalled(); + }); + + it('should do nothing when calendar.monthYearPicker is null', () => { + component.calendar = { monthYearPicker: null } as any; + + expect(() => component['focusMonthYearPicker']()).not.toThrow(); + }); + + it('should do nothing when calendar is null', () => { + component.calendar = null; + + expect(() => component['focusMonthYearPicker']()).not.toThrow(); + }); + }); + + describe('focusCalendar with monthYear/year mode:', () => { + it('should call focusMonthYearPicker when in MonthYear mode', () => { + const event = { preventDefault: jasmine.createSpy(), shiftKey: false } as unknown as KeyboardEvent; + component.calendarMode = PoCalendarMode.MonthYear; + component.dialogPicker = { + nativeElement: { querySelector: () => null } + } as ElementRef; + + spyOn(component as any, 'focusMonthYearPicker'); + + component['focusCalendar'](event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(component['focusMonthYearPicker']).toHaveBeenCalled(); + }); + + it('should call focusMonthYearPicker when in Year mode', () => { + const event = { preventDefault: jasmine.createSpy(), shiftKey: false } as unknown as KeyboardEvent; + component.calendarMode = PoCalendarMode.Year; + component.dialogPicker = { + nativeElement: { querySelector: () => null } + } as ElementRef; + + spyOn(component as any, 'focusMonthYearPicker'); + + component['focusCalendar'](event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(component['focusMonthYearPicker']).toHaveBeenCalled(); + }); + }); + describe('onCalendarKeyDown:', () => { it('should do nothing when calendar is not visible', () => { component.visible = false; @@ -1837,6 +1938,46 @@ describe('PoDatepickerComponent:', () => { expect(component['closeCalendar']).toHaveBeenCalledWith(false); }); + it('should return early when in MonthYear mode and key is not Escape', () => { + component.visible = true; + component.calendarMode = PoCalendarMode.MonthYear; + + const event = { + key: 'Tab', + shiftKey: true, + preventDefault: jasmine.createSpy(), + stopPropagation: jasmine.createSpy() + } as any; + + spyOn(component as any, 'closeCalendar'); + spyOn(component as any, 'isFocusOnFirstCombo'); + + component.onCalendarKeyDown(event); + + expect(component['isFocusOnFirstCombo']).not.toHaveBeenCalled(); + expect(component['closeCalendar']).not.toHaveBeenCalled(); + }); + + it('should return early when in Year mode and key is not Escape', () => { + component.visible = true; + component.calendarMode = PoCalendarMode.Year; + + const event = { + key: 'Tab', + shiftKey: true, + preventDefault: jasmine.createSpy(), + stopPropagation: jasmine.createSpy() + } as any; + + spyOn(component as any, 'closeCalendar'); + spyOn(component as any, 'isFocusOnFirstCombo'); + + component.onCalendarKeyDown(event); + + expect(component['isFocusOnFirstCombo']).not.toHaveBeenCalled(); + expect(component['closeCalendar']).not.toHaveBeenCalled(); + }); + it('should close calendar on Shift+Tab when focus is on first combo', () => { component.visible = true; @@ -1896,6 +2037,34 @@ describe('PoDatepickerComponent:', () => { expect(component['closeCalendar']).not.toHaveBeenCalled(); }); + + it('should focus first combo on Shift+Tab when focus is on last combo', () => { + component.visible = true; + + const mockFirstCombo = { focus: jasmine.createSpy('focus') }; + + const event = { + key: 'Tab', + shiftKey: true, + preventDefault: jasmine.createSpy(), + stopPropagation: jasmine.createSpy() + } as any; + + spyOn(component as any, 'isFocusOnFirstCombo').and.returnValue(false); + spyOn(component as any, 'isFocusOnLastCombo').and.returnValue(true); + + component.dialogPicker = { + nativeElement: { + querySelector: jasmine.createSpy().and.returnValue(mockFirstCombo) + } + } as any; + + component.onCalendarKeyDown(event); + + expect(mockFirstCombo.focus).toHaveBeenCalled(); + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + }); }); describe('closeCalendar', () => { @@ -2006,6 +2175,429 @@ describe('PoDatepickerComponent:', () => { }); }); + describe('MonthYear/Year input support:', () => { + describe('onKeyup with monthYear mode:', () => { + let mockEvent: any; + + beforeEach(() => { + mockEvent = { target: component.inputEl.nativeElement }; + component.calendarMode = PoCalendarMode.MonthYear; + component.locale = 'pt'; + component.ngOnInit(); + spyOn(component['objMask'], 'keyup'); + spyOn(component, 'callOnChange'); + }); + + it('should call handleMonthYearKeyup and parse valid mm/yyyy input', () => { + component['objMask'].valueToModel = '12/2025'; + component.inputEl.nativeElement.value = '12/2025'; + + component.onKeyup(mockEvent); + + expect(component['objMask'].keyup).toHaveBeenCalledWith(mockEvent); + expect(component.callOnChange).toHaveBeenCalledWith('12/2025'); + }); + + it('should call callOnChange with empty string when input length < 7', () => { + component['objMask'].valueToModel = '12/20'; + component.inputEl.nativeElement.value = '12/20'; + + component.onKeyup(mockEvent); + + expect(component.callOnChange).toHaveBeenCalledWith(''); + }); + + it('should call callOnChange with Data inválida for invalid month', () => { + component['objMask'].valueToModel = '13/2025'; + component.inputEl.nativeElement.value = '13/2025'; + + component.onKeyup(mockEvent); + + expect(component.callOnChange).toHaveBeenCalledWith('Data inválida'); + }); + + it('should not call callOnChange when valueToModel is null', () => { + component['objMask'].valueToModel = null; + component.inputEl.nativeElement.value = ''; + + component.onKeyup(mockEvent); + + expect(component.callOnChange).not.toHaveBeenCalled(); + }); + }); + + describe('onKeyup with year mode:', () => { + let mockEvent: any; + + beforeEach(() => { + mockEvent = { target: component.inputEl.nativeElement }; + component.calendarMode = PoCalendarMode.Year; + component.locale = 'pt'; + component.ngOnInit(); + spyOn(component['objMask'], 'keyup'); + spyOn(component, 'callOnChange'); + }); + + it('should parse valid yyyy input', () => { + component['objMask'].valueToModel = '2025'; + component.inputEl.nativeElement.value = '2025'; + + component.onKeyup(mockEvent); + + expect(component.callOnChange).toHaveBeenCalledWith('2025'); + }); + + it('should call callOnChange with empty string when input length < 4', () => { + component['objMask'].valueToModel = '202'; + component.inputEl.nativeElement.value = '202'; + + component.onKeyup(mockEvent); + + expect(component.callOnChange).toHaveBeenCalledWith(''); + }); + + it('should call callOnChange with Data inválida for year 0', () => { + component['objMask'].valueToModel = '0000'; + component.inputEl.nativeElement.value = '0000'; + + component.onKeyup(mockEvent); + + expect(component.callOnChange).toHaveBeenCalledWith('Data inválida'); + }); + + it('should not call callOnChange when valueToModel is null', () => { + component['objMask'].valueToModel = null; + component.inputEl.nativeElement.value = ''; + + component.onKeyup(mockEvent); + + expect(component.callOnChange).not.toHaveBeenCalled(); + }); + }); + + describe('eventOnBlur with monthYear mode:', () => { + beforeEach(() => { + component.calendarMode = PoCalendarMode.MonthYear; + component.locale = 'pt'; + component.ngOnInit(); + component['onTouchedModel'] = () => {}; + spyOn(component, 'callOnChange'); + spyOn(component.onblur, 'emit'); + }); + + it('should parse valid mm/yyyy on blur', () => { + component.inputEl.nativeElement.value = '06/2025'; + + component.eventOnBlur({ target: { value: '' } }); + + expect(component.callOnChange).toHaveBeenCalledWith('06/2025'); + }); + + it('should call callOnChange with Data inválida for invalid month on blur', () => { + component.inputEl.nativeElement.value = '13/2025'; + + component.eventOnBlur({ target: { value: '' } }); + + expect(component.callOnChange).toHaveBeenCalledWith('Data inválida'); + }); + + it('should call callOnChange with empty string when input is empty', () => { + component.inputEl.nativeElement.value = ''; + + component.eventOnBlur({ target: { value: '' } }); + + expect(component.callOnChange).toHaveBeenCalledWith(''); + }); + }); + + describe('eventOnBlur with year mode:', () => { + beforeEach(() => { + component.calendarMode = PoCalendarMode.Year; + component.locale = 'pt'; + component.ngOnInit(); + component['onTouchedModel'] = () => {}; + spyOn(component, 'callOnChange'); + spyOn(component.onblur, 'emit'); + }); + + it('should parse valid yyyy on blur', () => { + component.inputEl.nativeElement.value = '2025'; + + component.eventOnBlur({ target: { value: '' } }); + + expect(component.callOnChange).toHaveBeenCalledWith('2025'); + }); + + it('should call callOnChange with Data inválida for incomplete year on blur', () => { + component.inputEl.nativeElement.value = '20'; + + component.eventOnBlur({ target: { value: '' } }); + + expect(component.callOnChange).toHaveBeenCalledWith('Data inválida'); + }); + + it('should call callOnChange with empty string when input is empty', () => { + component.inputEl.nativeElement.value = ''; + + component.eventOnBlur({ target: { value: '' } }); + + expect(component.callOnChange).toHaveBeenCalledWith(''); + }); + }); + + describe('dateSelected with monthYear/year mode:', () => { + it('should set input value and call callOnChange for monthYear mode', () => { + component.calendarMode = PoCalendarMode.MonthYear; + component['onTouchedModel'] = () => {}; + spyOn(component, 'verifyMobile').and.returnValue(null); + spyOn(component, 'callOnChange'); + spyOn(component, 'togglePicker'); + + component.dateSelected('06/2025'); + + expect(component.inputEl.nativeElement.value).toBe('06/2025'); + expect(component.callOnChange).toHaveBeenCalledWith('06/2025'); + expect(component.togglePicker).toHaveBeenCalled(); + }); + + it('should set input value and call callOnChange for year mode', () => { + component.calendarMode = PoCalendarMode.Year; + component['onTouchedModel'] = () => {}; + spyOn(component, 'verifyMobile').and.returnValue(null); + spyOn(component, 'callOnChange'); + spyOn(component, 'togglePicker'); + + component.dateSelected('2025'); + + expect(component.inputEl.nativeElement.value).toBe('2025'); + expect(component.callOnChange).toHaveBeenCalledWith('2025'); + expect(component.togglePicker).toHaveBeenCalled(); + }); + + it('should handle empty event in monthYear mode', () => { + component.calendarMode = PoCalendarMode.MonthYear; + spyOn(component, 'clear'); + spyOn(component.onchange, 'emit'); + + component.dateSelected(''); + + expect(component.clear).toHaveBeenCalled(); + expect(component.onchange.emit).toHaveBeenCalledWith(undefined); + }); + }); + + describe('formatToDate with monthYear/year mode:', () => { + it('should return string value for monthYear mode', () => { + component.calendarMode = PoCalendarMode.MonthYear; + expect(component.formatToDate('06/2025')).toBe('06/2025'); + }); + + it('should return string value for year mode with string', () => { + component.calendarMode = PoCalendarMode.Year; + expect(component.formatToDate('2025')).toBe('2025'); + }); + + it('should return string value for year mode with number', () => { + component.calendarMode = PoCalendarMode.Year; + expect(component.formatToDate(2025 as any)).toBe('2025'); + }); + + it('should return undefined for null/undefined value', () => { + component.calendarMode = PoCalendarMode.MonthYear; + expect(component.formatToDate(null)).toBeUndefined(); + expect(component.formatToDate(undefined)).toBeUndefined(); + }); + + it('should return undefined for non-Date, non-string value in default mode', () => { + component.calendarMode = undefined; + expect(component.formatToDate(12345 as any)).toBeUndefined(); + }); + }); + + describe('writeValue with monthYear mode:', () => { + it('should write monthYear value to input', () => { + component.calendarMode = PoCalendarMode.MonthYear; + component.locale = 'pt'; + component.ngOnInit(); + + component.writeValue('06/2025'); + + expect(component.inputEl.nativeElement.value).toBe('06/2025'); + }); + + it('should clear input for invalid monthYear value', () => { + component.calendarMode = PoCalendarMode.MonthYear; + component.locale = 'pt'; + component.ngOnInit(); + component.inputEl.nativeElement.value = 'something'; + + component.writeValue('invalid'); + + expect(component.inputEl.nativeElement.value).toBe(''); + }); + + it('should clear input for out-of-range month', () => { + component.calendarMode = PoCalendarMode.MonthYear; + component.locale = 'pt'; + component.ngOnInit(); + + component.writeValue('13/2025'); + + expect(component.inputEl.nativeElement.value).toBe(''); + }); + }); + + describe('writeValue with year mode:', () => { + it('should write year value to input', () => { + component.calendarMode = PoCalendarMode.Year; + component.locale = 'pt'; + component.ngOnInit(); + + component.writeValue('2025'); + + expect(component.inputEl.nativeElement.value).toBe('2025'); + }); + + it('should write numeric year value to input', () => { + component.calendarMode = PoCalendarMode.Year; + component.locale = 'pt'; + component.ngOnInit(); + + component.writeValue(2025); + + expect(component.inputEl.nativeElement.value).toBe('2025'); + }); + + it('should clear input for invalid year value', () => { + component.calendarMode = PoCalendarMode.Year; + component.locale = 'pt'; + component.ngOnInit(); + + component.writeValue('abc'); + + expect(component.inputEl.nativeElement.value).toBe(''); + }); + }); + + describe('parseMonthYearInput:', () => { + it('should parse valid mm/yyyy input', () => { + const result = component['parseMonthYearInput']('06/2025', '/'); + expect(result).toBe('06/2025'); + }); + + it('should parse single-digit month', () => { + const result = component['parseMonthYearInput']('1/2025', '/'); + expect(result).toBe('01/2025'); + }); + + it('should return null for month > 12', () => { + const result = component['parseMonthYearInput']('13/2025', '/'); + expect(result).toBeNull(); + }); + + it('should return null for month < 1', () => { + const result = component['parseMonthYearInput']('00/2025', '/'); + expect(result).toBeNull(); + }); + + it('should return null for year <= 0', () => { + const result = component['parseMonthYearInput']('01/0000', '/'); + expect(result).toBeNull(); + }); + + it('should return null for invalid format', () => { + const result = component['parseMonthYearInput']('abc', '/'); + expect(result).toBeNull(); + }); + + it('should return null for NaN values', () => { + const result = component['parseMonthYearInput']('ab/cdef', '/'); + expect(result).toBeNull(); + }); + }); + + describe('syncCalendarMonthYear:', () => { + it('should sync calendar with valid monthYear value', () => { + const mockCalendar = { + monthYearPicker: { + selectedMonth: null, + selectedYear: null + } + }; + component.calendar = mockCalendar as any; + + component['syncCalendarMonthYear']('06/2025'); + + expect(mockCalendar.monthYearPicker.selectedMonth).toBe(5); + expect(mockCalendar.monthYearPicker.selectedYear).toBe(2025); + }); + + it('should not fail when calendar is null', () => { + component.calendar = null as any; + expect(() => component['syncCalendarMonthYear']('06/2025')).not.toThrow(); + }); + + it('should not fail when monthYearPicker is null', () => { + component.calendar = {} as any; + expect(() => component['syncCalendarMonthYear']('06/2025')).not.toThrow(); + }); + }); + + describe('syncCalendarYear:', () => { + it('should sync calendar with valid year value', () => { + const mockCalendar = { + monthYearPicker: { + selectedYear: null + } + }; + component.calendar = mockCalendar as any; + + component['syncCalendarYear'](2025); + + expect(mockCalendar.monthYearPicker.selectedYear).toBe(2025); + }); + + it('should not fail when calendar is null', () => { + component.calendar = null as any; + expect(() => component['syncCalendarYear'](2025)).not.toThrow(); + }); + }); + + describe('handleMonthYearBlur edge cases:', () => { + it('should handle value with wrong separator', () => { + component.calendarMode = PoCalendarMode.MonthYear; + component.locale = 'pt'; + component.ngOnInit(); + component['onTouchedModel'] = () => {}; + spyOn(component, 'callOnChange'); + spyOn(component.onblur, 'emit'); + + component.inputEl.nativeElement.value = '06-2025'; + + component.eventOnBlur({ target: { value: '' } }); + + expect(component.callOnChange).toHaveBeenCalledWith('Data inválida'); + }); + }); + + describe('handleYearBlur edge cases:', () => { + it('should handle year 0000 as invalid', () => { + component.calendarMode = PoCalendarMode.Year; + component.locale = 'pt'; + component.ngOnInit(); + component['onTouchedModel'] = () => {}; + spyOn(component, 'callOnChange'); + spyOn(component.onblur, 'emit'); + + component.inputEl.nativeElement.value = '0000'; + + component.eventOnBlur({ target: { value: '' } }); + + expect(component.callOnChange).toHaveBeenCalledWith('Data inválida'); + }); + }); + }); + describe('Templates:', () => { it('should contain the `po-datepicker-popup-calendar` class if `verifyMobile` is false', () => { spyOn(component, 'verifyMobile').and.returnValue(false); 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 f202bac09d..7a9c44bff0 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 @@ -23,6 +23,7 @@ import { isObservable, of, Subscription, switchMap } from 'rxjs'; import { PoLanguageService } from '../../../services/po-language/po-language.service'; import { PoButtonComponent } from '../../po-button/po-button.component'; import { PoCalendarComponent } from '../../po-calendar/po-calendar.component'; +import { PoCalendarMode } from '../../po-calendar/po-calendar-mode.enum'; import { PoDatepickerBaseComponent } from './po-datepicker-base.component'; import { PoDatepickerLiterals } from './po-datepicker.literals'; import { PoHelperComponent } from '../../po-helper'; @@ -150,6 +151,17 @@ export class PoDatepickerComponent extends PoDatepickerBaseComponent implements } this.objMask?.keyup($event); + + if (this.isMonthYearMode) { + this.handleMonthYearKeyup(); + return; + } + + if (this.isYearMode) { + this.handleYearKeyup(); + return; + } + // Controla a atualização do model, verificando se a data é valida if (this.objMask?.valueToModel || this.objMask?.valueToModel === '') { if (this.objMask?.valueToModel.length >= 10) { @@ -290,6 +302,14 @@ export class PoDatepickerComponent extends PoDatepickerBaseComponent implements this.focus(); } + if (this.isMonthYearMode || this.isYearMode) { + this.inputEl.nativeElement.value = event || ''; + this.callOnChange(event); + this.controlChangeEmitter(); + this.togglePicker(); + return; + } + this.inputEl.nativeElement.value = this.formatToDate(this.date) || ''; this.controlModel(this.date); this.controlChangeEmitter(); @@ -344,11 +364,24 @@ export class PoDatepickerComponent extends PoDatepickerBaseComponent implements eventOnBlur($event: any) { this.onTouchedModel?.(); - const date = this.inputEl.nativeElement.value; - const newDate = date ? this.getDateFromString(date) : undefined; this.objMask?.blur($event); this.onblur.emit(); + if (this.isMonthYearMode) { + this.handleMonthYearBlur(); + this.controlChangeEmitter(); + return; + } + + if (this.isYearMode) { + this.handleYearBlur(); + this.controlChangeEmitter(); + return; + } + + const date = this.inputEl.nativeElement.value; + const newDate = date ? this.getDateFromString(date) : undefined; + if (this.objMask?.valueToModel) { if (this.objMask.valueToModel.length >= 10) { this.controlModel(newDate); @@ -399,11 +432,23 @@ export class PoDatepickerComponent extends PoDatepickerBaseComponent implements } } - formatToDate(value: Date) { + formatToDate(value: any) { if (!value) { return undefined; } + if (this.isMonthYearMode && typeof value === 'string') { + return value; + } + + if (this.isYearMode && (typeof value === 'string' || typeof value === 'number')) { + return `${value}`; + } + + if (!(value instanceof Date)) { + return undefined; + } + let dateFormatted = PoUtils.replaceFormatSeparator(this.format, this.languageService.getDateSeparator(this.locale)); dateFormatted = dateFormatted.replace('dd', ('0' + value.getDate()).slice(-2)); @@ -469,6 +514,16 @@ export class PoDatepickerComponent extends PoDatepickerBaseComponent implements // Função implementada do ControlValueAccessor writeValue(value: any) { if (this.inputEl && value) { + if (this.isMonthYearMode && typeof value === 'string') { + this.writeMonthYearValue(value); + return; + } + + if (this.isYearMode) { + this.writeYearValue(value); + return; + } + if (value instanceof Date) { const dateString = value.toString(); this.hour = @@ -557,6 +612,143 @@ export class PoDatepickerComponent extends PoDatepickerBaseComponent implements return isMobile(); } + private handleMonthYearKeyup(): void { + const value = this.inputEl.nativeElement.value; + const separator = this.languageService.getDateSeparator(this.locale); + const expectedLength = 7; // mm/yyyy + + if (this.objMask?.valueToModel || this.objMask?.valueToModel === '') { + if (value.length >= expectedLength) { + const parsed = this.parseMonthYearInput(value, separator); + if (parsed) { + this.callOnChange(parsed); + this.syncCalendarMonthYear(parsed); + } else { + this.callOnChange('Data inválida'); + } + } else { + this.callOnChange(''); + } + } + } + + private handleYearKeyup(): void { + const value = this.inputEl.nativeElement.value; + const expectedLength = 4; // yyyy + + if (this.objMask?.valueToModel || this.objMask?.valueToModel === '') { + if (value.length >= expectedLength) { + const year = parseInt(value, 10); + if (!isNaN(year) && year > 0) { + const modelValue = `${year}`; + this.callOnChange(modelValue); + this.syncCalendarYear(year); + } else { + this.callOnChange('Data inválida'); + } + } else { + this.callOnChange(''); + } + } + } + + private handleMonthYearBlur(): void { + const value = this.inputEl.nativeElement.value; + const separator = this.languageService.getDateSeparator(this.locale); + + if (value) { + const parsed = this.parseMonthYearInput(value, separator); + if (parsed) { + this.callOnChange(parsed); + this.syncCalendarMonthYear(parsed); + } else { + this.callOnChange('Data inválida'); + } + } else { + this.callOnChange(''); + } + } + + private handleYearBlur(): void { + const value = this.inputEl.nativeElement.value; + + if (value) { + const year = parseInt(value, 10); + if (!isNaN(year) && year > 0 && value.length === 4) { + const modelValue = `${year}`; + this.callOnChange(modelValue); + this.syncCalendarYear(year); + } else { + this.callOnChange('Data inválida'); + } + } else { + this.callOnChange(''); + } + } + + private parseMonthYearInput(value: string, separator: string): string | null { + const parts = value.split(separator); + if (parts.length !== 2) { + 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) { + return null; + } + return ('0' + month).slice(-2) + '/' + year; + } + + private syncCalendarMonthYear(modelValue: string): void { + const parts = modelValue.split('/'); + if (parts.length === 2) { + const month = parseInt(parts[0], 10) - 1; + const year = parseInt(parts[1], 10); + if (this.calendar?.monthYearPicker) { + this.calendar.monthYearPicker.selectedMonth = month; + this.calendar.monthYearPicker.selectedYear = year; + } + } + } + + private syncCalendarYear(year: number): void { + if (this.calendar?.monthYearPicker) { + this.calendar.monthYearPicker.selectedYear = year; + } + } + + private writeMonthYearValue(value: string): void { + const separator = this.languageService.getDateSeparator(this.locale); + const parts = value.split('/'); + if (parts.length === 2) { + const month = parseInt(parts[0], 10); + const year = parseInt(parts[1], 10); + if (!isNaN(month) && !isNaN(year) && month >= 1 && month <= 12) { + const displayValue = ('0' + month).slice(-2) + separator + year; + this.inputEl.nativeElement.value = displayValue; + this.callOnChange(value, false); + this.syncCalendarMonthYear(value); + } else { + this.inputEl.nativeElement.value = ''; + } + } else { + this.inputEl.nativeElement.value = ''; + } + this.valueBeforeChange = this.inputEl.nativeElement.value; + } + + private writeYearValue(value: any): void { + const year = typeof value === 'string' ? parseInt(value, 10) : value; + if (!isNaN(year) && year > 0) { + this.inputEl.nativeElement.value = `${year}`; + this.callOnChange(`${year}`, false); + this.syncCalendarYear(year); + } else { + this.inputEl.nativeElement.value = ''; + } + this.valueBeforeChange = this.inputEl.nativeElement.value; + } + private controlChangeEmitter() { const dateModelFormatted = this.formatToDate(this.date); @@ -679,6 +871,12 @@ export class PoDatepickerComponent extends PoDatepickerBaseComponent implements return; } + if (this.isMonthYearOrYearMode()) { + event.preventDefault(); + this.focusMonthYearPicker(); + return; + } + const firstCombo = this.dialogPicker.nativeElement.querySelector('.po-combo-first .po-combo-input'); if (firstCombo) { event.preventDefault(); @@ -688,10 +886,37 @@ export class PoDatepickerComponent extends PoDatepickerBaseComponent implements } } + private isMonthYearOrYearMode(): boolean { + return this.calendarMode === PoCalendarMode.MonthYear || this.calendarMode === PoCalendarMode.Year; + } + + private focusMonthYearPicker(): void { + if (this.calendar?.monthYearPicker) { + if (this.calendarMode === PoCalendarMode.MonthYear) { + this.calendar.monthYearPicker.focusFirstMonth(); + } else { + this.calendar.monthYearPicker.focusFirstYear(); + } + this.calendar.monthYearPicker.scrollToSelectedYear(); + } + } + onCalendarKeyDown(event: KeyboardEvent): void { if (!this.visible) return; - if (event.key === 'Escape' || (event.key === 'Tab' && event.shiftKey && this.isFocusOnFirstCombo())) { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + this.iconDatepicker.buttonElement?.nativeElement.focus(); + this.closeCalendar(false); + return; + } + + if (this.isMonthYearOrYearMode()) { + return; + } + + if (event.key === 'Tab' && event.shiftKey && this.isFocusOnFirstCombo()) { event.preventDefault(); event.stopPropagation();