diff --git a/elements/package.json b/elements/package.json index 9685158d59..0a21d1639c 100644 --- a/elements/package.json +++ b/elements/package.json @@ -33,6 +33,7 @@ "./pf-clipboard-copy/pf-clipboard-copy.js": "./pf-clipboard-copy/pf-clipboard-copy.js", "./pf-code-block/BaseCodeBlock.js": "./pf-code-block/BaseCodeBlock.js", "./pf-code-block/pf-code-block.js": "./pf-code-block/pf-code-block.js", + "./pf-date-picker/pf-date-picker.js": "./pf-date-picker/pf-date-picker.js", "./pf-dropdown/pf-dropdown.js": "./pf-dropdown/pf-dropdown.js", "./pf-dropdown/pf-dropdown-group.js": "./pf-dropdown/pf-dropdown-group.ts", "./pf-dropdown/pf-dropdown-menu.js": "./pf-dropdown/pf-dropdown-menu.ts", diff --git a/elements/pf-date-picker/README.md b/elements/pf-date-picker/README.md new file mode 100644 index 0000000000..cb75687097 --- /dev/null +++ b/elements/pf-date-picker/README.md @@ -0,0 +1,80 @@ +# Date Picker + +The Date Picker component lets users choose dates within a set range defined by both a minimum and maximum date. This feature makes it easy for users to select dates that fall within the specified limits + +## Usage + +The date-picker component includes an input field for entering dates, a toggle for opening and closing the calendar, and a popup calendar interface for selecting dates. + +```html +
+

Date Format

+
<pf-date-picker date-format-input="YYYY-DD-MM"></pf-date-picker>
+ +

The date picker supports the following 12 date formats:

+

'DD/MM/YYYY', 'MM/DD/YYYY', 'YYYY/MM/DD', 'YYYY/DD/MM', 'DD-MM-YYYY', 'MM-DD-YYYY', 'YYYY-MM-DD', + 'YYYY-DD-MM', 'DD.MM.YYYY', 'MM.DD.YYYY', 'YYYY.MM.DD', 'YYYY.DD.MM'

+

The date format is set globally, independent of the user's locale. + This means that all users will see the same date format, regardless of their language or region.

+
+ +
+

Set Input Date

+
<pf-date-picker input-date=${new Date(2023, 0, 1)}></pf-date-picker>
+ + +

The default date value can be passed to the date picker to set the initial date that is displayed.

+

In the above given example, the date set to be displayed initially is January 1, 2023.

+
+ +
+

Disabled

+
<pf-date-picker disabled="true"></pf-date-picker>
+ +

The disabled attribute can be used to disable the date picker.

+
+ +
+

Localization

+
<pf-date-picker localization-language-code="fi"></pf-date-picker>
+ +

The locale string can be passed to the date picker to set the date format.

+

In the above given example, the locale is set to Finnish: fi

+

The date format is set globally, independent of the user's locale. + This means that all users will see the same date format, regardless of their language or region.

+
+ +
+

Set minimum and maximum date range

+
<pf-date-picker min-date=${new Date(2023, 0, 2)} max-date=${new Date(2023, 0, 20)} 
+      inputDate=${new Date(2023, 0, 3)}></pf-date-picker>
+ + + +

The minimum and maximum valid dates can be passed to the date picker to restrict the range of dates that can be selected

+

In the above given example, the minimum valid date + is January 2, 2023, and the maximum valid date is January 20, 2023

+
+ +
+

Basic

+
<pf-date-picker></pf-date-picker>
+ +

The basic date picker will use the user's locale to determine the date format. The default minimum valid date + is January 1, 1900, and the default maximum valid date is December 31, 9999.

+
+ +
+

Translation

+
<pf-date-picker translation-language-code="fr"></pf-date-picker>
+ +

The translation language string can be passed to the date picker to localize the month names.

+

In the above given example, the language is set to French: fr

