diff --git a/playwright/a11y-matrix-generator.js b/playwright/a11y-matrix-generator.js index 88aa01557..95d0f2728 100644 --- a/playwright/a11y-matrix-generator.js +++ b/playwright/a11y-matrix-generator.js @@ -80,7 +80,7 @@ function flattenSuites(suites) { test: spec.title, status: result?.status ?? 'unknown', error: result?.error?.message ?? '', - axeResults: parseAxeAttachment(result) + axeResults: parseAxeAttachments(result) }); } for (const sub of suite.suites || []) { @@ -91,7 +91,7 @@ function flattenSuites(suites) { test: `${sub.title} - ${spec.title}`, status: result?.status ?? 'unknown', error: result?.error?.message ?? '', - axeResults: parseAxeAttachment(result) + axeResults: parseAxeAttachments(result) }); } } @@ -99,18 +99,19 @@ function flattenSuites(suites) { return out; } -/** Extract the axe-core results object from the test attachment */ -function parseAxeAttachment(result) { - if (!result?.attachments) return null; - const att = result.attachments.find( - (a) => a.name === 'accessibility-scan-results' && a.body - ); - if (!att) return null; - try { - return JSON.parse(Buffer.from(att.body, 'base64').toString()); - } catch { - return null; - } +/** Extract all axe-core results objects from the test attachments (one per state) */ +function parseAxeAttachments(result) { + if (!result?.attachments) return []; + return result.attachments + .filter((a) => a.name.endsWith('-accessibility-scan') && a.body) + .map((a) => { + try { + return JSON.parse(Buffer.from(a.body, 'base64').toString()); + } catch { + return null; + } + }) + .filter(Boolean); } const tests = flattenSuites(data.suites?.[0]?.suites ?? []); @@ -132,23 +133,24 @@ function extractViolationIds(errorMsg) { ]; } -function addResults(comp, status, errorMsg, axeResults) { +function addResults(comp, status, errorMsg, axeResultsArr) { allComponents.add(comp); - // Use axe attachment for accurate applicability data - if (axeResults) { - for (const v of axeResults.violations || []) { - failedRules[`${comp}|||${v.id}`] = true; - applicableRules[`${comp}|||${v.id}`] = true; - allRules.add(v.id); - } - for (const p of axeResults.passes || []) { - applicableRules[`${comp}|||${p.id}`] = true; - } - for (const inc of axeResults.incomplete || []) { - applicableRules[`${comp}|||${inc.id}`] = true; + // Use axe attachments for accurate applicability data (one per state scan) + if (axeResultsArr.length > 0) { + for (const axeResults of axeResultsArr) { + for (const v of axeResults.violations || []) { + failedRules[`${comp}|||${v.id}`] = true; + applicableRules[`${comp}|||${v.id}`] = true; + allRules.add(v.id); + } + for (const p of axeResults.passes || []) { + applicableRules[`${comp}|||${p.id}`] = true; + } + for (const inc of axeResults.incomplete || []) { + applicableRules[`${comp}|||${inc.id}`] = true; + } } - // inapplicable rules are NOT added to applicableRules return; } diff --git a/playwright/cps-accessibility.spec.ts b/playwright/cps-accessibility.spec.ts index 0647f6d0c..fd3fd95cc 100644 --- a/playwright/cps-accessibility.spec.ts +++ b/playwright/cps-accessibility.spec.ts @@ -35,15 +35,18 @@ const components: ComponentEntry[] = [ }, { route: '/checkbox', name: 'Checkbox', selector: 'cps-checkbox' }, { route: '/chip', name: 'Chip', selector: 'cps-chip' }, - // { - // route: '/datepicker', - // name: 'Datepicker', - // selector: ['cps-datepicker', '.cps-datepicker-calendar-menu'], - // setup: async (page) => { - // await page.waitForSelector('cps-datepicker'); - // await page.locator('cps-datepicker .cps-icon').first().click(); - // } - // }, + { + route: '/datepicker', + name: 'Datepicker', + selector: ['cps-datepicker', '.cps-datepicker-calendar-menu'], + setup: async (page) => { + await page.waitForSelector('cps-datepicker'); + await page + .locator('cps-datepicker .cps-input-prefix-icon') + .first() + .click(); + } + }, { route: '/dialog', name: 'Confirmation dialog', diff --git a/projects/composition/src/app/api-data/cps-datepicker.json b/projects/composition/src/app/api-data/cps-datepicker.json index 62e0c1d84..8e1827cb7 100644 --- a/projects/composition/src/app/api-data/cps-datepicker.json +++ b/projects/composition/src/app/api-data/cps-datepicker.json @@ -37,13 +37,21 @@ "default": "100%", "description": "Width of the datepicker of type number denoting pixels or string." }, + { + "name": "dateFormat", + "optional": false, + "readonly": false, + "type": "CpsDatepickerDateFormat", + "default": "MM/DD/YYYY", + "description": "Date format for displaying and parsing the date string." + }, { "name": "placeholder", "optional": false, "readonly": false, "type": "string", - "default": "MM/DD/YYYY", - "description": "Placeholder text." + "default": "", + "description": "Placeholder text. Defaults to the configured dateFormat." }, { "name": "hint", @@ -145,14 +153,14 @@ "name": "minDate", "optional": false, "readonly": false, - "type": "Date", + "type": "Date | undefined", "description": "Minimal date availalbe for selection." }, { "name": "maxDate", "optional": false, "readonly": false, - "type": "Date", + "type": "Date | undefined", "description": "Maximal date availalbe for selection." }, { @@ -189,6 +197,11 @@ "name": "CpsDatepickerAppearanceType", "value": "\"outlined\" | \"underlined\" | \"borderless\"", "description": "CpsDatepickerAppearanceType is used to define the border of the datepicker input." + }, + { + "name": "CpsDatepickerDateFormat", + "value": "\"DD/MM/YYYY\" | \"MM/DD/YYYY\" | \"YYYY/MM/DD\"", + "description": "CpsDatepickerDateFormat defines the display and input format of the date string." } ] } diff --git a/projects/composition/src/app/api-data/cps-input.json b/projects/composition/src/app/api-data/cps-input.json index 7a9d0c177..8f8d1c246 100644 --- a/projects/composition/src/app/api-data/cps-input.json +++ b/projects/composition/src/app/api-data/cps-input.json @@ -21,6 +21,38 @@ "default": "", "description": "Aria label for the input component, used for accessibility, it takes precedence over label." }, + { + "name": "inputRole", + "optional": false, + "readonly": false, + "type": "string | null", + "default": "null", + "description": "WAI-ARIA role for the native input element." + }, + { + "name": "ariaExpanded", + "optional": false, + "readonly": false, + "type": "boolean | null", + "default": "null", + "description": "Whether the element controlled by this input is expanded." + }, + { + "name": "ariaHasPopup", + "optional": false, + "readonly": false, + "type": "string | null", + "default": "null", + "description": "Type of popup element the input controls." + }, + { + "name": "ariaControls", + "optional": false, + "readonly": false, + "type": "string | null", + "default": "null", + "description": "ID of the element controlled by this input." + }, { "name": "hint", "optional": false, diff --git a/projects/composition/src/app/api-data/types_map.json b/projects/composition/src/app/api-data/types_map.json index 69d282f47..56d0c694d 100644 --- a/projects/composition/src/app/api-data/types_map.json +++ b/projects/composition/src/app/api-data/types_map.json @@ -2,6 +2,7 @@ "CpsAutocompleteAppearanceType": "autocomplete", "CpsButtonToggleOption": "button-toggle", "CpsDatepickerAppearanceType": "datepicker", + "CpsDatepickerDateFormat": "datepicker", "CpsDividerType": "divider", "IconType": "icon", "iconSizeType": "icon", diff --git a/projects/composition/src/app/pages/datepicker-page/datepicker-page.component.html b/projects/composition/src/app/pages/datepicker-page/datepicker-page.component.html index b20e1214f..778a077ee 100644 --- a/projects/composition/src/app/pages/datepicker-page/datepicker-page.component.html +++ b/projects/composition/src/app/pages/datepicker-page/datepicker-page.component.html @@ -36,13 +36,18 @@ [clearable]="true" [showTodayButton]="false"> + +
diff --git a/projects/composition/src/app/pages/datepicker-page/datepicker-page.component.scss b/projects/composition/src/app/pages/datepicker-page/datepicker-page.component.scss index ecf004b01..6dc6bfe0e 100644 --- a/projects/composition/src/app/pages/datepicker-page/datepicker-page.component.scss +++ b/projects/composition/src/app/pages/datepicker-page/datepicker-page.component.scss @@ -1,11 +1,11 @@ .datepickers-group { - gap: 24px; + gap: 1.5rem; display: flex; flex-direction: column; } .sync-val-example { .sync-val { - margin-top: 8px; + margin-top: 0.5rem; } } diff --git a/projects/cps-ui-kit/src/lib/components/cps-datepicker/cps-datepicker.component.html b/projects/cps-ui-kit/src/lib/components/cps-datepicker/cps-datepicker.component.html index 8ec2c63da..97b4ff002 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-datepicker/cps-datepicker.component.html +++ b/projects/cps-ui-kit/src/lib/components/cps-datepicker/cps-datepicker.component.html @@ -1,19 +1,21 @@ -
+
-
+ diff --git a/projects/cps-ui-kit/src/lib/components/cps-datepicker/cps-datepicker.component.scss b/projects/cps-ui-kit/src/lib/components/cps-datepicker/cps-datepicker.component.scss index 75e9eaea2..0616e157c 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-datepicker/cps-datepicker.component.scss +++ b/projects/cps-ui-kit/src/lib/components/cps-datepicker/cps-datepicker.component.scss @@ -1,10 +1,10 @@ +@use '../../../../styles/mixins' as *; + $color-calm: var(--cps-color-calm); -$color-prepared: var(--cps-color-prepared); $color-highlight: var(--cps-color-highlight-hover); $color-selected-background: var(--cps-color-highlight-selected); -$other-month-color: var(--cps-color-text-light); $text-color: var(--cps-color-text-darkest); -$disabled-day-color: var(--cps-color-text-lightest); +$disabled-day-color: var(--cps-color-text-light); :host { display: flex; @@ -41,7 +41,11 @@ $disabled-day-color: var(--cps-color-text-lightest); padding: 0.5rem; background: #ffffff; color: $text-color; - border: 1px solid #ced4da; + border: 0.0625rem solid #ced4da; + } + + .p-datepicker .p-datepicker-panel-inline { + vertical-align: top; } .p-datepicker .p-datepicker-header { @@ -50,9 +54,9 @@ $disabled-day-color: var(--cps-color-text-lightest); background: #ffffff; font-weight: 600; margin: 0; - border-bottom: 1px solid #dee2e6; - border-top-right-radius: 6px; - border-top-left-radius: 6px; + border-bottom: 0.0625rem solid #dee2e6; + border-top-right-radius: 0.375rem; + border-top-left-radius: 0.375rem; } .p-datepicker .p-datepicker-header .p-datepicker-prev-button, .p-datepicker .p-datepicker-header .p-datepicker-next-button { @@ -75,9 +79,13 @@ $disabled-day-color: var(--cps-color-text-lightest); } .p-datepicker .p-datepicker-header .p-datepicker-prev-button:focus, .p-datepicker .p-datepicker-header .p-datepicker-next-button:focus { - outline: 0 none; - outline-offset: 0; - box-shadow: unset; + overflow: visible; + @include focus-ring(0.125rem, 0.25rem, 50%); + } + .p-datepicker .p-datepicker-header .p-datepicker-prev-button svg, + .p-datepicker .p-datepicker-header .p-datepicker-next-button svg { + width: 0.875rem; + height: 0.875rem; } .p-datepicker .p-datepicker-header .p-datepicker-title { line-height: 2rem; @@ -108,6 +116,18 @@ $disabled-day-color: var(--cps-color-text-lightest); .p-datepicker-select-month:enabled:hover { color: $color-calm; } + .p-datepicker + .p-datepicker-header + .p-datepicker-title + .p-datepicker-select-year:focus, + .p-datepicker + .p-datepicker-header + .p-datepicker-title + .p-datepicker-select-month:focus { + overflow: visible; + color: $color-calm; + @include focus-ring(0.125rem, 0.25rem, 0.375rem); + } .p-datepicker .p-datepicker-header .p-datepicker-title @@ -133,7 +153,7 @@ $disabled-day-color: var(--cps-color-text-lightest); height: 2.5rem; border-radius: 50%; transition: box-shadow 0.2s; - border: 1px solid transparent; + border: 0.0625rem solid transparent; &.p-disabled { color: $disabled-day-color; cursor: default; @@ -145,9 +165,8 @@ $disabled-day-color: var(--cps-color-text-lightest); font-weight: bold; } .p-datepicker table td > span:focus { - outline: 0 none; - outline-offset: 0; - box-shadow: unset; + overflow: visible; + @include focus-ring(0.0625rem, 0.125rem, 50%); } .p-datepicker table td.p-datepicker-today > span { border-color: $color-calm; @@ -161,16 +180,20 @@ $disabled-day-color: var(--cps-color-text-lightest); } .p-datepicker .p-datepicker-buttonbar { padding: 0.5rem 0 0; - border-top: 1px solid #dee2e6; + border-top: 0.0625rem solid #dee2e6; } .p-datepicker .p-datepicker-buttonbar .p-button { width: auto; - color: $color-prepared; - border-width: 2px; + color: $color-calm; + border-width: 0.125rem; border-style: solid; } + .p-datepicker .p-datepicker-buttonbar .p-button:focus { + overflow: visible; + @include focus-ring(); + } .p-datepicker .p-timepicker { - border-top: 1px solid #dee2e6; + border-top: 0.0625rem solid #dee2e6; padding: 0.5rem; } .p-datepicker .p-timepicker button { @@ -213,7 +236,8 @@ $disabled-day-color: var(--cps-color-text-lightest); .p-datepicker .p-datepicker-month-view .p-datepicker-month { padding: 0.5rem; transition: box-shadow 0.2s; - border-radius: 6px; + border-radius: 0.375rem; + position: relative; } .p-datepicker .p-datepicker-month-view @@ -222,13 +246,22 @@ $disabled-day-color: var(--cps-color-text-lightest); background: $color-selected-background; font-weight: bold; } + .p-datepicker .p-datepicker-month-view .p-datepicker-month.p-disabled { + color: $disabled-day-color; + cursor: default; + } .p-datepicker .p-datepicker-year-view { margin: 0.5rem 0; } .p-datepicker .p-datepicker-year-view .p-datepicker-year { padding: 0.5rem; transition: box-shadow 0.2s; - border-radius: 6px; + border-radius: 0.375rem; + position: relative; + } + .p-datepicker .p-datepicker-year-view .p-datepicker-year.p-disabled { + color: $disabled-day-color; + cursor: default; } .p-datepicker .p-datepicker-year-view @@ -238,7 +271,7 @@ $disabled-day-color: var(--cps-color-text-lightest); font-weight: bold; } .p-datepicker.p-datepicker-multiple-month .p-datepicker-group { - border-left: 1px solid #dee2e6; + border-left: 0.0625rem solid #dee2e6; padding-right: 0.5rem; padding-left: 0.5rem; padding-top: 0; @@ -254,7 +287,7 @@ $disabled-day-color: var(--cps-color-text-lightest); .p-datepicker:not(.p-disabled) table td - span:not(.p-datepicker-day-selected):not(.p-disabled):hover { + span:not(.p-datepicker-day-selected):not(.p-disabled):is(:hover, :focus) { background: $color-highlight; } .p-datepicker:not(.p-disabled) @@ -267,31 +300,37 @@ $disabled-day-color: var(--cps-color-text-lightest); } .p-datepicker:not(.p-disabled) .p-datepicker-month-view - .p-datepicker-month:not(.p-disabled):not( - .p-datepicker-day-selected - ):hover { + .p-datepicker-month:not(.p-disabled):not(.p-datepicker-day-selected):is( + :hover, + :focus + ) { background: $color-highlight; } .p-datepicker:not(.p-disabled) .p-datepicker-month-view - .p-datepicker-month:not(.p-disabled):focus { - outline: 0 none; - outline-offset: 0; - box-shadow: unset; + .p-datepicker-month:not(.p-disabled):not( + .p-datepicker-day-selected + ):focus { + overflow: visible; + z-index: 1; + @include focus-ring(0.125rem, 0.25rem, 0.375rem); } .p-datepicker:not(.p-disabled) - .p-yearpicker - .p-datepicker-year:not(.p-disabled):not( - .p-datepicker-day-selected - ):hover { + .p-datepicker-year-view + .p-datepicker-year:not(.p-disabled):not(.p-datepicker-day-selected):is( + :hover, + :focus + ) { background: $color-highlight; } .p-datepicker:not(.p-disabled) - .p-yearpicker - .p-datepicker-year:not(.p-disabled):focus { - outline: 0 none; - outline-offset: 0; - box-shadow: unset; + .p-datepicker-year-view + .p-datepicker-year:not(.p-disabled):not( + .p-datepicker-day-selected + ):focus { + overflow: visible; + z-index: 1; + @include focus-ring(0.125rem, 0.25rem, 0.375rem); } p-calendar.p-calendar-clearable .p-inputtext { @@ -313,7 +352,7 @@ $disabled-day-color: var(--cps-color-text-lightest); } .p-datepicker-other-month { - color: $other-month-color; + visibility: hidden; } .p-ripple:focus { @@ -333,7 +372,7 @@ $disabled-day-color: var(--cps-color-text-lightest); user-select: none; font-size: 1rem; font-family: 'Source Sans Pro', sans-serif; - border-radius: 6px; + border-radius: 0.375rem; } .p-datepicker-buttonbar { @@ -359,7 +398,7 @@ $disabled-day-color: var(--cps-color-text-lightest); color 0.2s, border-color 0.2s, box-shadow 0.2s; - border-radius: 6px; + border-radius: 0.375rem; &:focus { box-shadow: unset; } @@ -379,12 +418,12 @@ $disabled-day-color: var(--cps-color-text-lightest); .p-hidden-accessible { border: 0; clip: rect(0 0 0 0); - height: 1px; - margin: -1px; + height: 0.0625rem; + margin: -0.0625rem; overflow: hidden; padding: 0; position: absolute; - width: 1px; + width: 0.0625rem; } } } diff --git a/projects/cps-ui-kit/src/lib/components/cps-datepicker/cps-datepicker.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-datepicker/cps-datepicker.component.spec.ts new file mode 100644 index 000000000..b0ab13a1e --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-datepicker/cps-datepicker.component.spec.ts @@ -0,0 +1,1021 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + fakeAsync, + TestBed, + tick +} from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { CpsDatepickerComponent } from './cps-datepicker.component'; +import { CpsMenuHideReason } from '../cps-menu/cps-menu.component'; + +function keyEvent(key: string, target: HTMLElement): KeyboardEvent { + const event = new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true + }); + Object.defineProperty(event, 'target', { value: target, configurable: true }); + return event; +} + +function makeDp( + overrides: Partial<{ + currentView: string; + currentYear: number; + currentMonth: number; + isYearDisabled: (y: number) => boolean; + isMonthDisabled: (m: number, y: number) => boolean; + navigationState: unknown; + _focusKey: string | null; + }> = {} +) { + return { + currentView: 'date', + currentYear: 2026, + currentMonth: 5, + isYearDisabled: jest.fn().mockReturnValue(false), + isMonthDisabled: jest.fn().mockReturnValue(false), + navigationState: null, + _focusKey: null, + ...overrides + }; +} + +function makeDateGrid( + rows: number, + cols: number, + disabledPositions: { row: number; col: number }[] = [] +): { tbody: HTMLTableSectionElement; rowEls: HTMLTableRowElement[] } { + const tbody = document.createElement('tbody'); + const rowEls: HTMLTableRowElement[] = []; + for (let r = 0; r < rows; r++) { + const tr = document.createElement('tr'); + for (let c = 0; c < cols; c++) { + const td = document.createElement('td'); + const span = document.createElement('span'); + span.setAttribute('data-date', `2026-${r}-${c}`); + if (disabledPositions.some((p) => p.row === r && p.col === c)) { + span.classList.add('p-disabled'); + } + td.appendChild(span); + tr.appendChild(td); + } + tbody.appendChild(tr); + rowEls.push(tr); + } + return { tbody, rowEls }; +} + +describe('CpsDatepickerComponent', () => { + let component: CpsDatepickerComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CpsDatepickerComponent, FormsModule, NoopAnimationsModule], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(CpsDatepickerComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('ariaLabel', 'Test datepicker'); + fixture.detectChanges(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('Inputs', () => { + it('should default disabled to false', () => { + expect(component.disabled).toBe(false); + }); + + it('should apply disabled input', () => { + fixture.componentRef.setInput('disabled', true); + fixture.detectChanges(); + expect(component.disabled).toBe(true); + }); + + it('should convert width on init', () => { + expect(component.cvtWidth).toBe('100%'); + }); + + it('should update cvtWidth when width changes', () => { + fixture.componentRef.setInput('width', 300); + component.ngOnChanges({ + width: { + currentValue: 300, + previousValue: '100%', + firstChange: false, + isFirstChange: () => false + } + }); + expect(component.cvtWidth).toBe('300px'); + }); + + it('should update stringDate when dateFormat changes (not first change)', () => { + component.writeValue(new Date(2026, 5, 15)); + fixture.componentRef.setInput('dateFormat', 'MM/DD/YYYY'); + component.ngOnChanges({ + dateFormat: { + currentValue: 'MM/DD/YYYY', + previousValue: 'DD/MM/YYYY', + firstChange: false, + isFirstChange: () => false + } + }); + expect(component.stringDate).toBe('06/15/2026'); + }); + + it('should not update stringDate on first change of dateFormat', () => { + fixture.componentRef.setInput('dateFormat', 'DD/MM/YYYY'); + component.writeValue(new Date(2026, 5, 15)); + component.ngOnChanges({ + dateFormat: { + currentValue: 'DD/MM/YYYY', + previousValue: 'DD/MM/YYYY', + firstChange: true, + isFirstChange: () => true + } + }); + expect(component.stringDate).toBe('15/06/2026'); + }); + }); + + describe('ControlValueAccessor', () => { + it('should set value via writeValue', () => { + const date = new Date(2026, 5, 15); + component.writeValue(date); + expect(component.value).toBe(date); + }); + + it('should clear value via writeValue(null)', () => { + component.writeValue(new Date(2026, 5, 15)); + component.writeValue(null); + expect(component.value).toBeNull(); + }); + + it('should call registered onChange when a new value is written', () => { + const fn = jest.fn(); + component.registerOnChange(fn); + const date = new Date(2026, 5, 15); + component.writeValue(date); + expect(fn).toHaveBeenCalledWith(date); + }); + + it('should call registered onTouched', () => { + const fn = jest.fn(); + component.registerOnTouched(fn); + component.onTouched(); + expect(fn).toHaveBeenCalled(); + }); + }); + + describe('Date string formatting', () => { + it('should format as DD/MM/YYYY', () => { + fixture.componentRef.setInput('dateFormat', 'DD/MM/YYYY'); + component.writeValue(new Date(2026, 0, 5)); + expect(component.stringDate).toBe('05/01/2026'); + }); + + it('should format as MM/DD/YYYY', () => { + fixture.componentRef.setInput('dateFormat', 'MM/DD/YYYY'); + component.writeValue(new Date(2026, 0, 5)); + expect(component.stringDate).toBe('01/05/2026'); + }); + + it('should format as YYYY/MM/DD', () => { + fixture.componentRef.setInput('dateFormat', 'YYYY/MM/DD'); + component.writeValue(new Date(2026, 0, 5)); + expect(component.stringDate).toBe('2026/01/05'); + }); + + it('should produce an empty string for null', () => { + component.writeValue(null); + expect(component.stringDate).toBe(''); + }); + }); + + describe('Date string parsing', () => { + it('should parse a valid DD/MM/YYYY string', () => { + fixture.componentRef.setInput('dateFormat', 'DD/MM/YYYY'); + component.writeValue(null); + component.onInputValueChanged('15/06/2026'); + expect(component.value).toEqual(new Date(2026, 5, 15)); + }); + + it('should parse a valid MM/DD/YYYY string', () => { + fixture.componentRef.setInput('dateFormat', 'MM/DD/YYYY'); + component.writeValue(null); + component.onInputValueChanged('06/15/2026'); + expect(component.value).toEqual(new Date(2026, 5, 15)); + }); + + it('should parse a valid YYYY/MM/DD string', () => { + fixture.componentRef.setInput('dateFormat', 'YYYY/MM/DD'); + component.writeValue(null); + component.onInputValueChanged('2026/06/15'); + expect(component.value).toEqual(new Date(2026, 5, 15)); + }); + + it('should not update value for an invalid date string', () => { + component.writeValue(null); + component.onInputValueChanged('not-a-date'); + expect(component.value).toBeNull(); + }); + + it('should reject an impossible day (32)', () => { + fixture.componentRef.setInput('dateFormat', 'DD/MM/YYYY'); + component.writeValue(null); + component.onInputValueChanged('32/01/2026'); + expect(component.value).toBeNull(); + }); + + it('should clear the value for an empty string', () => { + component.writeValue(new Date(2026, 5, 15)); + component.onInputValueChanged(''); + expect(component.value).toBeNull(); + }); + + it('should still update stringDate even when date is invalid', () => { + component.onInputValueChanged('bad-input'); + expect(component.stringDate).toBe('bad-input'); + }); + + it('should not update value for a date before minDate', () => { + fixture.componentRef.setInput('dateFormat', 'DD/MM/YYYY'); + fixture.componentRef.setInput('minDate', new Date(2026, 0, 1)); + component.writeValue(null); + component.onInputValueChanged('31/12/2025'); + expect(component.value).toBeNull(); + }); + + it('should not update value for a date after maxDate', () => { + fixture.componentRef.setInput('dateFormat', 'DD/MM/YYYY'); + fixture.componentRef.setInput('maxDate', new Date(2026, 11, 31)); + component.writeValue(null); + component.onInputValueChanged('01/01/2027'); + expect(component.value).toBeNull(); + }); + + it('should accept a date within [minDate, maxDate]', () => { + fixture.componentRef.setInput('dateFormat', 'DD/MM/YYYY'); + fixture.componentRef.setInput('minDate', new Date(2026, 0, 1)); + fixture.componentRef.setInput('maxDate', new Date(2026, 11, 31)); + component.writeValue(null); + component.onInputValueChanged('15/06/2026'); + expect(component.value).toEqual(new Date(2026, 5, 15)); + }); + }); + + describe('_parseFormatParts', () => { + it('should warn and return null when date parts cannot be parsed as numbers', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + fixture.componentRef.setInput('dateFormat', 'MM/DD/YYYY'); + const result = (component as any)._parseFormatParts('aa/bb/cccc'); + expect(result).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith( + 'CpsDatepickerComponent: could not parse date string "aa/bb/cccc" using dateFormat "MM/DD/YYYY". Supported formats: DD/MM/YYYY, MM/DD/YYYY, YYYY/MM/DD.' + ); + }); + + it('should not warn for a valid date string', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + fixture.componentRef.setInput('dateFormat', 'MM/DD/YYYY'); + const result = (component as any)._parseFormatParts('06/15/2026'); + expect(result).toEqual({ day: 15, month: 6, year: 2026 }); + expect(warnSpy).not.toHaveBeenCalled(); + }); + }); + + describe('_checkErrors', () => { + it('should set error when stringDate is invalid', () => { + component.stringDate = '99/99/9999'; + (component as any)._checkErrors(); + expect(component.error).toBe('Date is invalid'); + }); + + it('should clear error when stringDate is empty', () => { + component.error = 'Date is invalid'; + component.stringDate = ''; + (component as any)._checkErrors(); + expect(component.error).toBe(''); + }); + + it('should clear error when stringDate is a valid date', () => { + fixture.componentRef.setInput('dateFormat', 'DD/MM/YYYY'); + component.error = 'Date is invalid'; + component.stringDate = '15/06/2026'; + (component as any)._checkErrors(); + expect(component.error).toBe(''); + }); + }); + + describe('onSelectCalendarDate', () => { + it('should set the selected date as value', () => { + const date = new Date(2026, 5, 15); + component.onSelectCalendarDate(date); + expect(component.value).toBe(date); + }); + + it('should emit valueChanged with the selected date', () => { + const spy = jest.spyOn(component.valueChanged, 'emit'); + const date = new Date(2026, 5, 15); + component.onSelectCalendarDate(date); + expect(spy).toHaveBeenCalledWith(date); + }); + + it('should call onChange with the selected date', () => { + const fn = jest.fn(); + component.registerOnChange(fn); + const date = new Date(2026, 5, 15); + component.onSelectCalendarDate(date); + expect(fn).toHaveBeenCalledWith(date); + }); + + it('should handle null (clear)', () => { + component.writeValue(new Date(2026, 5, 15)); + const spy = jest.spyOn(component.valueChanged, 'emit'); + component.onSelectCalendarDate(null); + expect(component.value).toBeNull(); + expect(spy).toHaveBeenCalledWith(null); + }); + }); + + describe('onInputBlur', () => { + it('should validate and update value when calendar is closed', () => { + fixture.componentRef.setInput('dateFormat', 'DD/MM/YYYY'); + component.writeValue(null); + component.stringDate = '15/06/2026'; + component.isOpened = false; + component.onInputBlur(); + expect(component.value).toEqual(new Date(2026, 5, 15)); + }); + + it('should set error for an invalid string on blur', () => { + component.stringDate = '99/99/9999'; + component.isOpened = false; + component.onInputBlur(); + expect(component.error).toBe('Date is invalid'); + }); + + it('should do nothing when calendar is open', () => { + component.stringDate = '99/99/9999'; + component.isOpened = true; + component.onInputBlur(); + expect(component.error).toBe(''); + }); + }); + + describe('Calendar listener management', () => { + it('should add a capture keydown listener on the container when calendar opens', () => { + const container = document.createElement('div'); + const addSpy = jest.spyOn(container, 'addEventListener'); + (component as any).calendarMenu = { container }; + component.onCalendarMenuShown(); + expect(addSpy).toHaveBeenCalledWith( + 'keydown', + expect.any(Function), + true + ); + }); + + it('should store the container reference on open', () => { + const container = document.createElement('div'); + (component as any).calendarMenu = { container }; + component.onCalendarMenuShown(); + expect((component as any)._calendarContainer).toBe(container); + }); + + it('should remove the keydown listener when calendar hides', () => { + const container = document.createElement('div'); + (component as any)._calendarContainer = container; + const removeSpy = jest.spyOn(container, 'removeEventListener'); + component.isOpened = false; + component.onBeforeCalendarHidden(CpsMenuHideReason.FORCED); + expect(removeSpy).toHaveBeenCalledWith( + 'keydown', + expect.any(Function), + true + ); + }); + + it('should clear the container reference when calendar hides', () => { + const container = document.createElement('div'); + (component as any)._calendarContainer = container; + component.isOpened = false; + component.onBeforeCalendarHidden(CpsMenuHideReason.FORCED); + expect((component as any)._calendarContainer).toBeNull(); + }); + + it('should restore focus to input when closed for non-outside reason (e.g. TOGGLE)', () => { + const focusSpy = jest.fn(); + const inputWrap = document.createElement('div'); + (component as any).datepickerInput = { + focus: focusSpy, + elementRef: { nativeElement: { querySelector: () => inputWrap } } + }; + (component as any).calendarMenu = { + hide: jest.fn(), + isVisible: jest.fn().mockReturnValue(false) + }; + component.isOpened = true; + component.onBeforeCalendarHidden(CpsMenuHideReason.TOGGLE); + expect(focusSpy).toHaveBeenCalled(); + }); + + it('should NOT restore focus to input when closed by CLICK_OUTSIDE', () => { + const focusSpy = jest.fn(); + const inputWrap = document.createElement('div'); + (component as any).datepickerInput = { + focus: focusSpy, + elementRef: { nativeElement: { querySelector: () => inputWrap } } + }; + (component as any).calendarMenu = { + hide: jest.fn(), + isVisible: jest.fn().mockReturnValue(false) + }; + component.isOpened = true; + component.onBeforeCalendarHidden(CpsMenuHideReason.CLICK_OUTSIDE); + expect(focusSpy).not.toHaveBeenCalled(); + }); + + it('should remove the keydown listener on destroy', () => { + const container = document.createElement('div'); + (component as any)._calendarContainer = container; + const removeSpy = jest.spyOn(container, 'removeEventListener'); + component.ngOnDestroy(); + expect(removeSpy).toHaveBeenCalledWith( + 'keydown', + expect.any(Function), + true + ); + }); + }); + + describe('_onCaptureKeydown: year view', () => { + let parent: HTMLDivElement; + let cells: HTMLSpanElement[]; + + beforeEach(() => { + parent = document.createElement('div'); + cells = Array.from({ length: 10 }, () => { + const span = document.createElement('span'); + span.classList.add('p-datepicker-year'); + parent.appendChild(span); + return span; + }); + }); + + function trigger(e: KeyboardEvent) { + (component as any)._onCaptureKeydown(e); + } + + it('should block ArrowDown when the cell two positions ahead is disabled', () => { + cells[2].classList.add('p-disabled'); + (component as any)._datepicker = makeDp({ + currentView: 'year', + currentYear: 2020 + }); + const event = keyEvent('ArrowDown', cells[0]); + trigger(event); + expect(event.defaultPrevented).toBe(true); + }); + + it('should not block ArrowDown when the cell two positions ahead is not disabled', () => { + (component as any)._datepicker = makeDp({ + currentView: 'year', + currentYear: 2020 + }); + const event = keyEvent('ArrowDown', cells[0]); + trigger(event); + expect(event.defaultPrevented).toBe(false); + }); + + it('should not block ArrowDown when there is no cell two positions ahead', () => { + (component as any)._datepicker = makeDp({ + currentView: 'year', + currentYear: 2020 + }); + const event = keyEvent('ArrowDown', cells[9]); + trigger(event); + expect(event.defaultPrevented).toBe(false); + }); + + it('should block ArrowUp when the cell two positions back is disabled', () => { + cells[0].classList.add('p-disabled'); + (component as any)._datepicker = makeDp({ + currentView: 'year', + currentYear: 2020 + }); + const event = keyEvent('ArrowUp', cells[2]); + trigger(event); + expect(event.defaultPrevented).toBe(true); + }); + + it('should not block ArrowUp when there is no cell two positions back', () => { + (component as any)._datepicker = makeDp({ + currentView: 'year', + currentYear: 2020 + }); + const event = keyEvent('ArrowUp', cells[0]); + trigger(event); + expect(event.defaultPrevented).toBe(false); + }); + + it('should block ArrowRight when the next sibling cell is disabled', () => { + cells[3].classList.add('p-disabled'); + (component as any)._datepicker = makeDp({ + currentView: 'year', + currentYear: 2020 + }); + const event = keyEvent('ArrowRight', cells[2]); + trigger(event); + expect(event.defaultPrevented).toBe(true); + }); + + it('should not block ArrowRight when the next sibling cell is not disabled', () => { + (component as any)._datepicker = makeDp({ + currentView: 'year', + currentYear: 2020 + }); + const event = keyEvent('ArrowRight', cells[2]); + trigger(event); + expect(event.defaultPrevented).toBe(false); + }); + + it('should block ArrowRight at page boundary when the next decade is fully disabled', () => { + (component as any)._datepicker = makeDp({ + currentView: 'year', + currentYear: 2029, + isYearDisabled: jest.fn().mockReturnValue(true) + }); + const event = keyEvent('ArrowRight', cells[9]); + trigger(event); + expect(event.defaultPrevented).toBe(true); + }); + + it('should not block ArrowRight at page boundary when the next decade has enabled years', () => { + (component as any)._datepicker = makeDp({ + currentView: 'year', + currentYear: 2029, + isYearDisabled: jest.fn().mockReturnValue(false) + }); + const event = keyEvent('ArrowRight', cells[9]); + trigger(event); + expect(event.defaultPrevented).toBe(false); + }); + + it('should block ArrowLeft when the previous sibling cell is disabled', () => { + cells[1].classList.add('p-disabled'); + (component as any)._datepicker = makeDp({ + currentView: 'year', + currentYear: 2020 + }); + const event = keyEvent('ArrowLeft', cells[2]); + trigger(event); + expect(event.defaultPrevented).toBe(true); + }); + + it('should block ArrowLeft at page boundary when the previous decade is fully disabled', () => { + (component as any)._datepicker = makeDp({ + currentView: 'year', + currentYear: 2020, + isYearDisabled: jest.fn().mockReturnValue(true) + }); + const event = keyEvent('ArrowLeft', cells[0]); + trigger(event); + expect(event.defaultPrevented).toBe(true); + }); + + it('should not block ArrowLeft at page boundary when the previous decade has enabled years', () => { + (component as any)._datepicker = makeDp({ + currentView: 'year', + currentYear: 2020, + isYearDisabled: jest.fn().mockReturnValue(false) + }); + const event = keyEvent('ArrowLeft', cells[0]); + trigger(event); + expect(event.defaultPrevented).toBe(false); + }); + + it('should ignore non-year-cell elements in year view', () => { + const other = document.createElement('div'); + parent.appendChild(other); + (component as any)._datepicker = makeDp({ + currentView: 'year', + isYearDisabled: jest.fn().mockReturnValue(true) + }); + const event = keyEvent('ArrowRight', other); + trigger(event); + expect(event.defaultPrevented).toBe(false); + }); + }); + + describe('_onCaptureKeydown: month view', () => { + let parent: HTMLDivElement; + let cells: HTMLSpanElement[]; + + beforeEach(() => { + parent = document.createElement('div'); + cells = Array.from({ length: 12 }, () => { + const span = document.createElement('span'); + span.classList.add('p-datepicker-month'); + parent.appendChild(span); + return span; + }); + }); + + function trigger(e: KeyboardEvent) { + (component as any)._onCaptureKeydown(e); + } + + it('should block ArrowDown when the cell three positions ahead is disabled', () => { + cells[3].classList.add('p-disabled'); + (component as any)._datepicker = makeDp({ + currentView: 'month', + currentYear: 2026 + }); + const event = keyEvent('ArrowDown', cells[0]); + trigger(event); + expect(event.defaultPrevented).toBe(true); + }); + + it('should not block ArrowDown when the cell three positions ahead is not disabled', () => { + (component as any)._datepicker = makeDp({ + currentView: 'month', + currentYear: 2026 + }); + const event = keyEvent('ArrowDown', cells[0]); + trigger(event); + expect(event.defaultPrevented).toBe(false); + }); + + it('should block ArrowUp when the cell three positions back is disabled', () => { + cells[0].classList.add('p-disabled'); + (component as any)._datepicker = makeDp({ + currentView: 'month', + currentYear: 2026 + }); + const event = keyEvent('ArrowUp', cells[3]); + trigger(event); + expect(event.defaultPrevented).toBe(true); + }); + + it('should block ArrowRight when the next sibling cell is disabled', () => { + cells[5].classList.add('p-disabled'); + (component as any)._datepicker = makeDp({ + currentView: 'month', + currentYear: 2026 + }); + const event = keyEvent('ArrowRight', cells[4]); + trigger(event); + expect(event.defaultPrevented).toBe(true); + }); + + it('should block ArrowRight at page boundary when the next year is disabled', () => { + (component as any)._datepicker = makeDp({ + currentView: 'month', + currentYear: 2026, + isYearDisabled: jest.fn().mockReturnValue(true) + }); + const event = keyEvent('ArrowRight', cells[11]); + trigger(event); + expect(event.defaultPrevented).toBe(true); + }); + + it('should not block ArrowRight at page boundary when the next year is enabled', () => { + (component as any)._datepicker = makeDp({ + currentView: 'month', + currentYear: 2026, + isYearDisabled: jest.fn().mockReturnValue(false) + }); + const event = keyEvent('ArrowRight', cells[11]); + trigger(event); + expect(event.defaultPrevented).toBe(false); + }); + + it('should block ArrowLeft at page boundary when the previous year is disabled', () => { + (component as any)._datepicker = makeDp({ + currentView: 'month', + currentYear: 2026, + isYearDisabled: jest.fn().mockReturnValue(true) + }); + const event = keyEvent('ArrowLeft', cells[0]); + trigger(event); + expect(event.defaultPrevented).toBe(true); + }); + + it('should not block ArrowLeft at page boundary when the previous year is enabled', () => { + (component as any)._datepicker = makeDp({ + currentView: 'month', + currentYear: 2026, + isYearDisabled: jest.fn().mockReturnValue(false) + }); + const event = keyEvent('ArrowLeft', cells[0]); + trigger(event); + expect(event.defaultPrevented).toBe(false); + }); + + it('should ignore non-month-cell elements in month view', () => { + const other = document.createElement('div'); + parent.appendChild(other); + (component as any)._datepicker = makeDp({ + currentView: 'month', + isYearDisabled: jest.fn().mockReturnValue(true) + }); + const event = keyEvent('ArrowRight', other); + trigger(event); + expect(event.defaultPrevented).toBe(false); + }); + }); + + describe('_onCaptureKeydown: date view', () => { + function trigger(e: KeyboardEvent) { + (component as any)._onCaptureKeydown(e); + } + + it('should block ArrowRight at row end when the next month is disabled', () => { + const { rowEls } = makeDateGrid(4, 7); + const span = (rowEls[0].children[6] as HTMLElement) + .children[0] as HTMLElement; + (component as any)._datepicker = makeDp({ + currentView: 'date', + currentMonth: 11, + currentYear: 2026, + isMonthDisabled: jest.fn().mockReturnValue(true) + }); + const event = keyEvent('ArrowRight', span); + trigger(event); + expect(event.defaultPrevented).toBe(true); + }); + + it('should not block ArrowRight at row end when the next month is enabled', () => { + const { rowEls } = makeDateGrid(4, 7); + const span = (rowEls[0].children[6] as HTMLElement) + .children[0] as HTMLElement; + (component as any)._datepicker = makeDp({ + currentView: 'date', + currentMonth: 5, + currentYear: 2026, + isMonthDisabled: jest.fn().mockReturnValue(false) + }); + const event = keyEvent('ArrowRight', span); + trigger(event); + expect(event.defaultPrevented).toBe(false); + }); + + it('should block ArrowRight when next sibling span is disabled and next month is disabled', () => { + const { rowEls } = makeDateGrid(4, 7, [{ row: 0, col: 3 }]); + const span = (rowEls[0].children[2] as HTMLElement) + .children[0] as HTMLElement; + (component as any)._datepicker = makeDp({ + currentView: 'date', + currentMonth: 5, + currentYear: 2026, + isMonthDisabled: jest.fn().mockReturnValue(true) + }); + const event = keyEvent('ArrowRight', span); + trigger(event); + expect(event.defaultPrevented).toBe(true); + }); + + it('should not block ArrowRight when next sibling span is not disabled', () => { + const { rowEls } = makeDateGrid(4, 7); + const span = (rowEls[0].children[2] as HTMLElement) + .children[0] as HTMLElement; + (component as any)._datepicker = makeDp({ + currentView: 'date', + currentMonth: 5, + currentYear: 2026 + }); + const event = keyEvent('ArrowRight', span); + trigger(event); + expect(event.defaultPrevented).toBe(false); + }); + + it('should block ArrowLeft at row start when the previous month is disabled', () => { + const { rowEls } = makeDateGrid(4, 7); + const span = (rowEls[0].children[0] as HTMLElement) + .children[0] as HTMLElement; + (component as any)._datepicker = makeDp({ + currentView: 'date', + currentMonth: 0, + currentYear: 2026, + isMonthDisabled: jest.fn().mockReturnValue(true) + }); + const event = keyEvent('ArrowLeft', span); + trigger(event); + expect(event.defaultPrevented).toBe(true); + }); + + it('should check month 11 of prev year when moving left from January', () => { + const { rowEls } = makeDateGrid(4, 7); + const span = (rowEls[0].children[0] as HTMLElement) + .children[0] as HTMLElement; + const dp = makeDp({ + currentView: 'date', + currentMonth: 0, + currentYear: 2026, + isMonthDisabled: jest.fn().mockReturnValue(false) + }); + (component as any)._datepicker = dp; + const event = keyEvent('ArrowLeft', span); + trigger(event); + expect(dp.isMonthDisabled).toHaveBeenCalledWith(11, 2025); + }); + + it('should block ArrowDown at last row when the next month is disabled', () => { + const { rowEls } = makeDateGrid(4, 7); + const span = (rowEls[3].children[3] as HTMLElement) + .children[0] as HTMLElement; + (component as any)._datepicker = makeDp({ + currentView: 'date', + currentMonth: 5, + currentYear: 2026, + isMonthDisabled: jest.fn().mockReturnValue(true) + }); + const event = keyEvent('ArrowDown', span); + trigger(event); + expect(event.defaultPrevented).toBe(true); + }); + + it('should block ArrowDown when adjacent row cell is disabled and next month is disabled', () => { + const { rowEls } = makeDateGrid(4, 7, [{ row: 2, col: 3 }]); + const span = (rowEls[1].children[3] as HTMLElement) + .children[0] as HTMLElement; + (component as any)._datepicker = makeDp({ + currentView: 'date', + currentMonth: 5, + currentYear: 2026, + isMonthDisabled: jest.fn().mockReturnValue(true) + }); + const event = keyEvent('ArrowDown', span); + trigger(event); + expect(event.defaultPrevented).toBe(true); + }); + + it('should not block ArrowDown when adjacent row cell is not disabled', () => { + const { rowEls } = makeDateGrid(4, 7); + const span = (rowEls[1].children[3] as HTMLElement) + .children[0] as HTMLElement; + (component as any)._datepicker = makeDp({ + currentView: 'date', + currentMonth: 5, + currentYear: 2026 + }); + const event = keyEvent('ArrowDown', span); + trigger(event); + expect(event.defaultPrevented).toBe(false); + }); + + it('should block ArrowUp at first row when the previous month is disabled', () => { + const { rowEls } = makeDateGrid(4, 7); + const span = (rowEls[0].children[3] as HTMLElement) + .children[0] as HTMLElement; + (component as any)._datepicker = makeDp({ + currentView: 'date', + currentMonth: 5, + currentYear: 2026, + isMonthDisabled: jest.fn().mockReturnValue(true) + }); + const event = keyEvent('ArrowUp', span); + trigger(event); + expect(event.defaultPrevented).toBe(true); + }); + + it('should check month 0 of next year when moving right from December', () => { + const { rowEls } = makeDateGrid(4, 7); + const span = (rowEls[0].children[6] as HTMLElement) + .children[0] as HTMLElement; + const dp = makeDp({ + currentView: 'date', + currentMonth: 11, + currentYear: 2026, + isMonthDisabled: jest.fn().mockReturnValue(false) + }); + (component as any)._datepicker = dp; + const event = keyEvent('ArrowRight', span); + trigger(event); + expect(dp.isMonthDisabled).toHaveBeenCalledWith(0, 2027); + }); + + it('should ignore elements without a data-date attribute', () => { + const div = document.createElement('div'); + (component as any)._datepicker = makeDp({ + currentView: 'date', + isMonthDisabled: jest.fn().mockReturnValue(true) + }); + const event = keyEvent('ArrowRight', div); + trigger(event); + expect(event.defaultPrevented).toBe(false); + }); + + it('should ignore non-arrow keys', () => { + const { rowEls } = makeDateGrid(4, 7); + const span = (rowEls[0].children[6] as HTMLElement) + .children[0] as HTMLElement; + (component as any)._datepicker = makeDp({ + currentView: 'date', + isMonthDisabled: jest.fn().mockReturnValue(true) + }); + const event = keyEvent('Enter', span); + trigger(event); + expect(event.defaultPrevented).toBe(false); + }); + }); + + describe('onDatepickerMonthChange', () => { + it('should focus the prev-month button when nav was backward via button', fakeAsync(() => { + const prevBtn = document.createElement('button'); + prevBtn.classList.add('p-datepicker-prev-button'); + const container = document.createElement('div'); + container.appendChild(prevBtn); + const focusSpy = jest.spyOn(prevBtn, 'focus'); + (component as any)._datepicker = makeDp({ + navigationState: { backward: true, button: true } + }); + (component as any).calendarMenu = { container }; + component.onDatepickerMonthChange(); + tick(); + expect(focusSpy).toHaveBeenCalled(); + })); + + it('should focus the next-month button when nav was forward via button', fakeAsync(() => { + const nextBtn = document.createElement('button'); + nextBtn.classList.add('p-datepicker-next-button'); + const container = document.createElement('div'); + container.appendChild(nextBtn); + const focusSpy = jest.spyOn(nextBtn, 'focus'); + (component as any)._datepicker = makeDp({ + navigationState: { backward: false, button: true } + }); + (component as any).calendarMenu = { container }; + component.onDatepickerMonthChange(); + tick(); + expect(focusSpy).toHaveBeenCalled(); + })); + + it('should focus the first available day cell for forward keyboard navigation', fakeAsync(() => { + const span = document.createElement('span'); + span.setAttribute('data-date', '2026-6-1'); + const td = document.createElement('td'); + td.appendChild(span); + const table = document.createElement('table'); + table.classList.add('p-datepicker-calendar'); + const tbody = document.createElement('tbody'); + const tr = document.createElement('tr'); + tr.appendChild(td); + tbody.appendChild(tr); + table.appendChild(tbody); + const container = document.createElement('div'); + container.appendChild(table); + const focusSpy = jest.spyOn(span, 'focus'); + (component as any)._datepicker = makeDp({ + navigationState: { backward: false } + }); + (component as any).calendarMenu = { container }; + component.onDatepickerMonthChange(); + tick(); + expect(focusSpy).toHaveBeenCalled(); + })); + + it('should focus the last available day cell for backward keyboard navigation', fakeAsync(() => { + const span1 = document.createElement('span'); + span1.setAttribute('data-date', '2026-5-1'); + const span2 = document.createElement('span'); + span2.setAttribute('data-date', '2026-5-31'); + const td1 = document.createElement('td'); + td1.appendChild(span1); + const td2 = document.createElement('td'); + td2.appendChild(span2); + const table = document.createElement('table'); + table.classList.add('p-datepicker-calendar'); + const tbody = document.createElement('tbody'); + const tr = document.createElement('tr'); + tr.appendChild(td1); + tr.appendChild(td2); + tbody.appendChild(tr); + table.appendChild(tbody); + const container = document.createElement('div'); + container.appendChild(table); + const focusSpy = jest.spyOn(span2, 'focus'); + (component as any)._datepicker = makeDp({ + navigationState: { backward: true } + }); + (component as any).calendarMenu = { container }; + component.onDatepickerMonthChange(); + tick(); + expect(focusSpy).toHaveBeenCalled(); + })); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/components/cps-datepicker/cps-datepicker.component.ts b/projects/cps-ui-kit/src/lib/components/cps-datepicker/cps-datepicker.component.ts index 98e7537c6..4b787359f 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-datepicker/cps-datepicker.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-datepicker/cps-datepicker.component.ts @@ -9,6 +9,7 @@ import { Optional, Output, Self, + type SimpleChanges, ViewChild } from '@angular/core'; import { ControlValueAccessor, FormsModule, NgControl } from '@angular/forms'; @@ -16,9 +17,15 @@ import { CpsInputComponent } from '../cps-input/cps-input.component'; import { Subscription } from 'rxjs'; import { convertSize } from '../../utils/internal/size-utils'; import { CpsTooltipPosition } from '../../directives/cps-tooltip/cps-tooltip.directive'; -import { CpsMenuComponent } from '../cps-menu/cps-menu.component'; -import { DatePickerModule } from 'primeng/datepicker'; -import { logMissingAriaLabelError } from '../../utils/internal/accessibility-utils'; +import { + CpsMenuComponent, + CpsMenuHideReason +} from '../cps-menu/cps-menu.component'; +import { DatePicker, DatePickerModule } from 'primeng/datepicker'; +import { + logMissingAriaLabelError, + generateUniqueId +} from '../../utils/internal/accessibility-utils'; /** * CpsDatepickerAppearanceType is used to define the border of the datepicker input. @@ -29,6 +36,15 @@ export type CpsDatepickerAppearanceType = | 'underlined' | 'borderless'; +/** + * CpsDatepickerDateFormat defines the display and input format of the date string. + * @group Types + */ +export type CpsDatepickerDateFormat = + | 'DD/MM/YYYY' + | 'MM/DD/YYYY' + | 'YYYY/MM/DD'; + /** * CpsDatepickerComponent is an input component to provide date input. * @group Components @@ -73,10 +89,16 @@ export class CpsDatepickerComponent @Input() width: number | string = '100%'; /** - * Placeholder text. + * Date format for displaying and parsing the date string. + * @group Props + */ + @Input() dateFormat: CpsDatepickerDateFormat = 'MM/DD/YYYY'; + + /** + * Placeholder text. Defaults to the configured dateFormat. * @group Props */ - @Input() placeholder = 'MM/DD/YYYY'; + @Input() placeholder = ''; /** * Bottom hint text for the input field. @@ -154,15 +176,13 @@ export class CpsDatepickerComponent * Minimal date availalbe for selection. * @group Props */ - @Input() - minDate!: Date; + @Input() minDate: Date | undefined; /** * Maximal date availalbe for selection. * @group Props */ - @Input() - maxDate!: Date; + @Input() maxDate: Date | undefined; /** * Value of the datepicker. @@ -192,6 +212,11 @@ export class CpsDatepickerComponent @ViewChild('calendarMenu') calendarMenu!: CpsMenuComponent; + @ViewChild(DatePicker) + private _datepicker!: DatePicker; + + readonly calendarId = generateUniqueId('cps-datepicker-calendar'); + stringDate = ''; isOpened = false; error = ''; @@ -199,6 +224,10 @@ export class CpsDatepickerComponent private _statusChangesSubscription?: Subscription; private _value: Date | null = null; + private _focusCalendarOnOpen = false; + private _suppressNextContentClick = false; + private _captureKeydown = (e: KeyboardEvent) => this._onCaptureKeydown(e); + private _calendarContainer: HTMLElement | null = null; constructor(@Self() @Optional() private _control: NgControl) { if (this._control) { @@ -222,7 +251,15 @@ export class CpsDatepickerComponent ); } - ngOnChanges() { + ngOnChanges(changes: SimpleChanges): void { + if (changes.width) { + this.cvtWidth = convertSize(this.width); + } + + if (changes.dateFormat && !changes.dateFormat.isFirstChange()) { + this.stringDate = this._dateToString(this._value); + } + logMissingAriaLabelError( 'CpsDatepickerComponent', this.label, @@ -232,6 +269,103 @@ export class CpsDatepickerComponent ngOnDestroy() { this._statusChangesSubscription?.unsubscribe(); + this._removeCalendarListeners(); + } + + private _removeCalendarListeners(): void { + this._calendarContainer?.removeEventListener( + 'keydown', + this._captureKeydown, + true + ); + } + + // PrimeNG's own keyboard handler does not check whether the destination cell + // is disabled before moving focus. This capture-phase listener intercepts + // arrow keys and swallows them when: + // - the target cell in the month/year grid is disabled, or + // - the keypress would cross a page boundary (next/prev decade, year, or + // month) where every cell on the adjacent page is also disabled. + private _onCaptureKeydown(event: KeyboardEvent): void { + const { key } = event; + const isLeft = key === 'ArrowLeft'; + const isUp = key === 'ArrowUp'; + const isHorizontal = isLeft || key === 'ArrowRight'; + const isVertical = isUp || key === 'ArrowDown'; + if (!isHorizontal && !isVertical) return; + + const view = this._datepicker?.currentView; + const target = event.target as HTMLElement; + const dp = this._datepicker; + const isBackward = isLeft || isUp; + let blocked = false; + + if (view === 'month' || view === 'year') { + const cellClass = + view === 'year' ? 'p-datepicker-year' : 'p-datepicker-month'; + if (!target.classList.contains(cellClass)) return; + + if (isVertical) { + const cells = Array.from(target.parentElement?.children ?? []); + const step = view === 'year' ? 2 : 3; + const dest = cells[cells.indexOf(target) + (isUp ? -step : step)] as + | HTMLElement + | undefined; + blocked = !!dest?.classList.contains('p-disabled'); + } else { + const sibling = ( + isLeft ? target.previousElementSibling : target.nextElementSibling + ) as HTMLElement | null; + if (sibling) { + blocked = sibling.classList.contains('p-disabled'); + } else if (view === 'year') { + const base = dp.currentYear - (dp.currentYear % 10); + const start = isBackward ? base - 10 : base + 10; + blocked = Array.from({ length: 10 }, (_, i) => start + i).every( + (y): boolean => dp.isYearDisabled(y) + ); + } else { + blocked = dp.isYearDisabled( + isBackward ? dp.currentYear - 1 : dp.currentYear + 1 + ); + } + } + } else if (view === 'date') { + if (!target.hasAttribute('data-date')) return; + const cell = target.parentElement; + let crossMonth: boolean; + if (isHorizontal) { + const sibling = isLeft + ? cell?.previousElementSibling + : cell?.nextElementSibling; + crossMonth = + !sibling || !!sibling.children[0]?.classList.contains('p-disabled'); + } else { + const cellIndex = Array.from( + cell?.parentElement?.children ?? [] + ).indexOf(cell as Element); + const adjRow = isUp + ? cell?.parentElement?.previousElementSibling + : cell?.parentElement?.nextElementSibling; + crossMonth = + !adjRow || + !!adjRow.children[cellIndex]?.children[0]?.classList.contains( + 'p-disabled' + ); + } + if (!crossMonth) return; + const adj = new Date( + dp.currentYear, + (dp.currentMonth as number) + (isBackward ? -1 : 1), + 1 + ); + blocked = dp.isMonthDisabled(adj.getMonth(), adj.getFullYear()); + } + + if (blocked) { + event.stopImmediatePropagation(); + event.preventDefault(); + } } // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -277,50 +411,75 @@ export class CpsDatepickerComponent } } - private _checkDateFormat(dateString: string): boolean { - if (!/^\d\d\/\d\d\/\d\d\d\d$/.test(dateString)) { - return false; - } + private _parseFormatParts( + dateString: string + ): { day: number; month: number; year: number } | null { const parts = dateString.split('/').map((p) => parseInt(p, 10)); - parts[0] -= 1; - const d = new Date(parts[2], parts[0], parts[1]); + if (parts.some(isNaN)) { + console.warn( + `CpsDatepickerComponent: could not parse date string "${dateString}" using dateFormat "${this.dateFormat}". Supported formats: DD/MM/YYYY, MM/DD/YYYY, YYYY/MM/DD.` + ); + return null; + } + switch (this.dateFormat) { + case 'DD/MM/YYYY': + return { day: parts[0], month: parts[1], year: parts[2] }; + case 'MM/DD/YYYY': + return { day: parts[1], month: parts[0], year: parts[2] }; + case 'YYYY/MM/DD': + return { day: parts[2], month: parts[1], year: parts[0] }; + } + } + + private _checkDateFormat(dateString: string): boolean { + const regex = + this.dateFormat === 'YYYY/MM/DD' + ? /^\d{4}\/\d{2}\/\d{2}$/ + : /^\d{2}\/\d{2}\/\d{4}$/; + if (!regex.test(dateString)) return false; + const parsed = this._parseFormatParts(dateString); + if (!parsed) return false; + const { day, month, year } = parsed; + const d = new Date(year, month - 1, day); return ( - d.getMonth() === parts[0] && - d.getDate() === parts[1] && - d.getFullYear() === parts[2] + d.getMonth() === month - 1 && + d.getDate() === day && + d.getFullYear() === year ); } - private _checkDateInRange(date: Date, minDate: Date, maxDate: Date): boolean { - if (!minDate && !maxDate) return true; - if (minDate && maxDate) { - return ( - date.getTime() >= minDate.getTime() && - date.getTime() <= maxDate.getTime() - ); - } - if (minDate) { - return date.getTime() >= minDate.getTime(); - } - return date.getTime() <= maxDate.getTime(); + private _checkDateInRange( + date: Date, + minDate?: Date, + maxDate?: Date + ): boolean { + if (minDate && date.getTime() < minDate.getTime()) return false; + if (maxDate && date.getTime() > maxDate.getTime()) return false; + return true; } private _dateToString(dateVal: Date | null): string { if (!dateVal) return ''; - let month = '' + (dateVal.getMonth() + 1); - if (month.length < 2) month = '0' + month; - let day = '' + dateVal.getDate(); - if (day.length < 2) day = '0' + day; - const year = dateVal.getFullYear(); - return `${month}/${day}/${year}`; + const mm = String(dateVal.getMonth() + 1).padStart(2, '0'); + const dd = String(dateVal.getDate()).padStart(2, '0'); + const yyyy = String(dateVal.getFullYear()); + switch (this.dateFormat) { + case 'DD/MM/YYYY': + return `${dd}/${mm}/${yyyy}`; + case 'MM/DD/YYYY': + return `${mm}/${dd}/${yyyy}`; + case 'YYYY/MM/DD': + return `${yyyy}/${mm}/${dd}`; + } } private _stringToDate(dateString: string): Date | null { if (!this._checkDateFormat(dateString)) return null; - const [month, day, year] = dateString.split('/'); - const dt = new Date(`${year}-${month}-${day}`); - const inRange = this._checkDateInRange(dt, this.minDate, this.maxDate); - return inRange ? dt : null; + const parsed = this._parseFormatParts(dateString); + if (!parsed) return null; + const { day, month, year } = parsed; + const dt = new Date(year, month - 1, day); + return this._checkDateInRange(dt, this.minDate, this.maxDate) ? dt : null; } private _checkErrors() { @@ -350,10 +509,6 @@ export class CpsDatepickerComponent this.error = message || 'Unknown error'; } - onClearCalendarDate() { - this.onSelectCalendarDate(null); - } - onSelectCalendarDate(dateVal: Date | null) { this.toggleCalendar(false); this.writeValue(dateVal); @@ -372,6 +527,16 @@ export class CpsDatepickerComponent if (this.openOnInputFocus) this.toggleCalendar(true); } + onInputKeydown(event: KeyboardEvent) { + if (!this.isOpened) return; + if (event.key === 'ArrowDown') { + event.preventDefault(); + this._focusActiveCalendarCell(); + } else if (event.key === 'Tab') { + this.toggleCalendar(false); + } + } + onInputEnterClicked() { if (!this.isOpened) return; this._control?.control?.markAsTouched(); @@ -383,13 +548,142 @@ export class CpsDatepickerComponent onClickCalendarIcon() { if (this.disabled) return; if (this.isOpened) this._updateValueFromInputString(); + else this._focusCalendarOnOpen = true; this.toggleCalendar(); } - onBeforeCalendarHidden() { + onCalendarClick(_event: MouseEvent): void { + // Handles view switches triggered by the Month/Year title buttons in the + // header (PrimeNG does not call initFocusableCell() after setCurrentView()). + // Year-cell clicks are also covered by onYearSelected(), but calling + // _initFocusableViewCell twice is harmless since it is idempotent. + setTimeout(() => { + const view = this._datepicker?.currentView; + if (view !== 'month' && view !== 'year') return; + this._suppressNextContentClick = true; + this._initFocusableViewCell(true); + }); + } + + onYearSelected(): void { + // Fired by PrimeNG when a year cell is clicked and the view switches to + // 'month'. At this point currentView is already 'month' but Zone's tick() + // may not have run yet. Use setTimeout so we run after Angular re-renders. + this._suppressNextContentClick = true; + setTimeout(() => this._initFocusableViewCell(true)); + } + + private _initFocusableViewCell(focus = false): void { + const view = this._datepicker?.currentView; + if (view !== 'year' && view !== 'month') return; + // Delegate to PrimeNG's own initFocusableCell which uses contentViewChild + // (the correct root element). Suppress its built-in focus unless requested; + // PrimeNG resets preventFocus = false at the end of that method. + if (!focus) this._datepicker.preventFocus = true; + this._datepicker.initFocusableCell(); + } + + onCalendarMenuShown() { + this._calendarContainer = this.calendarMenu.container ?? null; + this._calendarContainer?.addEventListener( + 'keydown', + this._captureKeydown, + true + ); + if (!this._focusCalendarOnOpen) return; + this._focusCalendarOnOpen = false; + // setTimeout fires after requestAnimationFrame, ensuring p-motion has + // already removed its initial display:none from the calendar panel. + setTimeout(() => this._focusActiveCalendarCell()); + } + + private _focusActiveCalendarCell(): void { + const container = this.calendarMenu.container; + if (!container) return; + + const view = this._datepicker?.currentView; + if (view === 'month' || view === 'year') { + this._initFocusableViewCell(true); + return; + } + + // Build a [data-date] key for the selected date using PrimeNG's format + // (YYYY-M-D, no zero-padding) to query directly rather than relying on the + // p-datepicker-day-selected class, which may not be rendered yet due to + // PrimeNG's OnPush change detection cycle. + let cell: HTMLElement | null = null; + if (this.value) { + const key = `${this.value.getFullYear()}-${this.value.getMonth()}-${this.value.getDate()}`; + cell = container.querySelector( + `span[data-date="${key}"]:not(.p-disabled)` + ); + } + + // Fallback: today's cell, then first available cell + cell ??= + container.querySelector( + 'td.p-datepicker-today span:not(.p-disabled):not(.p-ink)' + ) ?? + container.querySelector( + '.p-datepicker-calendar td span:not(.p-disabled):not(.p-ink)' + ); + + if (cell) { + cell.tabIndex = 0; + cell.focus({ preventScroll: true }); + } + } + + onDatepickerMonthChange(): void { + const navState = this._datepicker?.navigationState; + const focusKey = this._datepicker?._focusKey ?? null; + + // Wait for Angular to re-render the new month's *ngFor cells, then focus. + setTimeout(() => { + const container = this.calendarMenu.container; + if (!container) return; + + // Prevent PrimeNG from double-handling focus since we do it here. + if (this._datepicker) { + this._datepicker.navigationState = null; + this._datepicker._focusKey = null; + } + + if (navState?.button) { + // Nav button was activated - return focus to prev/next month button. + const btnClass = navState.backward + ? '.p-datepicker-prev-button' + : '.p-datepicker-next-button'; + container.querySelector(btnClass)?.focus(); + } else { + // Keyboard boundary navigation — focus the appropriate day cell. + let cell: HTMLElement | null = null; + if (focusKey) { + cell = container.querySelector(focusKey); + } else if (navState?.backward) { + const cells = container.querySelectorAll( + '.p-datepicker-calendar td span:not(.p-disabled):not(.p-ink)' + ); + cell = cells[cells.length - 1] ?? null; + } else { + cell = container.querySelector( + '.p-datepicker-calendar td span:not(.p-disabled):not(.p-ink)' + ); + } + if (cell) { + cell.tabIndex = 0; + cell.focus({ preventScroll: true }); + } + } + }); + } + + onBeforeCalendarHidden(reason: CpsMenuHideReason) { + this._removeCalendarListeners(); + this._calendarContainer = null; if (this.disabled || !this.isOpened) return; this._updateValueFromInputString(); - this.toggleCalendar(false); + this.toggleCalendar(false, reason !== CpsMenuHideReason.CLICK_OUTSIDE); } onInputClear() { @@ -397,6 +691,10 @@ export class CpsDatepickerComponent } onCalendarContentClick() { + if (this._suppressNextContentClick) { + this._suppressNextContentClick = false; + return; + } if (this.isOpened) this.focusInput(); } @@ -404,7 +702,7 @@ export class CpsDatepickerComponent this.datepickerInput.focus(); } - toggleCalendar(show?: boolean) { + toggleCalendar(show?: boolean, needFocusInput = true) { if (this.disabled || this.isOpened === show) return; const target = @@ -431,8 +729,7 @@ export class CpsDatepickerComponent if (!this.isOpened) { this._control?.control?.markAsTouched(); this._checkErrors(); - } else { - this.focusInput(); + if (needFocusInput) this.focusInput(); } } } diff --git a/projects/cps-ui-kit/src/lib/components/cps-input/cps-input.component.html b/projects/cps-ui-kit/src/lib/components/cps-input/cps-input.component.html index 66a386ba8..2e083ea00 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-input/cps-input.component.html +++ b/projects/cps-ui-kit/src/lib/components/cps-input/cps-input.component.html @@ -65,7 +65,11 @@ @if (!valueToDisplay) {