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) {