+
+``` + diff --git a/elements/pf-date-picker/date-picker-helper.ts b/elements/pf-date-picker/date-picker-helper.ts new file mode 100644 index 0000000000..d92eab3cdf --- /dev/null +++ b/elements/pf-date-picker/date-picker-helper.ts @@ -0,0 +1,344 @@ +export interface DateFormatDetails { + dateParts: string[]; + literal: string | undefined; +} + +export interface InputDate { + day: number; + month: number; + year: number; + literal: string; +} + +export const days: string[] = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; +export const defaultWeekdays: number[] = [0, 1, 2, 3, 4, 5, 6]; // S, M, T, W, T, F, S +export const defaultWeeks: number[] = [0, 1, 2, 3, 4, 5]; // 1 previous month week, 4 current month weeks, 1 next month week + +// Function to return date object +export const getFormattedDate = (date: Date) => { + const focusDate = { + day: date.getDate(), + month: date.getMonth(), + year: date.getFullYear() + }; + + return focusDate; +}; + + +// Function to get the date format locale parts +export const getLocaleParts = (language?: string) => { + // Get the browser user locale - Commented for reference + // const userLocale: string = navigator.languages && navigator.languages.length ? + // navigator.languages[0] : navigator.language; + + const { timeZone } = Intl.DateTimeFormat().resolvedOptions(); + let formatter: Intl.DateTimeFormatPart[] = []; + + // Set date format options + const options: Intl.DateTimeFormatOptions = { + timeZone: timeZone, + dateStyle: 'short', + localeMatcher: 'lookup' + }; + + // If there is locale passed from parent, pass locale else use "default" + const locale: string = language ? language : 'default'; + + // Try - Catch block is used to catch error and format date using default locale if invalid locale is passed from parent + try { + // Get the date components breakdown array + formatter = new Intl.DateTimeFormat(locale, options).formatToParts(new Date()); + } catch (error) { + if (error) { // Get the date components breakdown array with default locale + formatter = new Intl.DateTimeFormat('default', options).formatToParts(new Date()); + } + } + + return formatter; +}; + + +// Function to return the date values from user input +export const getDateValues = (dateString: string, languageCode?: string, dateFormatInput?: string) => { + let parseDay!: number; + let parseMonth!: number; + let parseYear!: number; + let splitWith!: string; + + // If there is a dateFormat input from parent, the input format will be applied + // else date format will be generated and applied from languageCode input or default locale + if (dateFormatInput && isInputDateFormatValid(dateFormatInput)) { + const dateFormatDetails: DateFormatDetails = parseDateFormat(dateFormatInput); + let index = 0; + + dateFormatDetails.dateParts.map((part: string) => { // Generate the date format + switch (part) { + case 'MM': + parseMonth = index; + index++; + break; + case 'DD': + parseDay = index; + index++; + break; + case 'YYYY': + parseYear = index; + index++; + break; + default: + break; + } + }); + splitWith = dateFormatDetails.literal ? dateFormatDetails.literal : '/'; + } else { + const formatter: Intl.DateTimeFormatPart[] = getLocaleParts(languageCode); + let index = 0; + + formatter.map((part: Intl.DateTimeFormatPart) => { // Generate the date format + switch (part.type) { + case 'month': + parseMonth = index; + index++; + break; + case 'day': + parseDay = index; + index++; + break; + case 'year': + parseYear = index; + index++; + break; + default: + splitWith = part.value; + } + }); + } + + const dateStringArray: string[] = dateString.split(splitWith); + const selectedDayInput: number = parseInt(dateStringArray[parseDay], 10); + const selectedMonthInput: number = parseInt(dateStringArray[parseMonth], 10); + const selectedYearInput: number = parseInt(dateStringArray[parseYear], 10); + const inputDate: InputDate = { + day: selectedDayInput, + month: selectedMonthInput, + year: selectedYearInput, + literal: splitWith + }; + + return inputDate; +}; + +// Function to return the fomatted date string +export const getDateFormat = (day: string, month: string, year: string, languageCode?: string, dateFormatInput?: string) => { + let formattedDate = ``; + const dd: string = day; + const mm: string = month; + const yyyy: string = year; + + // If there is a dateFormat input from parent, the input format will be applied + // else date format will be generated and applied from languageCode input or default locale + if (dateFormatInput && isInputDateFormatValid(dateFormatInput)) { + const dateFormatDetails: DateFormatDetails = parseDateFormat(dateFormatInput); + + dateFormatDetails.dateParts.map((part: string, index: number) => { // Generate the date format + switch (part) { + case 'MM': + formattedDate += `${mm}`; + break; + case 'DD': + formattedDate += `${dd}`; + break; + case 'YYYY': + formattedDate += `${yyyy}`; + break; + default: + break; + } + if (index < 2) { + formattedDate += dateFormatDetails.literal ? dateFormatDetails.literal : '/'; + } + }); + } else { + const formatter: Intl.DateTimeFormatPart[] = getLocaleParts(languageCode); + + formatter.map((part: Intl.DateTimeFormatPart) => { // Generate the date format + switch (part.type) { + case 'month': + formattedDate += `${mm}`; + break; + case 'day': + formattedDate += `${dd}`; + break; + case 'year': + formattedDate += `${yyyy}`; + break; + default: + formattedDate += part.value; + } + }); + } + + return formattedDate; +}; + +// Function to return date format based on date format input or locale +export const getDatePatternFromLocale = (languageCode?: string, dateFormatInput?: string) => { + let localeDateFormat = ''; + + // If there is a dateFormat input from parent, the input format will be applied + // else date format will be generated and applied from languageCode input or default locale + if (dateFormatInput && isInputDateFormatValid(dateFormatInput)) { + localeDateFormat = dateFormatInput; + } else { + const formatter: Intl.DateTimeFormatPart[] = getLocaleParts(languageCode); + + formatter.map((part: Intl.DateTimeFormatPart) => { // Generate the date format + switch (part.type) { + case 'month': + localeDateFormat += 'MM'; + break; + case 'day': + localeDateFormat += 'DD'; + break; + case 'year': + localeDateFormat += 'YYYY'; + break; + default: + localeDateFormat += part.value; + } + }); + } + + return localeDateFormat; +}; + +// Function to generate regex pattern based on date format input or locale +export const getRegexPattern = (languageCode?: string, dateFormatInput?: string) => { + const regDay = '[0-9]{1,2}'; + const regMonth = '[0-9]{1,2}'; + const regYear = '[0-9]{2,4}'; + let regex = '^'; + + // If there is a dateFormat input from parent, the input format will be applied + // else date format will be generated and applied from languageCode input or default locale + if (dateFormatInput && isInputDateFormatValid(dateFormatInput)) { + const dateFormatDetails: DateFormatDetails = parseDateFormat(dateFormatInput); + + dateFormatDetails.dateParts.map((part: string, index: number) => { // Generate the date format + switch (part) { + case 'MM': + regex += regMonth; + break; + case 'DD': + regex += regDay; + break; + case 'YYYY': + regex += regYear; + break; + default: + break; + } + if (index < 2) { + regex += dateFormatDetails.literal ? dateFormatDetails.literal : '/'; + } + }); + } else { + const formatter: Intl.DateTimeFormatPart[] = getLocaleParts(languageCode); + + formatter.map((part: Intl.DateTimeFormatPart) => { // Generate the regex for the date format according to locale + switch (part.type) { + case 'month': + regex += regMonth; + break; + case 'day': + regex += regDay; + break; + case 'year': + regex += regYear; + break; + default: + regex += part.value; // Will append the part literal '/' or '.' or '-' to the regex + break; + } + }); + } + regex += '$'; + + return regex; +}; + +// Function to generate date parts array from date format input +export const parseDateFormat = (dateFormatInput: string) => { + const literals: string[] = ['/', '-', '.']; // Supported date format literals + let datePartsArray: string[] = []; + + const literal = literals.find((literal: string) => { // Find the literal in the format + if (dateFormatInput.includes(literal)) { + return literal; + } + }); + + if (literal) { + datePartsArray = dateFormatInput.split(literal); // Split the format to date parts + } + + const dateFormatParts = { + dateParts: datePartsArray, + literal: literal + }; + + return dateFormatParts; +}; + +// Function to check validity of the date format input +export const isInputDateFormatValid = (dateFormatInput: string) => { + let isDateFormatValid = false; + const supportedDateFormats: string[] = [ // Supported date formats from parent + 'DD/MM/YYYY', 'MM/DD/YYYY', 'YYYY/MM/DD', 'YYYY/DD/MM', 'DD-MM-YYYY', 'MM-DD-YYYY', + 'YYYY-MM-DD', 'YYYY-DD-MM', 'DD.MM.YYYY', 'MM.DD.YYYY', 'YYYY.MM.DD', 'YYYY.DD.MM' + ]; + + if (supportedDateFormats.includes(dateFormatInput)) { + isDateFormatValid = true; + } else { + isDateFormatValid = false; + } + + return isDateFormatValid; +}; + +export const getMonthNamesFromLocale = (languageCode?: string) => { + const monthNames: string[] = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December' + ]; + + if (languageCode) { + const date: Date = new Date(); + try { + const translatedMonthNames: string[] = []; + for (let i = 0; i < 12; i++) { + date.setMonth(i); + const monthName: string = new Intl.DateTimeFormat(languageCode, { month: 'long' }).format(new Date(date.getFullYear(), i, date.getDate())); + translatedMonthNames.push(monthName); + } + return translatedMonthNames; + } catch (error) { + if (error) { + return monthNames; + } + } + } + return monthNames; +}; diff --git a/elements/pf-date-picker/demo/date-format.html b/elements/pf-date-picker/demo/date-format.html new file mode 100644 index 0000000000..82b45f5ef0 --- /dev/null +++ b/elements/pf-date-picker/demo/date-format.html @@ -0,0 +1,44 @@ +
+
+

Date Format

+
<pf-date-picker date-format-input="YYYY-DD-MM"></pf-date-picker>
+ +

The date picker supports the following 12 date formats:

+

'DD/MM/YYYY', 'MM/DD/YYYY', 'YYYY/MM/DD', 'YYYY/DD/MM', 'DD-MM-YYYY', 'MM-DD-YYYY', 'YYYY-MM-DD', + 'YYYY-DD-MM', 'DD.MM.YYYY', 'MM.DD.YYYY', 'YYYY.MM.DD', 'YYYY.DD.MM'

+

The date format is set globally, independent of the user's locale. + This means that all users will see the same date format, regardless of their language or region.

+
+
+ + + + + + diff --git a/elements/pf-date-picker/demo/date-input.html b/elements/pf-date-picker/demo/date-input.html new file mode 100644 index 0000000000..051165741e --- /dev/null +++ b/elements/pf-date-picker/demo/date-input.html @@ -0,0 +1,43 @@ +
+
+

Set Input Date

+
<pf-date-picker input-date=${new Date(2023, 0, 1)}></pf-date-picker>
+ + +

The default date value can be passed to the date picker to set the initial date that is displayed.

+

In the above given example, the date set to be displayed initially is January 1, 2023.

+
+
+ + + + + + diff --git a/elements/pf-date-picker/demo/disabled.html b/elements/pf-date-picker/demo/disabled.html new file mode 100644 index 0000000000..cd17d77957 --- /dev/null +++ b/elements/pf-date-picker/demo/disabled.html @@ -0,0 +1,40 @@ +
+
+

Disabled

+
<pf-date-picker disabled="true"></pf-date-picker>
+ +

The disabled attribute can be used to disable the date picker.

+
+
+ + + + + + diff --git a/elements/pf-date-picker/demo/localization.html b/elements/pf-date-picker/demo/localization.html new file mode 100644 index 0000000000..23d8dad125 --- /dev/null +++ b/elements/pf-date-picker/demo/localization.html @@ -0,0 +1,43 @@ +
+
+

Localization

+
<pf-date-picker localization-language-code="fi"></pf-date-picker>
+ +

The locale string can be passed to the date picker to set the date format.

+

In the above given example, the locale is set to Finnish: fi

+

The date format is set globally, independent of the user's locale. + This means that all users will see the same date format, regardless of their language or region.

+
+
+ + + + + + diff --git a/elements/pf-date-picker/demo/min-and-max-date.html b/elements/pf-date-picker/demo/min-and-max-date.html new file mode 100644 index 0000000000..217b85aed7 --- /dev/null +++ b/elements/pf-date-picker/demo/min-and-max-date.html @@ -0,0 +1,49 @@ +
+
+

Set minimum and maximum date range

+
<pf-date-picker min-date=${new Date(2023, 0, 2)} max-date=${new Date(2023, 0, 20)} 
+      inputDate=${new Date(2023, 0, 3)}></pf-date-picker>
+ + + +

The minimum and maximum valid dates can be passed to the date picker to restrict the range of dates that can be selected

+

In the above given example, the minimum valid date + is January 2, 2023, and the maximum valid date is January 20, 2023

+
+
+ + + + + + diff --git a/elements/pf-date-picker/demo/pf-date-picker.html b/elements/pf-date-picker/demo/pf-date-picker.html new file mode 100644 index 0000000000..f764e6829b --- /dev/null +++ b/elements/pf-date-picker/demo/pf-date-picker.html @@ -0,0 +1,41 @@ +
+
+

Basic

+
<pf-date-picker></pf-date-picker>
+ +

The basic date picker will use the user's locale to determine the date format. The default minimum valid date + is January 1, 1900, and the default maximum valid date is December 31, 9999.

+
+
+ + + + + + diff --git a/elements/pf-date-picker/demo/translation.html b/elements/pf-date-picker/demo/translation.html new file mode 100644 index 0000000000..841535beec --- /dev/null +++ b/elements/pf-date-picker/demo/translation.html @@ -0,0 +1,41 @@ +
+
+

Translation

+
<pf-date-picker translation-language-code="fr"></pf-date-picker>
+ +

The translation language string can be passed to the date picker to localize the month names.

+

In the above given example, the language is set to French: fr

+
+
+ + + + + + diff --git a/elements/pf-date-picker/docs/pf-date-picker.md b/elements/pf-date-picker/docs/pf-date-picker.md new file mode 100644 index 0000000000..89025c51c7 --- /dev/null +++ b/elements/pf-date-picker/docs/pf-date-picker.md @@ -0,0 +1,17 @@ +{% renderOverview %} + +{% endrenderOverview %} + +{% band header="Usage" %}{% endband %} + +{% renderSlots %}{% endrenderSlots %} + +{% renderAttributes %}{% endrenderAttributes %} + +{% renderMethods %}{% endrenderMethods %} + +{% renderEvents %}{% endrenderEvents %} + +{% renderCssCustomProperties %}{% endrenderCssCustomProperties %} + +{% renderCssParts %}{% endrenderCssParts %} diff --git a/elements/pf-date-picker/docs/screenshot.png b/elements/pf-date-picker/docs/screenshot.png new file mode 100644 index 0000000000..ba57f54dce Binary files /dev/null and b/elements/pf-date-picker/docs/screenshot.png differ diff --git a/elements/pf-date-picker/pf-calendar.css b/elements/pf-date-picker/pf-calendar.css new file mode 100644 index 0000000000..678833b86f --- /dev/null +++ b/elements/pf-date-picker/pf-calendar.css @@ -0,0 +1,74 @@ +:host { + display: block; +} + +.date-picker-table-row { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + margin-bottom: 0.125rem; +} + +.date-picker-table-col { + height: 39px; + width: 39px; + display: flex; + align-items: center; + justify-content: center; +} + +pf-button:focus, pf-button:focus-visible { + border: 2px solid var(--rh-color-accent-base-on-light, #0066cc); + outline: none; +} + +pf-button.calendar-date-button { + --pf-c-button--BorderRadius:50%; + width: 37px; + height: 37px; + white-space: nowrap; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + box-sizing: border-box; +} + +pf-button.calendar-date-button.isToday { + --pf-c-button--m-plain--BackgroundColor: #f0f0f0; + --pf-c-button--m-plain--hover--BackgroundColor: var(--rh-color-blue-50, #e7f1fa); + --pf-c-button--m-plain--focus--BackgroundColor: var(--rh-color-blue-50, #e7f1fa); +} + +pf-button.calendar-date-button:focus { + --pf-c-button--m-plain--BackgroundColor: var(--rh-color-blue-50, #e7f1fa); +} + +pf-button.calendar-date-button { + --pf-c-button--m-plain--Color: var(--rh-color-canvas-black, #151515); + --pf-c-button--m-plain--hover--BackgroundColor: var(--rh-color-blue-50, #e7f1fa); + --pf-c-button--m-plain--focus--BackgroundColor: var(--rh-color-blue-50, #e7f1fa); + --pf-c-button--FontSize: var(--rh-font-size-body-text-sm, 0.875rem); + + font-family: var(--rh-font-family-body-text, RedHatText, "Red Hat Text", "Noto Sans Arabic", "Noto Sans Hebrew", "Noto Sans JP", "Noto Sans KR", "Noto Sans Malayalam", "Noto Sans SC", "Noto Sans TC", "Noto Sans Thai", Helvetica, Arial, sans-serif); +} + +pf-button.calendar-date-button.previous-next-date { + --pf-c-button--m-plain--Color: #6A6E73; +} + +pf-button.calendar-date-button.selected-date { + --pf-c-button--m-plain--BackgroundColor: var(--rh-color-accent-base-on-light, #0066cc); + --pf-c-button--m-plain--Color: var(--rh-color-white, #ffffff); + --pf-c-button--m-plain--hover--Color: var(--rh-color-white, #ffffff); + --pf-c-button--m-plain--hover--BackgroundColor: var(--rh-color-interactive-blue-darkest, #004080); + --pf-c-button--m-plain--focus--BackgroundColor: var(--rh-color-interactive-blue-darkest, #004080); +} + +pf-button.calendar-date-button.selected-date:focus { + box-shadow: 0 0 0.3125rem var(--rh-color-accent-base-on-light, #0066cc); + background: var(--rh-color-interactive-blue-darkest, #004080); + border: none; + outline: none; +} diff --git a/elements/pf-date-picker/pf-calendar.ts b/elements/pf-date-picker/pf-calendar.ts new file mode 100644 index 0000000000..170e9ea64d --- /dev/null +++ b/elements/pf-date-picker/pf-calendar.ts @@ -0,0 +1,282 @@ +import { LitElement, html } from 'lit'; +import { customElement } from 'lit/decorators/custom-element.js'; +import { property } from 'lit/decorators/property.js'; +import { ref, createRef } from 'lit/directives/ref.js'; +import styles from './pf-calendar.css'; +import { + getFormattedDate, + getMonthNamesFromLocale, + defaultWeekdays, + defaultWeeks, +} from './date-picker-helper.js'; + +export interface FocusedDateValues { + day: number; + month: number; + year: number; +} + +export interface CalendarDateValues { + day: number; + month: number; + year: number; + isCurrentMonth: boolean; +} + +/** + * Date Calendar + * @slot - Place element content here + */ + +export class DayChangeEvent extends Event { + constructor(public event: Event, public day: number, public month: number, public year: number) { + super('daySelected', { bubbles: true, cancelable: true }); + } +} + +export class FocusChangeEvent extends Event { + constructor(public dateToBeFocusedRef: HTMLButtonElement | undefined) { + super('setDateFocus', { bubbles: true, cancelable: true }); + } +} + +export class KeyboardNavigationFocusEvent extends Event { + constructor(public event: Event, public day: number, public month: number, public year: number) { + super('onCalendarKeydown', { bubbles: true, cancelable: true }); + } +} + +/** + * Calendar + * @slot - Place element content here + */ +@customElement('pf-calendar') +export class PfCalendar extends LitElement { + static readonly styles = [styles]; + + #currentDate = new Date(); + #weekdays = defaultWeekdays; + #weeks = defaultWeeks; + + // Input properties from the parent + @property() currentYear: number = this.#currentDate.getFullYear(); + @property() currentMonth: number = this.#currentDate.getMonth(); + @property() currentWeek = 0; + @property() selectedDay: number = this.#currentDate.getDate(); + @property() focusRef!: HTMLButtonElement; + @property() dayToBeFocused!: number; + @property() focusedDateValues!: FocusedDateValues; + @property() dateSelected!: Date; + @property() firstDayToBeFocused!: number; + @property() minDate!: Date; + @property() maxDate!: Date; + @property() translationLanguageCode!: string; + @property() monthNames: string[] = getMonthNamesFromLocale(this.translationLanguageCode); + focusDateRef: any = createRef(); // Reference to the button that needs to be focused + + connectedCallback() { + super.connectedCallback(); + this.#init(); + if (this.translationLanguageCode) { + this.monthNames = getMonthNamesFromLocale(this.translationLanguageCode); + } + } + + render() { + this.focusedDateValues = getFormattedDate( + new Date(this.currentYear, this.currentMonth, this.dayToBeFocused) + ); + + // Get the total number of days of the current month + const totalDays: number = new Date(this.currentYear, this.currentMonth + 1, 0).getDate(); + + // Find the day of the week for the first day of the month + const firstDay: number = new Date(this.currentYear, this.currentMonth, 1).getDay(); + + // Get the last date of the previous month + const previousMonthLastDate: number = + new Date(this.currentYear, this.currentMonth, 0).getDate(); + + // Get the number of weeks in a month + this.#weeks = this.#getNoOfWeeks(this.currentYear, this.currentMonth); + + let day = 1; + let nextMonthPointer = 0; + + return html`
+ ${this.#weeks.map((week: number) => { + return html` +
+ ${this.#weekdays.map((weekDay: number) => { + let calendarDayTemplate: unknown; + if (week === 0 && weekDay < firstDay) { + // Add the last dates of the previous month + const dateValue: CalendarDateValues = { + day: (previousMonthLastDate - (firstDay - 1) + weekDay), + month: this.currentMonth - 1, + year: this.currentYear, + isCurrentMonth: false, + }; + calendarDayTemplate = this.#renderCalendarDates(dateValue); + } else if (day > totalDays) { + // Add the first dates of the next month + const dateValue: CalendarDateValues = { + day: nextMonthPointer + 1, + month: this.currentMonth + 1, + year: this.currentYear, + isCurrentMonth: false, + }; + calendarDayTemplate = this.#renderCalendarDates(dateValue); + nextMonthPointer++; + } else { + // Add the dates of the current month + const dateValue: CalendarDateValues = { + day: day, + month: this.currentMonth, + year: this.currentYear, + isCurrentMonth: true, + }; + calendarDayTemplate = this.#renderCalendarDates(dateValue); + day++; + } + return calendarDayTemplate; + })} +
`; + })} +
`; + } + + // Function to render the days of the calendar + #renderCalendarDates(value: CalendarDateValues) { + const isDayToBeFocused: boolean = ((value.day === this.dayToBeFocused) + && (value.month === this.focusedDateValues?.month) + && (value.year === this.focusedDateValues?.year)); + + const selectedDate: Date = new Date(this.dateSelected); + const isToday: boolean = ((value.day === this.#currentDate.getDate()) + && (value.month === this.#currentDate.getMonth()) + && (value.year === this.#currentDate.getFullYear())); + + const isSelectedDay: boolean = ((value.day === selectedDate?.getDate()) + && (value.month === selectedDate?.getMonth()) + && (value.year === selectedDate?.getFullYear())); + + const dateRendering = new Date(value.year, value.month, value.day); + const isDateInvalid = dateRendering.toString() === 'Invalid Date'; + const isDateDisabled: boolean = isDateInvalid + || dateRendering < this.minDate || dateRendering > this.maxDate; + + + const dateValue = html`
+ this.#selectDate(e, value.month, value.year)} + class='${this.selectedDay && isSelectedDay ? 'selected-date' : ''} ${!value.isCurrentMonth ? 'previous-next-date' : ''} ${isToday ? 'isToday' : ''} calendar-date-button'> + ${value.day} + +
`; + + return dateValue; + } + + // Function to dispatch the selected date + #selectDate(event: Event, month: number, year: number) { + const { value } = event.target as HTMLButtonElement; + const selectedDay: number = parseInt(value); + let currentMonth: number = month; + let currentYear: number = year; + if (month < 0) { + currentMonth = 11; + currentYear = currentYear - 1; + } + if (month > 11) { + currentMonth = 0; + currentYear = currentYear + 1; + } + + this.dispatchEvent(new DayChangeEvent(event, selectedDay, currentMonth, currentYear)); + } + + async #init() { + await this.updateComplete; + this.#setDateFocus(); + } + + // Function to focus date button; + #setDateFocus() { + const date: HTMLButtonElement = this.focusDateRef.value!; + this.focusRef = date; + setTimeout(() => { + date?.focus(); + }, 25); + } + + // Function to handle focus on property update + updated() { + this.dispatchEvent(new FocusChangeEvent(this.focusDateRef.value!)); + this.#setDateFocus(); + } + + // Funtion to handle focus based on arrow keys + #onCalendarKeydown(event: KeyboardEvent) { + const date = new Date(this.currentYear, this.currentMonth, this.dayToBeFocused); + + switch (event.key) { + case 'ArrowUp': + date.setDate(date.getDate() - 7); + break; + case 'ArrowDown': + date.setDate(date.getDate() + 7); + break; + case 'ArrowLeft': + date.setDate(date.getDate() - 1); + break; + case 'ArrowRight': + date.setDate(date.getDate() + 1); + break; + } + + if ( + event.key === 'ArrowUp' + || event.key === 'ArrowDown' + || event.key === 'ArrowLeft' + || event.key === 'ArrowRight' + ) { + this.focusedDateValues = getFormattedDate(date); + this.dispatchEvent(new KeyboardNavigationFocusEvent( + event, + this.focusedDateValues.day, + this.focusedDateValues.month, + this.focusedDateValues.year + ) + ); + } + } + + // Find total number of weeks of the month + #getNoOfWeeks(year: number, month: number) { + const firstDayOfMonth: Date = new Date(year, month, 1); + const lastDayOfMonth: Date = new Date(year, month + 1, 0); + + const totalNoOfDays: number = firstDayOfMonth.getDay() + lastDayOfMonth.getDate(); + const totalWeeks: number = Math.ceil( totalNoOfDays / 7); + + const weeks: number[] = []; + + for (let i = 0; i < totalWeeks; i++) { + weeks.push(i); + } + return weeks; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'pf-calendar': PfCalendar; + } +} diff --git a/elements/pf-date-picker/pf-date-picker.css b/elements/pf-date-picker/pf-date-picker.css new file mode 100644 index 0000000000..9159896be1 --- /dev/null +++ b/elements/pf-date-picker/pf-date-picker.css @@ -0,0 +1,151 @@ +:host { + display: block; +} + +.datepicker { + position: relative; + display: inline-block; + font-family: var(--rh-font-family-body-text, RedHatText, "Red Hat Text", "Noto Sans Arabic", "Noto Sans Hebrew", "Noto Sans JP", "Noto Sans KR", "Noto Sans Malayalam", "Noto Sans SC", "Noto Sans TC", "Noto Sans Thai", Helvetica, Arial, sans-serif); +} + +.calendar { + width: auto; + min-width: 330px; + max-width: 330px; + background-color: var(--rh-color-white, #ffffff); + padding: var(--rh-space-md, 8px); + padding-bottom: 0; + box-sizing: border-box; +} + +.date-picker-table { + display: block; + width: 100%; +} + +.date-picker-table-row { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; +} + +.date-picker-table-main-header { + justify-content: space-around; +} + +.date-picker-table-col { + height: 39px; + width: 39px; + display: flex; + align-items: center; + justify-content: center; + font-size: var(--rh-font-size-body-text-sm, 0.875rem); + font-style: normal; + font-weight: var(--rh-font-weight-body-text-regular, 400); + line-height: 21px; +} + +.date-picker-table-month-year { + display: flex; + flex-direction: row; + align-items: center; + width: fit-content; +} + +.date-picker-week-days { + border-bottom: 1px solid #D2D2D2; + margin-bottom: var(--rh-space-md, 8px); + margin-top: var(--rh-space-md, 8px); +} + +#date-input { + padding: 9px 10px; + border-radius: 0; + background-color: transparent; + cursor:text; + position: relative; + border: 1px solid #f0f0f0; + border-bottom: 1px solid #8b8d90; + border-right: none; + box-sizing: border-box; + width: 150px; + z-index: 1; + font-family: var(--rh-font-family-body-text, RedHatText, "Red Hat Text", "Noto Sans Arabic", "Noto Sans Hebrew", "Noto Sans JP", "Noto Sans KR", "Noto Sans Malayalam", "Noto Sans SC", "Noto Sans TC", "Noto Sans Thai", Helvetica, Arial, sans-serif); + font-size: var(--rh-font-size-body-text-md, 1rem); + font-style: normal; + font-weight: var(--rh-font-weight-body-text-regular, 400); + line-height: 24px; + height: 36px; +} + +#date-input:focus { + border-color: var(--rh-color-accent-base-on-light, #0066cc); + outline: var(--rh-color-accent-base-on-light, #0066cc) auto 2px; +} + +.date-input-box { + width: auto; + background-color: transparent; + display: flex; + align-items: flex-start; + justify-content: flex-start; +} + +.date-input-box-container { + position: relative; +} + +.isDateInvalid { + position: absolute; + top: 50%; + right: var(--rh-space-md, 8px); + transform: translateY(-50%); + color: #c9190b; + display: block; +} + +.invalidDateInput { + border-bottom: 2px solid #c9190b !important; +} + +.showInvalidText { + display: block; + color: #a30000; + font-size: var(--rh-font-size-body-text-sm, 0.875rem); + font-family: var(--rh-font-family-body-text, RedHatText, "Red Hat Text", "Noto Sans Arabic", "Noto Sans Hebrew", "Noto Sans JP", "Noto Sans KR", "Noto Sans Malayalam", "Noto Sans SC", "Noto Sans TC", "Noto Sans Thai", Helvetica, Arial, sans-serif); + position: absolute; + bottom: -24px; + left: 0; + white-space: nowrap; +} + +.hidden { + display: none; +} + +.date-picker-container pf-popover::part(content) { + min-width: 330px; +} + +.disable-popover { + pointer-events: none; +} + +pf-button:focus, pf-button:focus-visible { + outline: var(--rh-color-accent-base-on-light, #0066cc) auto 2px; +} + +:host .date-picker-toggle-container pf-button.date-picker-calendar-icon { + height: 36px; + padding: 8px 12px; + box-sizing: border-box; +} + +:host .date-picker-toggle-container pf-button.date-picker-calendar-icon:focus { + outline: none; +} + +:host .date-picker-toggle-container pf-button.date-picker-calendar-icon:focus-visible { + outline: var(--rh-color-accent-base-on-light, #0066cc) auto 2px; +} \ No newline at end of file diff --git a/elements/pf-date-picker/pf-date-picker.ts b/elements/pf-date-picker/pf-date-picker.ts new file mode 100644 index 0000000000..39a1ab2ced --- /dev/null +++ b/elements/pf-date-picker/pf-date-picker.ts @@ -0,0 +1,658 @@ +import { LitElement, html } from 'lit'; +import { customElement } from 'lit/decorators/custom-element.js'; +import { state } from 'lit/decorators/state.js'; +import { property } from 'lit/decorators/property.js'; +import '@patternfly/elements/pf-icon/pf-icon.js'; +import '@patternfly/elements/pf-button/pf-button.js'; +import '@patternfly/elements/pf-popover/pf-popover.js'; +import { PfPopover } from '@patternfly/elements/pf-popover/pf-popover.js'; +import { bound } from '@patternfly/pfe-core/decorators.js'; +import { query } from 'lit/decorators/query.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { + days, + getDateFormat, + getDateValues, + getDatePatternFromLocale, + getRegexPattern, + getMonthNamesFromLocale, +} from './date-picker-helper.js'; +import styles from './pf-date-picker.css'; +import './pf-previous-button.js'; +import './pf-month-select.js'; +import './pf-year-input.js'; +import './pf-next-button.js'; +import './pf-calendar.js'; +import { PreviousButtonClickEvent } from './pf-previous-button.js'; +import { NextButtonClickEvent } from './pf-next-button.js'; +import { MonthChangeEvent, MonthPopupStateChangeEvent } from './pf-month-select.js'; +import { DayChangeEvent, FocusChangeEvent, KeyboardNavigationFocusEvent } from './pf-calendar.js'; + +/** + * Date Picker + * @slot - Place element content here + */ + +interface ErrorMessages { + inValid: string; + lessThanMinDate: string; + greaterThanMaxDate: string; +} + +export class DateChangeEvent extends Event { + constructor(public event: Event, public value: Date | null ) { + super('selectedDate', { bubbles: true, cancelable: true }); + } +} + +/* + * Date Picker + * @slot - Place element content here + */ +@customElement('pf-date-picker') +export class PfDatePicker extends LitElement { + static readonly styles = [styles]; + + #currentDate = new Date(); + #errorMessages: ErrorMessages = { + inValid: 'Invalid date', + lessThanMinDate: 'Date is before the allowable range.', + greaterThanMaxDate: 'Date is after the allowable range.', + }; + + @state() private isDateValid = true; // Checks if the date enetered by user in the input box is valid or not + @state() private isValid = true; // Checks if the year enetered by user in the year input box is valid or not + @state() private monthExpand = false; // Handles the closing and opening of the Month select + + @query('#date-input') _textInput!: HTMLInputElement; // Date picker input box reference + @query('#popover') private _popover!: PfPopover; // Popover reference + + + // ------------------------Input properties from parent-------------------------------// + // -----------[***Only these properties can be used to pass data from parent***] ------------ // + + // Default minimum valid date set as 1st January 1900 + @property({ + reflect: true, + attribute: 'min-date', + }) minDate: Date = new Date(1900, 0, 1); + + // Default maximum valid date set as 31st December 9999 + @property({ + reflect: true, + attribute: 'max-date', + }) maxDate: Date = new Date(9999, 11, 31); + + // Handle date value with a unique time stamp that parent sends to the component. + @property({ + reflect: true, + attribute: 'input-date', + }) inputDateWithUniqueTimeStamp!: string; + // The format: inputDateWithUniqueTimeStamp = (new Date(2024, 3, 2)).toDateString() +'#'+ (Date.now() + Math.random()) // + + // Handles if the date picker is disabled or not + @property({ + reflect: true, + attribute: 'disabled', + }) isDisabled = false; + + // Language code for date format based on localization + @property({ + reflect: true, + attribute: 'localization-language-code', + }) localizationLanguageCode!: string; + + // Language code for translation of date input and month names + @property({ + reflect: true, + attribute: 'translation-language-code', + }) translationLanguageCode!: string; + + // Date format input from parent + @property({ + reflect: true, + attribute: 'date-format-input', + }) dateFormatInput!: 'MM-DD-YYYY' | 'YYYY-MM-DD' | + 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY/MM/DD' | 'YYYY/DD/MM' | 'DD-MM-YYYY' | + 'YYYY-DD-MM' | 'DD.MM.YYYY' | 'MM.DD.YYYY' | 'YYYY.MM.DD' | 'YYYY.DD.MM'; + + // Placeholder from parent + @property({ + reflect: true, + attribute: 'placeholder', + }) placeholderTextWithUniqueCode!: string; + // The format: placeholderTextWithUniqueCode = 'placeholder-text' + '#' + Math.random(); + + // ----------------------Input properties from parent ends--------------------------------- // + // -------------------------------------------------------// + + + // 'current' refers to the temporary values of Month and Year the user selected before day is selected + // and the input box is updated + @property() monthNames: string[] = getMonthNamesFromLocale(this.translationLanguageCode); + @property() currentMonthSelection: string = this.monthNames[this.#currentDate.getMonth()]; + @property() currentMonthIndex: number = this.#currentDate.getMonth(); + @property() currentYear: number = this.#currentDate.getFullYear(); + + // 'selected' refers to the active selected values of day, Month and Year the user selected + // which is updated in the input box + @property() selectedDay!: number | null; + @property() selectedMonthIndex: number = this.#currentDate.getMonth(); + @property() selectedMonthSelection: string = this.monthNames[this.#currentDate.getMonth()]; + @property() selectedYear: number = this.#currentDate.getFullYear(); + @property() formattedDate = ''; // The value which is updated in the date-picker input box + @property() errorMessage!: string; // Handle the error message on invalid date + @property() dateSelected!: Date; // The date selected by the user + @property() dayToBeFocused!: number; // Handles the day that needs to be focused + @property() dayToBeFocusedRef!: HTMLButtonElement | undefined; // Reference of the day that needs to be focused + @property() firstDayToBeFocused!: number; // Handles the day to be focused on popover open + @property() dateFormat: string = + getDatePatternFromLocale(this.localizationLanguageCode, this.dateFormatInput); // Date format + + @property() inputDate!: Date | null; // Handle and format date input value parent sends to the component + #minYear = new Date(this.minDate).getFullYear(); // Minimum Valid Year + #maxYear: number = new Date(this.maxDate).getFullYear(); // Maximum Valid Year + + constructor() { + super(); + } + + connectedCallback() { + super.connectedCallback(); + document.addEventListener('click', this._onOutsideClick); + this.#init(); + if (this.translationLanguageCode) { + this.monthNames = getMonthNamesFromLocale(this.translationLanguageCode); + } + if (this.dateFormatInput || this.localizationLanguageCode) { + this.dateFormat = getDatePatternFromLocale( + this.localizationLanguageCode, + this.dateFormatInput + ); + } + } + + willUpdate(changedProperties: Map) { + if (changedProperties.has('minDate')) { + this.minDate = new Date(this.minDate); + } + + if (changedProperties.has('maxDate')) { + this.maxDate = new Date(this.maxDate); + } + + if (changedProperties.has('inputDateWithUniqueTimeStamp')) { + // The unique timestamp is used to re-render the date-picker on date set and date reset + this.inputDate = new Date(this.inputDateWithUniqueTimeStamp.split('#')[0]); + this.#setInputDate(); + } + + if (changedProperties.has('placeholderTextWithUniqueCode')) { + const [placeholder, uniqueCode] = this.placeholderTextWithUniqueCode.split('#'); + this.dateFormat = placeholder; + } + } + + render() { + const invalidIconClasses = { isDateInvalid: !this.isDateValid, hidden: this.isDateValid }; + const invalidTextClasses = { showInvalidText: !this.isDateValid, hidden: this.isDateValid }; + const invalidInputClasses = { invalidDateInput: !this.isDateValid }; + + + return html` +
+
+
+
+ + +
${this.errorMessage}
+
+ +
+ + + +
+
+
+
+
+
+ + +
+
+ + + + +
+
+ + +
+
+
+ ${days.map((day: string) => { + return html` +
+ ${day} +
+ `; + })} +
+ + +
+
+
+
+
+
+
+ `; + } + + async #init() { + await this.updateComplete; + /* The setTimeout function is added to allow the thread to wait until the page has + fully loaded before executing the #removePFPopoverDialogFromDOM() function. */ + setTimeout(()=>{ + this.#removePFPopoverDialogFromDOM(); + }, 100); + this.#setInputDate(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener('click', this._onOutsideClick); + } + + // Function to handle the closing of popover and month select popup on outside click + @bound private _onOutsideClick(event: MouseEvent) { + const path = event.composedPath(); + if (!path.includes(this._popover)) { + if (this.monthExpand) { + this.monthExpand = false; + } else { + this._popover.hide(); + } + } + } + + // Function to set the date input from parent component + #setInputDate() { + if (this.inputDate?.toString() === 'Invalid Date') { + this.#clearDateSelection(); + } else if (this.inputDate) { + this.selectedDay = new Date(this.inputDate).getDate(); + this.selectedMonthIndex = new Date(this.inputDate).getMonth(); + this.selectedYear = new Date(this.inputDate).getFullYear(); + this.selectedMonthSelection = this.monthNames[this.selectedMonthIndex]; + this.currentMonthIndex = new Date(this.inputDate).getMonth(); + this.currentMonthSelection = this.monthNames[this.currentMonthIndex]; + this.currentYear = new Date(this.inputDate).getFullYear(); + this.dateSelected = this.inputDate; + this.#setDayToBeFocused(); + this.#getFormattedDate(); + } + } + + // Function to clear date selected + #clearDateSelection() { + this.selectedDay = null; + this.currentMonthIndex = this.#currentDate.getMonth(); + this.currentMonthSelection = this.monthNames[this.currentMonthIndex]; + this.currentYear = this.#currentDate.getFullYear(); + this.selectedMonthIndex = this.#currentDate.getMonth(); + this.selectedYear = this.#currentDate.getFullYear(); + this.selectedMonthSelection = this.monthNames[this.selectedMonthIndex]; + this.formattedDate = ''; + } + + // Function to get the reference of the button that needs to be focused + #getDayRefToBeFocused(event: FocusChangeEvent) { + this.dayToBeFocusedRef = event.dateToBeFocusedRef; + } + + // Function to get the month user selected + #getCurrentMonth(event: MonthChangeEvent) { + this.currentMonthSelection = event.name; + this.currentMonthIndex = event.index; + this.#setDayToBeFocused(); + } + + // Function to get the year user entered + #getCurrentYear(event: CustomEvent) { + if (event.detail < 100) { + this.currentYear = this.#validateYearInput(event.detail); + } else { + this.currentYear = event.detail; + } + this.#setDayToBeFocused(); + } + + // Function to get the year user entered on KeyUp/KeyDown + #getCurrentStepValue(event: CustomEvent) { + this.currentYear = this.#validateYearInput(event.detail); + this.#setDayToBeFocused(); + } + + // Function to check if the year entered by the user is valid or not + #validateYearInput(year: number) { + this.#minYear = new Date(this.minDate).getFullYear(); + this.#maxYear = new Date(this.maxDate).getFullYear(); + const yearInput: number = year; + + this.isValid = yearInput > this.#minYear && yearInput < this.#maxYear; + if (this.isValid) { + return yearInput; + } else if (yearInput < this.#minYear) { + let inputYear: number; + const date: Date = new Date(year, this.currentMonthIndex, this.selectedDay ? + this.selectedDay : this.#currentDate.getDate()); + + if (date.getFullYear() >= this.#minYear) { + inputYear = date.getFullYear(); + } else { + inputYear = year; + } + return inputYear; + } else if (yearInput > this.#maxYear) { + return this.#maxYear; + } else { + return this.currentYear; + } + } + + // Function to get the year and month on previous button click + #getPreviousMonthAndYear(event: PreviousButtonClickEvent) { + this.currentMonthSelection = this.monthNames[event.month]; + this.currentMonthIndex = event.month; + this.currentYear = event.year; + this.#setDayToBeFocused(); + } + + // Function to get the year and month on next button click + #getNextMonthAndYear(event: NextButtonClickEvent) { + this.currentMonthSelection = this.monthNames[event.month]; + this.currentMonthIndex = event.month; + this.currentYear = event.year; + this.#setDayToBeFocused(); + } + + // Function to get the day to be focused on KeyDown + #getFocusDate(event: KeyboardNavigationFocusEvent) { + this.currentMonthSelection = this.monthNames[event.month]; + this.currentMonthIndex = event.month; + this.currentYear = event.year; + this.dayToBeFocused = event.day; + } + + // Function to get the selected day and set and update the input box + #getDaySelected(event: DayChangeEvent) { + this.selectedDay = event.day; + this.currentMonthSelection = this.monthNames[event.month]; + this.currentMonthIndex = event.month; + this.selectedMonthIndex = event.month; + this.selectedYear = event.year; + this.currentYear = event.year; + this.selectedMonthSelection = this.monthNames[event.month]; + this.monthExpand = false; + this.#getFormattedDate(); + this.#dispatchSelectedDate(event); + this.isDateValid = true; + this._popover.hide(); + } + + // Function to dispatch selected date values to the parent component + #dispatchSelectedDate(event: Event) { + if (this.selectedDay) { + this.dateSelected = new Date(this.selectedYear, this.selectedMonthIndex, this.selectedDay); + this.dispatchEvent(new DateChangeEvent(event, this.dateSelected)); + } else { + this.dispatchEvent(new DateChangeEvent(event, null)); + } + } + + // Function to get the month select expansion state + #getMonthExpandState(event: MonthPopupStateChangeEvent) { + this.monthExpand = event.isExpanded; + } + + // Function to handle opening of Popover + #openPopover() { + this.#addPFPopoverDialogToDOM(); + if (this.isDateValid) { + this.currentMonthIndex = this.selectedMonthIndex; + this.currentYear = this.selectedYear; + this.currentMonthSelection = this.selectedMonthSelection; + if (this.selectedDay) { + this.#getFormattedDate(); + this.firstDayToBeFocused = this.selectedDay; + } else { + this.firstDayToBeFocused = this.#currentDate.getDate(); + } + } else { + this.currentMonthIndex = this.#currentDate.getMonth(); + this.currentYear = this.#currentDate.getFullYear(); + this.currentMonthSelection = this.monthNames[this.currentMonthIndex]; + this.firstDayToBeFocused = this.#currentDate.getDate(); + this.selectedDay = null; + } + + this.#setDayToBeFocused(); + setTimeout(() => { + this.dayToBeFocusedRef?.focus(); + }, 50); + } + + // Function to set the day to be focused + #setDayToBeFocused() { + const totalDays: number = new Date(this.currentYear, this.currentMonthIndex + 1, 0).getDate(); + if (this.isDateValid && this.selectedDay) { + this.dayToBeFocused = this.selectedDay <= totalDays ? this.selectedDay : totalDays; + } else { + this.dayToBeFocused = this.#currentDate.getDate(); + } + } + + // Function to handle click on date input box + #dateInputClick() { + this._popover.hide(); + setTimeout(() => { + this._textInput.focus(); + }, 1); + } + + // Function to handle date input by user + #onInput(event: Event) { + const { value } = event.target as HTMLInputElement; + this.formattedDate = value; + + this.isDateValid = this.#isValidDate(value); + + if (this.isDateValid && value !== '') { + const dateValues = getDateValues(value, this.localizationLanguageCode, this.dateFormatInput); + this.selectedDay = dateValues.day; + this.currentMonthIndex = dateValues.month - 1; + this.currentMonthSelection = this.monthNames[this.currentMonthIndex]; + this.currentYear = dateValues.year; + this.selectedMonthIndex = dateValues.month - 1; + this.selectedYear = dateValues.year; + this.selectedMonthSelection = this.monthNames[this.selectedMonthIndex]; + this.#dispatchSelectedDate(event); + } else if (this.isDateValid && value === '') { + this.selectedDay = null; + this.currentMonthIndex = this.#currentDate.getMonth(); + this.currentMonthSelection = this.monthNames[this.currentMonthIndex]; + this.currentYear = this.#currentDate.getFullYear(); + this.selectedMonthIndex = this.#currentDate.getMonth(); + this.selectedYear = this.#currentDate.getFullYear(); + this.selectedMonthSelection = this.monthNames[this.selectedMonthIndex]; + this.#dispatchSelectedDate(event); + } + } + + // Function to check if the date user entered is valid or not + #isValidDate(dateString: string) { + let isValid = true; + if (dateString) { + const monthLength: number[] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + // const regex = /^[0-9./-]*$/g; // Commented for reference + + // Parse the date parts to integers + const dateValues = getDateValues( + dateString, + this.localizationLanguageCode, + this.dateFormatInput + ); + const selectedDayInput: number = dateValues.day; + const selectedMonthInput: number = dateValues.month; + const selectedYearInput: number = dateValues.year; + const date: Date = new Date(selectedYearInput, selectedMonthInput - 1, selectedDayInput); + const regex = new RegExp( + getRegexPattern(this.localizationLanguageCode, this.dateFormatInput) + ); + + if (date.toString() === 'Invalid Date') { + this.errorMessage = this.#errorMessages.inValid; + return false; + } + if (date < this.minDate) { + this.errorMessage = this.#errorMessages.lessThanMinDate; + return false; + } + if (date > this.maxDate) { + this.errorMessage = this.#errorMessages.greaterThanMaxDate; + return false; + } + + // if (!dateString.match(regex)) { // Commented for reference + if (!regex.test(dateString)) { + isValid = false; + this.errorMessage = this.#errorMessages.inValid; + return isValid; + } + + // Check the ranges of month and year + if (selectedYearInput < this.#minYear || selectedYearInput > this.#maxYear + || selectedMonthInput === 0 || selectedMonthInput > 12) { + this.errorMessage = this.#errorMessages.inValid; + return false; + } + + // Adjust for leap years + if (selectedYearInput % 400 === 0 || ( + selectedYearInput % 100 !== 0 && selectedYearInput % 4 === 0)) { + monthLength[1] = 29; + } + + // Check the range of the day + isValid = selectedDayInput > 0 && selectedDayInput <= monthLength[selectedMonthInput - 1]; + if (!isValid) { + this.errorMessage = this.#errorMessages.inValid; + } + } else if (dateString === '') { + isValid = true; + } + + return isValid; + } + + // Function to get the formatted date string according to date format selected in parent component + #getFormattedDate() { + const monthSelected: number = this.currentMonthIndex + 1; // Months start at 0! + const daySelected: number = this.selectedDay ? this.selectedDay : this.#currentDate.getDate(); + const yearSelected: number = this.selectedYear; + let dd: string = daySelected.toString(); + let mm: string = monthSelected.toString(); + const yyyy: string = yearSelected.toString(); + + if (daySelected < 10) { + dd = `0${dd}`; + } + if (monthSelected < 10) { + mm = `0${mm}`; + } + + this.formattedDate = getDateFormat( + dd, + mm, + yyyy, + this.localizationLanguageCode, + this.dateFormatInput + ); + } + + /* The functions #removePFPopoverDialogFromDOM() and #addPFPopoverDialogToDOM() are added in in order to prevent + the responsiveness issue created by pf-popover and can be removed once https://github.com/patternfly/patternfly-elements/issues/2648 is addressed. + + Issue: The pf-popover implementation maintains the popover dialog element in the DOM even when it is not visible or active. + This persistent presence in the DOM causes unintended visual effects, including spacing inconsistencies and + horizontal scroll issues, particularly when the popover is positioned at the far right edge of the page. */ + + #removePFPopoverDialogFromDOM() { + this._popover?.shadowRoot?.querySelector('dialog#popover')?. + setAttribute('style', 'display:none'); + } + + #addPFPopoverDialogToDOM() { + this._popover?.shadowRoot?.querySelector('dialog#popover')?. + setAttribute('style', 'display:block'); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'pf-date-picker': PfDatePicker; + } +} diff --git a/elements/pf-date-picker/pf-month-select.css b/elements/pf-date-picker/pf-month-select.css new file mode 100644 index 0000000000..d90b65dc7c --- /dev/null +++ b/elements/pf-date-picker/pf-month-select.css @@ -0,0 +1,74 @@ +:host { + display: block; +} + +.month-select-container { + font-family: var(--rh-font-family-body-text, RedHatText, "Red Hat Text", "Noto Sans Arabic", "Noto Sans Hebrew", "Noto Sans JP", "Noto Sans KR", "Noto Sans Malayalam", "Noto Sans SC", "Noto Sans TC", "Noto Sans Thai", Helvetica, Arial, sans-serif); + position: relative; + width: 140px; +} + +pf-button#date-picker-month-select { + width: 140px; + background: transparent; + font-family: var(--rh-font-family-body-text, RedHatText, "Red Hat Text", "Noto Sans Arabic", "Noto Sans Hebrew", "Noto Sans JP", "Noto Sans KR", "Noto Sans Malayalam", "Noto Sans SC", "Noto Sans TC", "Noto Sans Thai", Helvetica, Arial, sans-serif); + text-align: left; + display: flex; + align-items: center; + justify-content: space-between; + position: relative; + box-sizing: border-box; + height: 36px; +} + +.date-month-select-icon { + position: absolute; + top: 50%; + transform: translateY(-50%); + left: 92px; + width: 36px; + height: 34px; +} + +div#date-picker-month-select-popup { + width: 140px; + background: var(--rh-color-white, #ffffff); + box-shadow: 0 0.25rem 0.5rem 0 rgba(3, 3, 3, 0.12), 0 0 0.25rem 0 rgba(3, 3, 3, 0.06); + position: absolute; + top: 36px; + left: 0; + z-index: 1; +} + +div#date-picker-month-select-popup ul { + margin-block-start: 0; + margin-block-end: 0; + padding-inline-start: 0; + list-style: none; + padding: 8px 0; +} + +div#date-picker-month-select-popup pf-button { + --pf-c-button--m-plain--Color: var(--rh-color-canvas-black, #151515); + --pf-c-button--m-plain--hover--BackgroundColor: var(--rh-color-blue-50, #e7f1fa); + + width: 140px; + margin-bottom: .125rem; +} + +div#date-picker-month-select-popup pf-button { + background: var(--rh-color-white, #ffffff); + text-align: left; + font-family: var(--rh-font-family-body-text, RedHatText, "Red Hat Text", "Noto Sans Arabic", "Noto Sans Hebrew", "Noto Sans JP", "Noto Sans KR", "Noto Sans Malayalam", "Noto Sans SC", "Noto Sans TC", "Noto Sans Thai", Helvetica, Arial, sans-serif); + width: 140px; + border-radius: 0; + box-sizing: border-box; +} + +div#date-picker-month-select-popup pf-button:hover { + background:#f0f0f0; +} + +pf-button:focus, pf-button:focus-visible { + outline: var(--rh-color-accent-base-on-light, #0066cc) auto 2px; +} \ No newline at end of file diff --git a/elements/pf-date-picker/pf-month-select.ts b/elements/pf-date-picker/pf-month-select.ts new file mode 100644 index 0000000000..32a4c7d309 --- /dev/null +++ b/elements/pf-date-picker/pf-month-select.ts @@ -0,0 +1,152 @@ +import { LitElement, html } from 'lit'; +import { customElement } from 'lit/decorators/custom-element.js'; +import { property } from 'lit/decorators/property.js'; +import { bound } from '@patternfly/pfe-core/decorators.js'; +import { query } from 'lit/decorators/query.js'; +import styles from './pf-month-select.css'; +import { createRef, ref } from 'lit/directives/ref.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { getMonthNamesFromLocale } from './date-picker-helper.js'; + +export class MonthChangeEvent extends Event { + constructor(public event: Event, public name: string, public index: number) { + super('currentMonth', { bubbles: true, cancelable: true }); + } +} + +export class MonthPopupStateChangeEvent extends Event { + constructor(public event: Event, public isExpanded: boolean) { + super('monthExpandState', { bubbles: true, cancelable: true }); + } +} + +/** + * Month Select + * @slot - Place element content here + */ +@customElement('pf-month-select') +export class PfMonthSelect extends LitElement { + static readonly styles = [styles]; + + // Input properties from the parent + @property() translationLanguageCode!: string; + @property() monthNames: string[] = getMonthNamesFromLocale(this.translationLanguageCode); + @property() currentDate: Date = new Date(); + @property() currentMonthName: string = this.monthNames[this.currentDate.getMonth()]; + @property() currentMonthIndex: number = this.currentDate.getMonth(); + @property() isMonthExpanded = false; + @property() monthToBeFocused!: number; + + focusMonthRef: any = createRef(); // Reference to the month that needs to be focused on keyboard navigation + + @query('#date-picker-month-select') private datePickerMonthToggle!: HTMLButtonElement; + + connectedCallback() { + super.connectedCallback(); + document.addEventListener('click', this._onOutsideClick); + if (this.translationLanguageCode) { + this.monthNames = getMonthNamesFromLocale(this.translationLanguageCode); + this.currentMonthName = this.monthNames[this.currentDate.getMonth()]; + } + } + + render() { + return html` +
+
+ + ${this.currentMonthName} + +
+
+
    + ${this.monthNames.map((month: string, key: number) => { + return html` +
  • + this.#selectMonth(event, month, key)}>${month} + +
  • `; + })} +
+
+
+ `; + } + + disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener('click', this._onOutsideClick); + } + + // Function to focus month on keyboard navigation + updated(changedProperties: Map) { + if (changedProperties.has('monthToBeFocused')) { + const month: HTMLButtonElement | undefined = this.focusMonthRef.value!; + month?.focus(); + } + + if (changedProperties.has('translationLanguageCode')) { + this.monthNames = getMonthNamesFromLocale(this.translationLanguageCode); + this.currentMonthName = this.monthNames[this.currentMonthIndex]; + } + } + + // Function to handle closing of month select popup on clicking outside + @bound private _onOutsideClick(event: MouseEvent) { + const path = event.composedPath(); + if (!path.includes(this.datePickerMonthToggle) && this.isMonthExpanded) { + this.isMonthExpanded = false; + } + } + + // Function to dispatch selected month + #selectMonth(event: Event, monthName: string, monthIndex: number) { + this.currentMonthIndex = monthIndex; + if (monthName && (monthName.toString() !== this.currentMonthName)) { + this.currentMonthName = monthName.toString(); + this.dispatchEvent( + new MonthChangeEvent(event, this.currentMonthName, this.currentMonthIndex) + ); + } + this.isMonthExpanded = !this.isMonthExpanded; + } + + // Function to handle focus on keyboard navigation + #onMonthSelectKeydown(event: KeyboardEvent) { + switch (event.key) { + case 'ArrowUp': + if (this.monthToBeFocused > 0) { + this.monthToBeFocused = this.monthToBeFocused - 1; + } else { + this.monthToBeFocused = 11; + } + break; + case 'ArrowDown': + if (this.monthToBeFocused < 11) { + this.monthToBeFocused = this.monthToBeFocused + 1; + } else { + this.monthToBeFocused = 0; + } + break; + } + } + + // Function to dispatch value to handle the opening and closing of month select popup + #showMonthSelect(event: Event) { + this.isMonthExpanded = !this.isMonthExpanded; + this.monthToBeFocused = -1; + this.dispatchEvent(new MonthPopupStateChangeEvent(event, this.isMonthExpanded)); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'pf-month-select': PfMonthSelect; + } +} diff --git a/elements/pf-date-picker/pf-next-button.css b/elements/pf-date-picker/pf-next-button.css new file mode 100644 index 0000000000..9fa3db4714 --- /dev/null +++ b/elements/pf-date-picker/pf-next-button.css @@ -0,0 +1,21 @@ +:host { + display: block; +} + +.date-previous-next-button { + --pf-c-button--m-plain--Color: #6a6e73; + + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: var(--rh-color-white, #ffffff); + border: none; + line-height: 0.5rem; + box-sizing: border-box; +} + +pf-button:focus, pf-button:focus-visible { + outline: var(--rh-color-accent-base-on-light, #0066cc) auto 2px; +} \ No newline at end of file diff --git a/elements/pf-date-picker/pf-next-button.ts b/elements/pf-date-picker/pf-next-button.ts new file mode 100644 index 0000000000..a611905613 --- /dev/null +++ b/elements/pf-date-picker/pf-next-button.ts @@ -0,0 +1,52 @@ +import { LitElement, html } from 'lit'; +import { customElement } from 'lit/decorators/custom-element.js'; +import { property } from 'lit/decorators/property.js'; + +import styles from './pf-next-button.css'; + +export class NextButtonClickEvent extends Event { + constructor(public event: Event, public month: number, public year: number) { + super('nextMonthAndYear', { bubbles: true, cancelable: true }); + } +} + +/** + * Next Button + * @slot - Place element content here + */ +@customElement('pf-next-button') +export class PfNextButton extends LitElement { + static readonly styles = [styles]; + + // Input properties from the parent + @property() currentYear: number = (new Date).getFullYear(); + @property() currentMonth: number = (new Date).getMonth(); + + render() { + return html` + + + + `; + } + + // Function to dispatch month and year + #nextMonth(event: Event) { + // Increment the current month by one + this.currentMonth++; + + // If the current month is greater than 11 (December), set it to 0 (January) and increment the current year + if (this.currentMonth > 11) { + this.currentMonth = 0; + this.currentYear++; + } + + this.dispatchEvent(new NextButtonClickEvent(event, this.currentMonth, this.currentYear)); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'pf-next-button': PfNextButton; + } +} diff --git a/elements/pf-date-picker/pf-previous-button.css b/elements/pf-date-picker/pf-previous-button.css new file mode 100644 index 0000000000..f73131f05d --- /dev/null +++ b/elements/pf-date-picker/pf-previous-button.css @@ -0,0 +1,21 @@ +:host { + display: block; +} + +.date-previous-next-button { + --pf-c-button--m-plain--Color: #6a6e73; + + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: var(--rh-color-white, #ffffff); + border: none; + line-height: 0.5rem; + box-sizing: border-box; +} + +pf-button:focus, pf-button:focus-visible { + outline: var(--rh-color-accent-base-on-light, #0066cc) auto 2px; +} \ No newline at end of file diff --git a/elements/pf-date-picker/pf-previous-button.ts b/elements/pf-date-picker/pf-previous-button.ts new file mode 100644 index 0000000000..8352b1c1f4 --- /dev/null +++ b/elements/pf-date-picker/pf-previous-button.ts @@ -0,0 +1,50 @@ +import { LitElement, html } from 'lit'; +import { customElement } from 'lit/decorators/custom-element.js'; +import { property } from 'lit/decorators/property.js'; +import styles from './pf-previous-button.css'; + +export class PreviousButtonClickEvent extends Event { + constructor(public event: Event, public month: number, public year: number) { + super('previousMonthAndYear', { bubbles: true, cancelable: true }); + } +} +/** + * Previous Button + * @slot - Place element content here + */ +@customElement('pf-previous-button') +export class PfPreviousButton extends LitElement { + static readonly styles = [styles]; + + // Input properties from the parent + @property() currentYear: number = (new Date).getFullYear(); + @property() currentMonth: number = (new Date).getMonth(); + + render() { + return html` + + + + `; + } + + // Function to dispatch month and year + #previousMonth(event: Event) { + // Decrement the current month by one + this.currentMonth--; + + // If the current month is less than 0, set it to 11 (December) and decrement the current year + if (this.currentMonth < 0) { + this.currentMonth = 11; + this.currentYear--; + } + + this.dispatchEvent(new PreviousButtonClickEvent(event, this.currentMonth, this.currentYear)); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'pf-previous-button': PfPreviousButton; + } +} diff --git a/elements/pf-date-picker/pf-year-input.css b/elements/pf-date-picker/pf-year-input.css new file mode 100644 index 0000000000..0bf1506dc7 --- /dev/null +++ b/elements/pf-date-picker/pf-year-input.css @@ -0,0 +1,21 @@ +:host { + display: block; +} + +.date-year-input-container input { + height: 36px; + box-sizing: border-box; + border-radius: 0; + border: 1px solid #f0f0f0; + border-left: none; + border-bottom: 1px solid #8b8d90; + padding: 6px 8px; + font-size: var(--rh-font-size-body-text-md, 1rem); + font-family: var(--rh-font-family-body-text, RedHatText, "Red Hat Text", "Noto Sans Arabic", "Noto Sans Hebrew", "Noto Sans JP", "Noto Sans KR", "Noto Sans Malayalam", "Noto Sans SC", "Noto Sans TC", "Noto Sans Thai", Helvetica, Arial, sans-serif); + width: 80px; +} + +.date-year-input-container input:focus-visible { + border-left: 2px; + outline: var(--rh-color-accent-base-on-light, #0066cc) auto 2px; +} \ No newline at end of file diff --git a/elements/pf-date-picker/pf-year-input.ts b/elements/pf-date-picker/pf-year-input.ts new file mode 100644 index 0000000000..61c66e9fb8 --- /dev/null +++ b/elements/pf-date-picker/pf-year-input.ts @@ -0,0 +1,86 @@ +import { LitElement, html } from 'lit'; +import { customElement } from 'lit/decorators/custom-element.js'; +import { property } from 'lit/decorators/property.js'; +import { query } from 'lit/decorators/query.js'; + +import styles from './pf-year-input.css'; + +/** + * Year Input + * @slot - Place element content here + */ +@customElement('pf-year-input') +export class PfYearInput extends LitElement { + static readonly styles = [styles]; + + // Input properties from the parent + @property() currentYear: number = (new Date).getFullYear(); + @property() isValid = true; + @property() minDate!: Date; + @property() maxDate!: Date; + + #minYear = new Date(this.minDate).getFullYear(); + #maxYear = new Date(this.maxDate).getFullYear(); + + @query('#year-input') _numberInput!: HTMLInputElement; + + render() { + return html` +
+ +
+ `; + } + + // Function to hanlde year input on input + #OnInput(event: Event) { + const { value } = event.target as HTMLInputElement; + this.#dispatchInputYear(parseInt(value), 'setCurrentYear'); + } + + // Function to handle year input on step + #onChange(event: Event) { + const { value } = event.target as HTMLInputElement; + this.#dispatchInputYear(parseInt(value), 'currentYearOnStep'); + } + + // Function to dispatch year input + #dispatchInputYear(value: number, eventName: string) { + if (isNaN(value)) { + this.currentYear = 0; + } else { + this.currentYear = value; + } + + const options = { + detail: this.currentYear, + bubbles: true, + composed: true, + }; + this.dispatchEvent(new CustomEvent(eventName, options)); + } + + // Function to handle year input on keyUp + #onKeyUp(event: KeyboardEvent) { + if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { + this.#dispatchInputYear(this.currentYear, 'currentYearOnStep'); + } + } +} + + +declare global { + interface HTMLElementTagNameMap { + 'pf-year-input': PfYearInput; + } +} diff --git a/elements/pf-date-picker/test/pf-date-picker.e2e.ts b/elements/pf-date-picker/test/pf-date-picker.e2e.ts new file mode 100644 index 0000000000..b99ec8b803 --- /dev/null +++ b/elements/pf-date-picker/test/pf-date-picker.e2e.ts @@ -0,0 +1,12 @@ +import { test } from '@playwright/test'; +import { PfeDemoPage } from '@patternfly/pfe-tools/test/playwright/PfeDemoPage.js'; + +const tagName = 'pf-date-picker'; + +test.describe(tagName, () => { + test('snapshot', async ({ page }) => { + const componentPage = new PfeDemoPage(page, tagName); + await componentPage.navigate(); + await componentPage.snapshot(); + }); +}); diff --git a/elements/pf-date-picker/test/pf-date-picker.spec.ts b/elements/pf-date-picker/test/pf-date-picker.spec.ts new file mode 100644 index 0000000000..d6d969dee3 --- /dev/null +++ b/elements/pf-date-picker/test/pf-date-picker.spec.ts @@ -0,0 +1,21 @@ +import { expect, html } from '@open-wc/testing'; +import { createFixture } from '@patternfly/pfe-tools/test/create-fixture.js'; +import { PfDatePicker } from '@patternfly/elements/pf-date-picker/pf-date-picker.js'; + +describe('', function() { + describe('simply instantiating', function() { + let element: PfDatePicker; + it('imperatively instantiates', function() { + expect(document.createElement('pf-date-picker')).to.be.an.instanceof(PfDatePicker); + }); + + it('should upgrade', async function() { + element = await createFixture(html``); + const klass = customElements.get('pf-date-picker'); + expect(element) + .to.be.an.instanceOf(klass) + .and + .to.be.an.instanceOf(PfDatePicker); + }); + }); +}); diff --git a/elements/pf-tabs/pf-tab-panel.ts b/elements/pf-tabs/pf-tab-panel.ts index a81b028ca2..d70f883310 100644 --- a/elements/pf-tabs/pf-tab-panel.ts +++ b/elements/pf-tabs/pf-tab-panel.ts @@ -11,9 +11,7 @@ import styles from './pf-tab-panel.css'; /** * @slot - Tab panel content - * * @cssprop {} --pf-c-tab-content--m-light-300 {@default `#f0f0f0`} - * * @csspart container - container for the panel content */ @customElement('pf-tab-panel')