diff --git a/.circleci/config.yml b/.circleci/config.yml index bb45cbc278d..947e73add36 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -10,7 +10,7 @@ executors: parameters: current_golden_images_hash: type: string - default: 07a5cba6726f993d5ddc38a9c30e9c9e3e94ebc7 + default: ee8f9f1cc2fbe29917c792fd40bdb6fce2f622bd wireit_cache_name: type: string default: wireit diff --git a/packages/calendar/package.json b/packages/calendar/package.json index 14614ba5a82..843908042fb 100644 --- a/packages/calendar/package.json +++ b/packages/calendar/package.json @@ -69,8 +69,7 @@ "@spectrum-web-components/reactive-controllers": "^0.41.2" }, "devDependencies": { - "@spectrum-css/calendar": "^4.2.4", - "@spectrum-web-components/story-decorator": "^0.41.2" + "@spectrum-css/calendar": "^4.2.4" }, "types": "./src/index.d.ts", "customElements": "custom-elements.json", diff --git a/packages/calendar/src/Calendar.ts b/packages/calendar/src/Calendar.ts index cc48871e492..815b7ca80ff 100644 --- a/packages/calendar/src/Calendar.ts +++ b/packages/calendar/src/Calendar.ts @@ -15,6 +15,7 @@ import { getLocalTimeZone, getWeeksInMonth, isSameDay, + parseDate, startOfMonth, startOfWeek, today, @@ -36,9 +37,12 @@ import { classMap, ifDefined, } from '@spectrum-web-components/base/src/directives.js'; -import { LanguageResolutionController } from '@spectrum-web-components/reactive-controllers/src/LanguageResolution.js'; +import { + LanguageResolutionController, + languageResolverUpdatedSymbol, +} from '@spectrum-web-components/reactive-controllers/src/LanguageResolution.js'; -import { CalendarWeekday, daysInWeek } from './types.js'; +import { CalendarWeekday, DateCellProperties } from './types.js'; import styles from './calendar.css.js'; @@ -46,6 +50,7 @@ import '@spectrum-web-components/action-button/sp-action-button.js'; import '@spectrum-web-components/icons-workflow/icons/sp-icon-chevron-left.js'; import '@spectrum-web-components/icons-workflow/icons/sp-icon-chevron-right.js'; +export const DAYS_PER_WEEK = 7; /** * @element sp-calendar * @@ -60,43 +65,72 @@ export class Calendar extends SpectrumElement { } /** - * Date used to display the calendar. If no date is given, the current month will be used + * The selected date in the calendar. If defined, this also indicates where the calendar opens. + * If not, the calendar opens at the current month. */ @property({ attribute: false }) - selectedDate?: Date; + public set selectedDate(date: Date) { + if (!this.isValidDate(date)) return; + + this._selectedDate = this.toCalendarDate(date); + this.currentDate = this._selectedDate; + + this.requestUpdate('selectedDate', this._selectedDate); + } + + public get selectedDate(): Date { + if (!this._selectedDate) return new Date('Invalid Date'); + return this._selectedDate?.toDate(this.timeZone); + } + private _selectedDate?: CalendarDate; + + /** + * The date that indicates the current position in the calendar. + */ + @state() + private currentDate: CalendarDate = this.today; /** * The minimum allowed date a user can select */ @property({ attribute: false }) min?: Date; + private minDate?: CalendarDate; /** * The maximum allowed date a user can select */ @property({ attribute: false }) max?: Date; + private maxDate?: CalendarDate; /** * Indicates when the calendar should be disabled entirely */ @property({ type: Boolean, reflect: true }) - disabled = false; + public disabled = false; /** * Adds a padding around the calendar */ @property({ type: Boolean, reflect: true }) - padded = false; + public padded = false; - @state() - private currentDate!: CalendarDate; + private languageResolver = new LanguageResolutionController(this); - @state() - private minDate!: CalendarDate; + /** + * The locale used to format the dates and weekdays. + * The default value is the language of the document or the user's browser. + */ + private get locale(): string { + return this.languageResolver.language; + } - @state() - private maxDate!: CalendarDate; + // TODO: Implement a cache mechanism to store the value of `today` + private timeZone: string = getLocalTimeZone(); + public get today(): CalendarDate { + return today(this.timeZone); + } @state() private weeksInCurrentMonth: number[] = []; @@ -104,40 +138,60 @@ export class Calendar extends SpectrumElement { @state() private weekdays: CalendarWeekday[] = []; - private languageResolver = new LanguageResolutionController(this); - private timeZone: string = getLocalTimeZone(); + @state() + protected set isDateFocusIntent(value: boolean) { + if (this._isDateFocusIntent === value) return; - private get locale(): string { - return this.languageResolver.language; + this._isDateFocusIntent = value; + this.requestUpdate('isDateFocusIntent', !value); } - // TODO: Implement a cache mechanism to store the value of `today` and use this value to initialise `currentDate` - public get today(): CalendarDate { - return today(this.timeZone); + protected get isDateFocusIntent(): boolean { + return this._isDateFocusIntent; } + private _isDateFocusIntent: boolean = false; - constructor() { - super(); - this.setInitialCalendarDate(); + private setDateFocusIntent(): void { + this.isDateFocusIntent = true; } - protected override willUpdate(changedProperties: PropertyValues): void { - if (changedProperties.has('selectedDate')) { - this.setCurrentCalendarDate(); - } + private resetDateFocusIntent(): void { + this.isDateFocusIntent = false; + } - if (changedProperties.has('min')) { - this.setMinCalendarDate(); - } + override connectedCallback(): void { + super.connectedCallback(); + document.addEventListener('mousedown', this.resetDateFocusIntent); + } - if (changedProperties.has('max')) { - this.setMaxCalendarDate(); - } + override disconnectedCallback(): void { + super.disconnectedCallback(); + document.removeEventListener('mousedown', this.resetDateFocusIntent); + } - this.setWeeksInCurrentMonth(); + override willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has('min')) this.setMinCalendarDate(); + + if (changedProperties.has('max')) this.setMaxCalendarDate(); - // TODO: Include a condition to run the `setWeekdays()` method only when really needed - this.setWeekdays(); + if (changedProperties.has(languageResolverUpdatedSymbol)) { + this.setNumberFormatter(); + this.setWeekdays(); + this.setWeeksInCurrentMonth(); + } + } + + override updated(changedProperties: PropertyValues): void { + /** + * Keeps the focus on the correct day when navigating through the calendar. + * Particularly useful when the month changes and the focus is lost. + */ + if (changedProperties.has('currentDate') && this.isDateFocusIntent) { + const elementToFocus = this.shadowRoot?.querySelector( + 'td[tabindex="0"]' + ) as HTMLElement; + elementToFocus.focus(); + } } protected override render(): TemplateResult { @@ -146,20 +200,20 @@ export class Calendar extends SpectrumElement { `; } - public renderCalendarHeader(): TemplateResult { + protected renderCalendarHeader(): TemplateResult { const monthAndYear = this.formatDate(this.currentDate, { month: 'long', year: 'numeric', }); return html` -
+
- + ${this.renderCalendarTableHead()} ${this.renderCalendarTableBody()} @@ -230,7 +286,7 @@ export class Calendar extends SpectrumElement { `; } - public renderCalendarTableHead(): TemplateResult { + protected renderCalendarTableHead(): TemplateResult { return html` @@ -242,21 +298,17 @@ export class Calendar extends SpectrumElement { `; } - public renderWeekdayColumn(weekday: CalendarWeekday): TemplateResult { + protected renderWeekdayColumn(weekday: CalendarWeekday): TemplateResult { return html` - - + + ${weekday.narrow} `; } - public renderCalendarTableBody(): TemplateResult { + protected renderCalendarTableBody(): TemplateResult { return html` ${this.weeksInCurrentMonth.map((weekIndex) => @@ -266,7 +318,7 @@ export class Calendar extends SpectrumElement { `; } - public renderCalendarTableRow(weekIndex: number): TemplateResult { + protected renderCalendarTableRow(weekIndex: number): TemplateResult { return html` ${this.getDatesInWeek(weekIndex).map((calendarDate) => @@ -276,24 +328,44 @@ export class Calendar extends SpectrumElement { `; } - public renderCalendarTableCell(calendarDate: CalendarDate): TemplateResult { - const isOutsideMonth = calendarDate.month !== this.currentDate.month; + private parseDateCellProperties( + calendarDate: CalendarDate + ): DateCellProperties { + const props = { + isOutsideMonth: false, + isSelected: false, + isToday: false, + isDisabled: false, + isTabbable: false, + }; + props.isOutsideMonth = calendarDate.month !== this.currentDate.month; + if (props.isOutsideMonth) return props; - const isSelected = Boolean( - this.selectedDate && - isSameDay(this.toCalendarDate(this.selectedDate), calendarDate) - ); + props.isDisabled = + this.disabled || + this.isMinLimitReached(calendarDate) || + this.isMaxLimitReached(calendarDate); - const isToday = isSameDay(calendarDate, this.today); + props.isToday = isSameDay(calendarDate, this.today); - const isDisabled = Boolean( - this.disabled || - (this.minDate && calendarDate.compare(this.minDate) < 0) || - (this.maxDate && calendarDate.compare(this.maxDate) > 0) + if (props.isDisabled) return props; + props.isTabbable = isSameDay(calendarDate, this.currentDate); + + props.isSelected = Boolean( + this._selectedDate && isSameDay(this._selectedDate, calendarDate) ); + return props; + } + + protected renderCalendarTableCell( + calendarDate: CalendarDate + ): TemplateResult { + const { isOutsideMonth, isSelected, isToday, isDisabled, isTabbable } = + this.parseDateCellProperties(calendarDate); + const dayClasses: ClassInfo = { - 'spectrum-Calendar-date': true, + date: true, 'is-outsideMonth': isOutsideMonth, 'is-selected': isSelected, 'is-today': isToday, @@ -311,17 +383,20 @@ export class Calendar extends SpectrumElement { return html` this.handleDayClick(calendarDate)} > ${this.formatNumber(calendarDate.day)} @@ -329,28 +404,172 @@ export class Calendar extends SpectrumElement { `; } - public handlePreviousMonth(): void { - this.currentDate = startOfMonth(this.currentDate).subtract({ - months: 1, - }); - } + private handleDaySelect(event: MouseEvent | KeyboardEvent): void { + if (this.disabled) { + event.preventDefault(); + return; + } - public handleNextMonth(): void { - this.currentDate = startOfMonth(this.currentDate).add({ months: 1 }); - } + const dateCell = (event.target as Element).closest( + 'td.tableCell' + ) as HTMLTableCellElement; + + if (event instanceof MouseEvent) { + const dateContent = dateCell.querySelector('span')!; + if (!this.isClickInsideContentRadius(event, dateContent)) { + event.preventDefault(); + return; + } + } + + const dateString = dateCell.dataset.value!; + const calendarDateEngaged = parseDate(dateString); + const isAlreadySelected = + this._selectedDate && + isSameDay(this._selectedDate, calendarDateEngaged); + + if ( + isAlreadySelected || + this.isMinLimitReached(calendarDateEngaged) || + this.isMaxLimitReached(calendarDateEngaged) + ) { + event.preventDefault(); + return; + } - public handleDayClick(calendarDate: CalendarDate): void { - this.selectedDate = calendarDate.toDate(this.timeZone); + this.currentDate = calendarDateEngaged; + this.selectedDate = calendarDateEngaged.toDate(this.timeZone); this.dispatchEvent( new CustomEvent('change', { bubbles: true, composed: true, - detail: this.selectedDate, }) ); } + private isClickInsideContentRadius( + event: MouseEvent, + element: HTMLElement + ): boolean { + const rect = element.getBoundingClientRect(); + const radius = rect.width / 2; + const centerX = rect.left + radius; + const centerY = rect.top + radius; + const clickCenterDistance = Math.sqrt( + Math.pow(event.clientX - centerX, 2) + + Math.pow(event.clientY - centerY, 2) + ); + + return clickCenterDistance <= radius; + } + + private handlePreviousMonth(): void { + const isSelectedInPreviousMonth = + this._selectedDate?.month === this.currentDate.month - 1; + const isTodayInPreviousMonth = + this.today.month === this.currentDate.month - 1; + + if (isSelectedInPreviousMonth) this.currentDate = this._selectedDate!; + else if (isTodayInPreviousMonth) this.currentDate = this.today; + else + this.currentDate = startOfMonth(this.currentDate).subtract({ + months: 1, + }); + + this.setWeeksInCurrentMonth(); + } + + private handleNextMonth(): void { + const isSelectedInNextMonth = + this._selectedDate?.month === this.currentDate.month + 1; + const isTodayInNextMonth = + this.today.month === this.currentDate.month + 1; + + if (isSelectedInNextMonth) this.currentDate = this._selectedDate!; + else if (isTodayInNextMonth) this.currentDate = this.today; + else + this.currentDate = startOfMonth(this.currentDate).add({ + months: 1, + }); + + this.setWeeksInCurrentMonth(); + } + + private handleKeydown(event: KeyboardEvent): void { + this.setDateFocusIntent(); + + const initialMonth = this.currentDate.month; + + switch (event.code) { + case 'ArrowLeft': { + this.focusPreviousDay(); + break; + } + case 'ArrowDown': { + this.focusNextWeek(); + break; + } + case 'ArrowRight': { + this.focusNextDay(); + break; + } + case 'ArrowUp': { + this.focusPreviousWeek(); + break; + } + case 'Space': + case 'Enter': { + this.handleDaySelect(event); + break; + } + } + + if (this.currentDate.month !== initialMonth) + this.setWeeksInCurrentMonth(); + } + + private focusPreviousDay(): void { + const previousDay = this.currentDate.subtract({ days: 1 }); + if (!this.isMinLimitReached(previousDay)) + this.currentDate = previousDay; + } + + private focusNextDay(): void { + const nextDay = this.currentDate.add({ days: 1 }); + if (!this.isMaxLimitReached(nextDay)) this.currentDate = nextDay; + } + + private focusPreviousWeek(): void { + const previousWeek = this.currentDate.subtract({ weeks: 1 }); + if (!this.isMinLimitReached(previousWeek)) { + this.currentDate = previousWeek; + return; + } + + let dayToFocus = previousWeek.add({ days: 1 }); + while (this.isMinLimitReached(dayToFocus)) { + dayToFocus = dayToFocus.add({ days: 1 }); + } + this.currentDate = dayToFocus; + } + + private focusNextWeek(): void { + const nextWeek = this.currentDate.add({ weeks: 1 }); + + if (!this.isMaxLimitReached(nextWeek)) { + this.currentDate = nextWeek; + + return; + } + + let dayToFocus = nextWeek.subtract({ days: 1 }); + while (this.isMaxLimitReached(dayToFocus)) { + dayToFocus = dayToFocus.subtract({ days: 1 }); + } + this.currentDate = dayToFocus; + } + /** * Defines the array with the indexes (starting at zero) of the weeks of the current month */ @@ -367,7 +586,7 @@ export class Calendar extends SpectrumElement { private setWeekdays(): void { const weekStart = startOfWeek(this.currentDate, this.locale); - this.weekdays = [...new Array(daysInWeek).keys()].map((dayIndex) => { + this.weekdays = [...new Array(DAYS_PER_WEEK).keys()].map((dayIndex) => { const date = weekStart.add({ days: dayIndex }); return { @@ -377,41 +596,12 @@ export class Calendar extends SpectrumElement { }); } - /** - * Defines the initial date that will be used to render the calendar, if no specific date is provided - */ - private setInitialCalendarDate(): void { - this.currentDate = this.today; - } - - /** - * If a date is received by the component via property, it uses that date as the current date to render the calendar - */ - private setCurrentCalendarDate(): void { - if (!this.selectedDate) { - return; - } - - this.selectedDate = new Date(this.selectedDate); - - if (!this.isValidDate(this.selectedDate)) { - this.selectedDate = undefined; - return; - } - - this.currentDate = this.toCalendarDate(this.selectedDate); - } - /** * Sets the minimum allowed date a user can select by converting a `Date` object to `CalendarDate`, which is the * type of object used internally by the class */ private setMinCalendarDate(): void { - if (!this.min) { - return; - } - - this.min = new Date(this.min); + if (!this.min) return; if (!this.isValidDate(this.min)) { this.min = undefined; @@ -426,11 +616,7 @@ export class Calendar extends SpectrumElement { * type of object used internally by the class */ private setMaxCalendarDate(): void { - if (!this.max) { - return; - } - - this.max = new Date(this.max); + if (!this.max) return; if (!this.isValidDate(this.max)) { this.max = undefined; @@ -450,11 +636,13 @@ export class Calendar extends SpectrumElement { const dates: CalendarDate[] = []; let date = startOfWeek( - startOfMonth(this.currentDate).add({ weeks: weekIndex }), + startOfMonth(this.currentDate).add({ + weeks: weekIndex, + }), this.locale ); - while (dates.length < daysInWeek) { + while (dates.length < DAYS_PER_WEEK) { dates.push(date); const nextDate = date.add({ days: 1 }); @@ -489,13 +677,22 @@ export class Calendar extends SpectrumElement { * @param date - `Date` object to validate */ private isValidDate(date: Date): boolean { + date = new Date(date); return !isNaN(date.getTime()); } + private isMinLimitReached(calendarDate: CalendarDate): boolean { + return Boolean(this.minDate && calendarDate.compare(this.minDate) < 0); + } + + private isMaxLimitReached(calendarDate: CalendarDate): boolean { + return Boolean(this.maxDate && calendarDate.compare(this.maxDate) > 0); + } + /** * Formats a `CalendarDate` object using the current locale and the provided date format options * - * @param calendarDate - The `CalendarDate` object that will be used by the formatter + * @param calendarDate - The `CalendarDate` object that will be formatted * @param options - All date format options that will be used by the formatter */ private formatDate( @@ -507,12 +704,12 @@ export class Calendar extends SpectrumElement { ); } - /** - * Formats a number using the defined locale - * - * @param number - The number to format - */ + private numberFormatter = new NumberFormatter(this.locale); + private setNumberFormatter(): void { + this.numberFormatter = new NumberFormatter(this.locale); + } + private formatNumber(number: number): string { - return new NumberFormatter(this.locale).format(number); + return this.numberFormatter.format(number); } } diff --git a/packages/calendar/src/spectrum-calendar.css b/packages/calendar/src/spectrum-calendar.css index 0a5b1855103..54ea1c1536a 100644 --- a/packages/calendar/src/spectrum-calendar.css +++ b/packages/calendar/src/spectrum-calendar.css @@ -80,13 +80,13 @@ governing permissions and limitations under the License. var(--mod-calendar-margin-y, var(--spectrum-calendar-margin-y)); } -.spectrum-Calendar-header { +.header { align-items: center; inline-size: 100%; display: flex; } -.spectrum-Calendar-title { +.title { color: var( --highcontrast-calendar-day-title-text-color, var( @@ -112,13 +112,13 @@ governing permissions and limitations under the License. overflow: hidden; } -.spectrum-Calendar-prevMonth, -.spectrum-Calendar-nextMonth { +.prevMonth, +.nextMonth { transform: var(--spectrum-logical-rotation); } -.spectrum-Calendar-prevMonth:not([disabled]), -.spectrum-Calendar-nextMonth:not([disabled]) { +.prevMonth:not([disabled]), +.nextMonth:not([disabled]) { color: var( --highcontrast-calendar-button-icon-color, var( @@ -128,15 +128,15 @@ governing permissions and limitations under the License. ); } -.spectrum-Calendar-prevMonth { +.prevMonth { order: 0; } -.spectrum-Calendar-nextMonth { +.nextMonth { order: 2; } -.spectrum-Calendar-dayOfWeek { +.dayOfWeek { inline-size: var( --mod-calendar-day-width, var(--spectrum-calendar-day-width) @@ -166,7 +166,7 @@ governing permissions and limitations under the License. text-decoration: none !important; } -:host([title]) .spectrum-Calendar-dayOfWeek { +:host([title]) .dayOfWeek { text-decoration: underline; letter-spacing: var( --mod-calendar-title-text-letter-spacing, @@ -176,11 +176,11 @@ governing permissions and limitations under the License. text-decoration: underline dotted; } -.spectrum-Calendar-body { +.body { outline: none; } -.spectrum-Calendar-table { +.table { table-layout: fixed; border-collapse: collapse; border-spacing: 0; @@ -188,7 +188,7 @@ governing permissions and limitations under the License. user-select: none; } -.spectrum-Calendar-tableCell { +.tableCell { text-align: center; box-sizing: content-box; block-size: var( @@ -206,11 +206,11 @@ governing permissions and limitations under the License. position: relative; } -.spectrum-Calendar-tableCell:focus { +.tableCell:focus { outline: 0; } -.spectrum-Calendar-date { +.date { box-sizing: border-box; block-size: var( --mod-calendar-day-height, @@ -252,25 +252,25 @@ governing permissions and limitations under the License. inset-inline-start: 0; } -.spectrum-Calendar-date:lang(ja), -.spectrum-Calendar-date:lang(zh), -.spectrum-Calendar-date:lang(ko) { +.date:lang(ja), +.date:lang(zh), +.date:lang(ko) { font-size: var( --mod-calendar-day-text-size-han, var(--spectrum-calendar-day-text-size-han) ); } -.spectrum-Calendar-date.is-disabled { +.date.is-disabled { cursor: default; pointer-events: none; } -.spectrum-Calendar-date.is-outsideMonth { +.date.is-outsideMonth { display: none; } -.spectrum-Calendar-date:before { +.date:before { content: ''; box-sizing: border-box; inline-size: var( @@ -301,25 +301,25 @@ governing permissions and limitations under the License. ); } -.spectrum-Calendar-date.is-selected:not(.is-range-selection) { +.date.is-selected:not(.is-range-selection) { font-weight: var( --mod-calendar-day-text-font-weight-selected, var(--spectrum-calendar-day-text-font-weight-selected) ); } -.spectrum-Calendar-date.is-selected:not(.is-range-selection):before { +.date.is-selected:not(.is-range-selection):before { display: none; } -.spectrum-Calendar-date.is-today { +.date.is-today { font-weight: var( --mod-calendar-day-today-text-font-weight, var(--spectrum-calendar-day-today-text-font-weight) ); } -.spectrum-Calendar-date.is-range-selection { +.date.is-range-selection { margin: var( --mod-calendar-day-padding, var(--spectrum-calendar-day-padding) @@ -346,10 +346,10 @@ governing permissions and limitations under the License. ); } -.spectrum-Calendar-date.is-range-selection.is-range-start, -.spectrum-Calendar-date.is-range-selection.is-range-end, -.spectrum-Calendar-date.is-range-selection.is-selection-start, -.spectrum-Calendar-date.is-range-selection.is-selection-end { +.date.is-range-selection.is-range-start, +.date.is-range-selection.is-range-end, +.date.is-range-selection.is-selection-start, +.date.is-range-selection.is-selection-end { inline-size: calc( var(--mod-calendar-day-width, var(--spectrum-calendar-day-width)) + var( @@ -359,16 +359,16 @@ governing permissions and limitations under the License. ); } -.spectrum-Calendar-date.is-range-selection.is-selection-start, -.spectrum-Calendar-date.is-range-selection.is-selection-end { +.date.is-range-selection.is-selection-start, +.date.is-range-selection.is-selection-end { font-weight: var( --mod-calendar-day-text-font-weight-cap-selected, var(--spectrum-calendar-day-text-font-weight-cap-selected) ); } -.spectrum-Calendar-date.is-range-selection.is-selection-start:after, -.spectrum-Calendar-date.is-range-selection.is-selection-end:after { +.date.is-range-selection.is-selection-start:after, +.date.is-range-selection.is-selection-end:after { block-size: var( --mod-calendar-day-height, var(--spectrum-calendar-day-height) @@ -387,8 +387,8 @@ governing permissions and limitations under the License. inset-block-start: 0; } -.spectrum-Calendar-date.is-range-selection.is-range-start, -.spectrum-Calendar-date.is-range-selection.is-selection-start { +.date.is-range-selection.is-range-start, +.date.is-range-selection.is-selection-start { border-start-start-radius: var( --mod-calendar-day-width, var(--spectrum-calendar-day-width) @@ -407,15 +407,15 @@ governing permissions and limitations under the License. ); } -.spectrum-Calendar-date.is-range-selection.is-range-start:before, -.spectrum-Calendar-date.is-range-selection.is-range-start:after, -.spectrum-Calendar-date.is-range-selection.is-selection-start:before, -.spectrum-Calendar-date.is-range-selection.is-selection-start:after { +.date.is-range-selection.is-range-start:before, +.date.is-range-selection.is-range-start:after, +.date.is-range-selection.is-selection-start:before, +.date.is-range-selection.is-selection-start:after { inset-inline-start: 0; } -.spectrum-Calendar-date.is-range-selection.is-range-end, -.spectrum-Calendar-date.is-range-selection.is-selection-end { +.date.is-range-selection.is-range-end, +.date.is-range-selection.is-selection-end { border-start-end-radius: var( --mod-calendar-day-width, var(--spectrum-calendar-day-width) @@ -434,17 +434,17 @@ governing permissions and limitations under the License. ); } -.spectrum-Calendar-date.is-range-selection.is-range-end:before, -.spectrum-Calendar-date.is-range-selection.is-range-end:after, -.spectrum-Calendar-date.is-range-selection.is-selection-end:before, -.spectrum-Calendar-date.is-range-selection.is-selection-end:after { +.date.is-range-selection.is-range-end:before, +.date.is-range-selection.is-range-end:after, +.date.is-range-selection.is-selection-end:before, +.date.is-range-selection.is-selection-end:after { inset-inline: auto 0; } -.spectrum-Calendar-date.is-range-selection.is-selection-start.is-selection-end, -.spectrum-Calendar-date.is-range-selection.is-selection-start.is-range-end, -.spectrum-Calendar-date.is-range-selection.is-selection-end.is-range-start, -.spectrum-Calendar-date.is-range-selection.is-range-start.is-range-end { +.date.is-range-selection.is-selection-start.is-selection-end, +.date.is-range-selection.is-selection-start.is-range-end, +.date.is-range-selection.is-selection-end.is-range-start, +.date.is-range-selection.is-range-start.is-range-end { inline-size: var( --mod-calendar-day-width, var(--spectrum-calendar-day-width) @@ -455,7 +455,7 @@ governing permissions and limitations under the License. ); } -.spectrum-Calendar-date { +.date { color: var( --highcontrast-calendar-day-title-text-color, var( @@ -465,7 +465,7 @@ governing permissions and limitations under the License. ); } -.spectrum-Calendar-date:active { +.date:active { background-color: var( --highcontrast-calendar-day-background-color-down, var( @@ -475,7 +475,7 @@ governing permissions and limitations under the License. ); } -.spectrum-Calendar-date.is-selected { +.date.is-selected { color: var( --highcontrast-calendar-day-text-color-selected, var( @@ -492,7 +492,7 @@ governing permissions and limitations under the License. ); } -.spectrum-Calendar-date.is-selected:not(.is-range-selection) { +.date.is-selected:not(.is-range-selection) { background: var( --highcontrast-calendar-day-background-color-cap-selected, var( @@ -502,7 +502,7 @@ governing permissions and limitations under the License. ); } -.spectrum-Calendar-date.is-today { +.date.is-today { color: var( --highcontrast-calendar-day-today-text-color, var( @@ -519,7 +519,7 @@ governing permissions and limitations under the License. ); } -.spectrum-Calendar-date.is-today:before { +.date.is-today:before { border-color: var( --highcontrast-calendar-day-today-border-color, var( @@ -530,7 +530,7 @@ governing permissions and limitations under the License. } @media (hover: hover) { - .spectrum-Calendar-date:hover.is-selected:not(.is-selection-end):not(.is-selection-start):before { + .date:hover.is-selected:not(.is-selection-end):not(.is-selection-start):before { background: var( --highcontrast-calendar-day-background-color-selected-hover, var( @@ -540,7 +540,7 @@ governing permissions and limitations under the License. ); } - .spectrum-Calendar-date:hover { + .date:hover { color: var( --highcontrast-calendar-day-text-color-hover, var( @@ -550,7 +550,7 @@ governing permissions and limitations under the License. ); } - .spectrum-Calendar-date:hover:not(.is-selection-end):not(.is-selection-start):before { + .date:hover:not(.is-selection-end):not(.is-selection-start):before { background: var( --highcontrast-calendar-day-background-color-hover, var( @@ -560,7 +560,7 @@ governing permissions and limitations under the License. ); } - .spectrum-Calendar-date:hover.is-range-selection:before { + .date:hover.is-range-selection:before { background: var( --highcontrast-calendar-day-background-color-selected-hover, var( @@ -570,7 +570,7 @@ governing permissions and limitations under the License. ); } - .spectrum-Calendar-date.is-today:hover.is-selected:not(.is-range-selection):before { + .date.is-today:hover.is-selected:not(.is-range-selection):before { background: var( --highcontrast-calendar-day-today-background-color-selected-hover, var( @@ -583,7 +583,7 @@ governing permissions and limitations under the License. } } -.spectrum-Calendar-date.is-today.is-disabled { +.date.is-today.is-disabled { color: var( --highcontrast-calendar-day-today-text-color-disabled, var( @@ -600,7 +600,7 @@ governing permissions and limitations under the License. ); } -.spectrum-Calendar-date.is-today.is-disabled:before { +.date.is-today.is-disabled:before { border-color: var( --highcontrast-calendar-day-today-border-color-disabled, var( @@ -610,7 +610,7 @@ governing permissions and limitations under the License. ); } -.spectrum-Calendar-date.is-focused:not(.is-range-selection) { +.tableCell:focus-within .date:not(.is-range-selection) { background: var( --highcontrast-calendar-day-background-color-key-focus, var( @@ -634,7 +634,7 @@ governing permissions and limitations under the License. ); } -.spectrum-Calendar-date.is-focused:not(.is-range-selection).is-today { +.tableCell:focus-within .date:not(.is-range-selection).is-today { border-color: var( --highcontrast-calendar-day-border-color-key-focus, var( @@ -644,8 +644,8 @@ governing permissions and limitations under the License. ); } -.spectrum-Calendar-date.is-focused:not(.is-range-selection):active, -.spectrum-Calendar-date.is-focused:not(.is-range-selection).is-selected { +.tableCell:focus-within .date:not(.is-range-selection):active, +.tableCell:focus-within .date:not(.is-range-selection).is-selected { color: var( --highcontrast-calendar-day-text-color-selected, var( @@ -669,8 +669,8 @@ governing permissions and limitations under the License. ); } -.spectrum-Calendar-date.is-focused.is-selected:before, -.spectrum-Calendar-date.is-focused.is-range-selection:before { +.tableCell:focus-within .date.is-selected:before, +.tableCell:focus-within .date.is-range-selection:before { background: var( --highcontrast-calendar-day-background-color-selected-hover, var( @@ -680,7 +680,7 @@ governing permissions and limitations under the License. ); } -.spectrum-Calendar-date.is-focused:before { +.tableCell:focus-within .date:before { border-color: var( --highcontrast-calendar-day-border-color-key-focus, var( @@ -690,7 +690,7 @@ governing permissions and limitations under the License. ); } -.spectrum-Calendar-date.is-disabled { +.date.is-disabled { color: var( --highcontrast-calendar-day-text-color-disabled, var( @@ -700,8 +700,8 @@ governing permissions and limitations under the License. ); } -.spectrum-Calendar-date.is-selection-start, -.spectrum-Calendar-date.is-selection-end { +.date.is-selection-start, +.date.is-selection-end { color: var( --highcontrast-calendar-day-text-color-cap-selected, var( @@ -711,8 +711,8 @@ governing permissions and limitations under the License. ); } -.spectrum-Calendar-date.is-selection-start:after, -.spectrum-Calendar-date.is-selection-end:after { +.date.is-selection-start:after, +.date.is-selection-end:after { background-color: var( --highcontrast-calendar-day-background-color-selected, var( @@ -722,8 +722,8 @@ governing permissions and limitations under the License. ); } -.spectrum-Calendar-date.is-selection-start.is-disabled, -.spectrum-Calendar-date.is-selection-end.is-disabled { +.date.is-selection-start.is-disabled, +.date.is-selection-end.is-disabled { color: var( --highcontrast-calendar-day-text-color-disabled, var( @@ -734,13 +734,13 @@ governing permissions and limitations under the License. } @media (forced-colors: active) { - .spectrum-Calendar-prevMonth, - .spectrum-Calendar-nextMonth { + .prevMonth, + .nextMonth { --highcontrast-calendar-button-icon-color-disabled: GrayText; --highcontrast-calendar-button-icon-color: ButtonText; } - .spectrum-Calendar-date { + .date { color: canvastext; forced-color-adjust: none; --highcontrast-calendar-day-background-color-cap-selected: Highlight; @@ -764,17 +764,17 @@ governing permissions and limitations under the License. --highcontrast-calendar-day-today-text-color: ButtonText; } - .spectrum-Calendar-date.is-range-selection.is-today { + .date.is-range-selection.is-today { color: highlighttext; } - .spectrum-Calendar-date.is-range-selection.is-selection-start:after, - .spectrum-Calendar-date.is-range-selection.is-selection-end:after { + .date.is-range-selection.is-selection-start:after, + .date.is-range-selection.is-selection-end:after { content: none; } - .spectrum-Calendar-date.is-disabled.is-range-selection, - .spectrum-Calendar-date.is-disabled.is-selected { + .date.is-disabled.is-range-selection, + .date.is-disabled.is-selected { color: highlighttext; background: highlight; } diff --git a/packages/calendar/src/spectrum-config.js b/packages/calendar/src/spectrum-config.js index c83b888d563..e066e80013a 100644 --- a/packages/calendar/src/spectrum-config.js +++ b/packages/calendar/src/spectrum-config.js @@ -11,7 +11,10 @@ governing permissions and limitations under the License. */ // @ts-check -import { converterFor } from '../../../tasks/process-spectrum-utils.js'; +import { + builder, + converterFor, +} from '../../../tasks/process-spectrum-utils.js'; const converter = converterFor('spectrum-Calendar'); @@ -27,6 +30,48 @@ const config = { components: [ converter.classToHost(), converter.classToAttribute('spectrum-Calendar--padded'), + { + find: [ + builder.class('spectrum-Calendar-date'), + builder.class('is-focused'), + ], + replace: [ + { + replace: builder.class('tableCell'), + }, + { + replace: builder.pseudoClass('focus-within'), + }, + { + replace: builder.combinator(' '), + }, + { + replace: builder.class('date'), + }, + ], + collapseSelector: true, + }, + converter.classToClass('spectrum-Calendar-header', 'header'), + converter.classToClass('spectrum-Calendar-title', 'title'), + converter.classToClass( + 'spectrum-Calendar-prevMonth', + 'prevMonth' + ), + converter.classToClass( + 'spectrum-Calendar-nextMonth', + 'nextMonth' + ), + converter.classToClass( + 'spectrum-Calendar-dayOfWeek', + 'dayOfWeek' + ), + converter.classToClass('spectrum-Calendar-body', 'body'), + converter.classToClass('spectrum-Calendar-table', 'table'), + converter.classToClass( + 'spectrum-Calendar-tableCell', + 'tableCell' + ), + converter.classToClass('spectrum-Calendar-date', 'date'), ], }, ], diff --git a/packages/calendar/src/types.ts b/packages/calendar/src/types.ts index 659c5e03606..8b5fdcac85f 100644 --- a/packages/calendar/src/types.ts +++ b/packages/calendar/src/types.ts @@ -9,9 +9,16 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -export const daysInWeek = 7; export interface CalendarWeekday { narrow: string; long: string; } + +export interface DateCellProperties { + isOutsideMonth: boolean; + isToday: boolean; + isSelected: boolean; + isDisabled: boolean; + isTabbable: boolean; +} diff --git a/packages/calendar/stories/calendar.stories.ts b/packages/calendar/stories/calendar.stories.ts index 1baadee86ce..bceb32f509c 100644 --- a/packages/calendar/stories/calendar.stories.ts +++ b/packages/calendar/stories/calendar.stories.ts @@ -9,23 +9,14 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -import { html, render, TemplateResult } from '@spectrum-web-components/base'; -import { defaultLocale } from '@spectrum-web-components/story-decorator/src/StoryDecorator.js'; - +import { + html, + render, + type TemplateResult, +} from '@spectrum-web-components/base'; import { spreadProps } from '../../../test/lit-helpers.js'; - import '@spectrum-web-components/calendar/sp-calendar.js'; -export default { - title: 'Calendar', - component: 'sp-calendar', - parameters: { - actions: { - handles: ['onChange'], - }, - }, -}; - type ComponentArgs = { selectedDate?: Date; min?: Date; @@ -38,17 +29,62 @@ type StoryArgs = ComponentArgs & { onChange?: (dateTime: Date) => void; }; +export default { + title: 'Calendar', + component: 'sp-calendar', + args: { + disabled: false, + padded: false, + min: undefined, + max: undefined, + selectedDate: undefined, + }, + argTypes: { + disabled: { + description: "Component's disabled state", + type: { required: false }, + control: 'boolean', + }, + padded: { + description: "Component's padded variant", + type: { required: false }, + control: 'boolean', + }, + min: { + description: 'Minimum date allowed', + type: { required: false }, + control: 'date', + }, + max: { + description: 'Maximum date allowed', + type: { required: false }, + control: 'date', + }, + selectedDate: { + description: 'The pre-selected date of the component', + type: { required: false }, + control: 'date', + }, + }, + parameters: { + actions: { + handles: ['onChange'], + }, + }, +}; + interface SpreadStoryArgs { [prop: string]: unknown; } -const renderCalendar = ( - title: string, - args: StoryArgs = {} -): TemplateResult => { +const Template = (args: StoryArgs = {}): TemplateResult => { + args.min = args.min ? new Date(args.min) : undefined; + args.max = args.max ? new Date(args.max) : undefined; + args.selectedDate = args.selectedDate + ? new Date(args.selectedDate) + : undefined; + const story = html` -

${title}

-
{ - return renderCalendar('Default', args); -}; - -export const selectedDate = (args: StoryArgs = {}): TemplateResult => { - const date = new Date(2019, 0, 30); - const formatted = Intl.DateTimeFormat(defaultLocale, { - day: 'numeric', - month: 'long', - year: 'numeric', - }).format(date); - - args = { - ...args, - selectedDate: date, - }; - - return renderCalendar(`Selected Date: ${formatted}`, args); -}; - -export const minimumDate = (args: StoryArgs = {}): TemplateResult => { - const today = new Date(); - const lastMonth = new Date( - today.getFullYear(), - today.getMonth() - 1, - today.getDate() - ); - - const formatted = Intl.DateTimeFormat(defaultLocale, { - day: 'numeric', - month: 'long', - year: 'numeric', - }).format(lastMonth); - - args = { - ...args, - min: lastMonth, - }; - - return renderCalendar(`Minimum Date: ${formatted}`, args); -}; - -export const maximumDate = (args: StoryArgs = {}): TemplateResult => { - const today = new Date(); - const nextMonth = new Date( - today.getFullYear(), - today.getMonth() + 1, - today.getDate() - ); - - const formatted = Intl.DateTimeFormat(defaultLocale, { - day: 'numeric', - month: 'long', - year: 'numeric', - }).format(nextMonth); - - args = { - ...args, - max: nextMonth, - }; - - return renderCalendar(`Maximum Date: ${formatted}`, args); -}; - -export const disabled = (args: StoryArgs = {}): TemplateResult => { - return renderCalendar(`Disabled? ${args.disabled}`, args); -}; - -disabled.argTypes = { - disabled: { - control: 'boolean', - table: { - defaultValue: { - summary: true, - }, - }, - }, -}; - +export const Default = (args: StoryArgs): TemplateResult => Template(args); +export const disabled = (args: StoryArgs): TemplateResult => Template(args); disabled.args = { disabled: true, }; -export const padded = (args: StoryArgs = {}): TemplateResult => { - return renderCalendar(`Padded? ${args.padded}`, args); +export const selectedDate = (args: StoryArgs): TemplateResult => Template(args); +selectedDate.args = { + selectedDate: new Date(2022, 4, 16), }; -padded.argTypes = { - padded: { - control: 'boolean', - table: { - defaultValue: { - summary: true, - }, - }, - }, +export const minDate = (args: StoryArgs): TemplateResult => Template(args); +minDate.args = { + min: new Date(2022, 4, 8), + selectedDate: new Date(2022, 4, 16), +}; + +export const maxDate = (args: StoryArgs): TemplateResult => Template(args); +maxDate.args = { + max: new Date(2022, 4, 22), + selectedDate: new Date(2022, 4, 16), }; -padded.args = { - padded: true, +export const minAndMaxDates = (args: StoryArgs): TemplateResult => + Template(args); +minAndMaxDates.args = { + min: new Date(2022, 4, 8), + max: new Date(2022, 4, 22), + selectedDate: new Date(2022, 4, 16), }; diff --git a/packages/calendar/test/calendar.test.ts b/packages/calendar/test/calendar.test.ts index 6b7e1b3b337..9f1ce387681 100644 --- a/packages/calendar/test/calendar.test.ts +++ b/packages/calendar/test/calendar.test.ts @@ -10,23 +10,67 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; -import sinon, { spy } from 'sinon'; - +import { sendKeys, sendMouse } from '@web/test-runner-commands'; import { testForLitDevWarnings } from '../../../test/testing-helpers.js'; -import { Calendar } from '@spectrum-web-components/calendar'; - +import { spy } from 'sinon'; +import { + CalendarDate, + endOfMonth, + isSameDay, + parseDate, + today, +} from '@internationalized/date'; +import { Button } from '@spectrum-web-components/button'; +import { Calendar, DAYS_PER_WEEK } from '@spectrum-web-components/calendar'; import '@spectrum-web-components/calendar/sp-calendar.js'; import '@spectrum-web-components/theme/sp-theme.js'; +import { spreadProps } from '../../../test/lit-helpers.js'; -const DEFAULT_LOCALE = 'en-US'; -const CALENDAR_TITLE_SELECTOR = '[data-test-id="calendar-title"]'; +const LOCAL_TIME_ZONE = new Intl.DateTimeFormat().resolvedOptions().timeZone; const NEXT_BUTTON_SELECTOR = '[data-test-id="next-btn"]'; const PREV_BUTTON_SELECTOR = '[data-test-id="prev-btn"]'; -const FIRST_ACTIVE_DAY_SELECTOR = - '[data-test-id="calendar-day"]:not(:is(.is-disabled, .is-outsideMonth))'; + +async function fixtureElement({ + locale = 'en-US', + props = {}, +}: { + locale?: string; + props?: { [prop: string]: unknown }; +} = {}): Promise { + const wrapped = await fixture(html` + + + + `); + const el = wrapped.querySelector('sp-calendar') as Calendar; + await elementUpdated(el); + return el; +} describe('Calendar', () => { - const sandbox = sinon.createSandbox(); + let element: Calendar; + const originalDateNow = Date.now; + const fixedYear = 2022; + const fixedMonth = 5; + const fixedDay = 15; + + before(async () => { + const fixedTime = new Date( + fixedYear, + fixedMonth - 1, // 0-indexed in Date but 1-indexed in CalendarDate + fixedDay + ).getTime(); + Date.now = () => fixedTime; + }); + + beforeEach(async () => { + element = await fixtureElement(); + await elementUpdated(element); + }); + + after(() => { + Date.now = originalDateNow; + }); testForLitDevWarnings( async () => @@ -37,135 +81,622 @@ describe('Calendar', () => { ) ); - async function getCalendar({ - locale = DEFAULT_LOCALE, - disabled = false, - } = {}): Promise { - const wrapped = await fixture(html` - - - - `); - const el = wrapped.querySelector('sp-calendar') as Calendar; - await elementUpdated(el); - return el; - } - - beforeEach(() => { - // Use this date as the current date for running the tests - sandbox.stub(Date, 'now').returns(new Date('2022-05-20').valueOf()); + it('loads default calendar accessibly', async () => { + const el = await fixtureElement(); + await expect(el).to.be.accessible(); }); - afterEach(() => { - sandbox.restore(); - }); + describe('Displays the correct initial month', () => { + it('with no pre-selected value provided', async () => { + const localToday = today(LOCAL_TIME_ZONE); + const isLocalTodayDisplayed = isSameDay( + element['currentDate'], + localToday + ); - it('loads default calendar accessibly', async () => { - const el = await getCalendar(); + expect(isLocalTodayDisplayed).to.be.true; + }); + + it('with a valid pre-selected value', async () => { + const selectedDate = '2024-08-21'; + const element = await fixtureElement({ + props: { selectedDate: new Date(selectedDate) }, + }); + await elementUpdated(element); - await elementUpdated(el); + const selectedCalendarDate = parseDate(selectedDate); + const isSelectedDateDisplayed = isSameDay( + element['currentDate'], + selectedCalendarDate + ); - await expect(el).to.be.accessible(); + expect(isSelectedDateDisplayed).to.be.true; + }); + + it('with an invalid pre-selected value', async () => { + const selectedDate = 'invalid'; + const localToday = today(LOCAL_TIME_ZONE); + const element = await fixtureElement({ + props: { selectedDate: new Date(selectedDate) }, + }); + await elementUpdated(element); + + const isLocalTodayDisplayed = isSameDay( + element['currentDate'], + localToday + ); + + expect(element.selectedDate.toString()).to.equal('Invalid Date'); + expect(element['_selectedDate']).to.be.undefined; + expect(isLocalTodayDisplayed).to.be.true; + }); }); - it('should render disabled calendar cells', async () => { - const el = await getCalendar({ disabled: true }); + describe('Correctly manages the focusable day when changing months', () => { + let focusableDay: HTMLElement; + let nextButton: Button; + let prevButton: Button; + + beforeEach(async () => { + focusableDay = element.shadowRoot.querySelector( + "td.tableCell[tabindex='0']" + ) as HTMLElement; + nextButton = + element.shadowRoot.querySelector(NEXT_BUTTON_SELECTOR)!; + prevButton = + element.shadowRoot.querySelector(PREV_BUTTON_SELECTOR)!; + }); - await elementUpdated(el); + it('after selecting a date', async () => { + focusableDay.focus(); + await sendKeys({ press: 'ArrowRight' }); + await elementUpdated(element); + await sendKeys({ press: 'Enter' }); + await elementUpdated(element); + + nextButton.focus(); + await sendKeys({ press: 'Enter' }); + await elementUpdated(element); + await sendKeys({ press: 'Enter' }); + await elementUpdated(element); + + await sendKeys({ press: 'Tab' }); + await elementUpdated(element); + + const focusedDay = element.shadowRoot.activeElement as HTMLElement; + const focusedCalendarDate = parseDate(focusedDay.dataset.value!); + + expect(isSameDay(focusedCalendarDate, element['currentDate'])).to.be + .true; + expect(element['currentDate'].year).to.equal(fixedYear); + expect(element['currentDate'].month).to.equal(fixedMonth + 2); + expect(element['currentDate'].day).to.equal(1); + }); - const items = el.shadowRoot.querySelectorAll('td span'); + it('coming back to a selected date', async () => { + focusableDay.focus(); + await sendKeys({ press: 'ArrowRight' }); + await elementUpdated(element); + await sendKeys({ press: 'Enter' }); + await elementUpdated(element); + + nextButton.focus(); + await sendKeys({ press: 'Enter' }); + await elementUpdated(element); + await sendKeys({ press: 'Enter' }); + await elementUpdated(element); + + prevButton.focus(); + await sendKeys({ press: 'Enter' }); + await elementUpdated(element); + await sendKeys({ press: 'Enter' }); + await elementUpdated(element); + + await sendKeys({ press: 'Tab' }); + await sendKeys({ press: 'Tab' }); + await elementUpdated(element); + + const focusedDay = element.shadowRoot.activeElement as HTMLElement; + const focusedCalendarDate = parseDate(focusedDay.dataset.value!); + expect(isSameDay(focusedCalendarDate, element['_selectedDate']!)).to + .be.true; + }); - items.forEach((item) => { - expect(item.classList.contains('is-disabled')).to.be.true; + it("coming back to today's date", async () => { + nextButton.focus(); + await sendKeys({ press: 'Enter' }); + await elementUpdated(element); + await sendKeys({ press: 'Enter' }); + await elementUpdated(element); + + prevButton.focus(); + await sendKeys({ press: 'Enter' }); + await elementUpdated(element); + await sendKeys({ press: 'Enter' }); + await elementUpdated(element); + + await sendKeys({ press: 'Tab' }); + await sendKeys({ press: 'Tab' }); + await elementUpdated(element); + + const focusedDay = element.shadowRoot.activeElement as HTMLElement; + expect(focusedDay).to.equal(focusableDay); }); }); - it('should call "@change" event', async () => { - const changeSpy = spy(); - const el = await getCalendar(); + describe('Navigates', () => { + let nextButton: Button; + let prevButton: Button; + + const resetElementPosition = (): void => { + element['currentDate'] = today(LOCAL_TIME_ZONE); + }; + + beforeEach(() => { + prevButton = + element.shadowRoot.querySelector(PREV_BUTTON_SELECTOR)!; + + nextButton = + element.shadowRoot.querySelector(NEXT_BUTTON_SELECTOR)!; + }); + + afterEach(async () => { + resetElementPosition(); + }); + + describe('via header buttons', () => { + it('using pointer on the next month button', async () => { + nextButton.click(); + await elementUpdated(element); + + expect(element['currentDate'].year).to.equal(fixedYear); + expect(element['currentDate'].month).to.equal(fixedMonth + 1); + expect(element['currentDate'].day).to.equal(1); + + nextButton.click(); + await elementUpdated(element); + + expect(element['currentDate'].year).to.equal(fixedYear); + expect(element['currentDate'].month).to.equal(fixedMonth + 2); + expect(element['currentDate'].day).to.equal(1); + }); + + it('using pointer on the previous month button', async () => { + prevButton.click(); + await elementUpdated(element); + + expect(element['currentDate'].year).to.equal(fixedYear); + expect(element['currentDate'].month).to.equal(fixedMonth - 1); + expect(element['currentDate'].day).to.equal(1); + + prevButton.click(); + await elementUpdated(element); + + expect(element['currentDate'].year).to.equal(fixedYear); + expect(element['currentDate'].month).to.equal(fixedMonth - 2); + expect(element['currentDate'].day).to.equal(1); + }); + + it('using keyboard action on the next month button', async () => { + nextButton.focus(); + await sendKeys({ press: 'Space' }); + await elementUpdated(element); + + expect(element['currentDate'].year).to.equal(fixedYear); + expect(element['currentDate'].month).to.equal(fixedMonth + 1); + expect(element['currentDate'].day).to.equal(1); + + await sendKeys({ press: 'Enter' }); + await elementUpdated(element); + + expect(element['currentDate'].year).to.equal(fixedYear); + expect(element['currentDate'].month).to.equal(fixedMonth + 2); + expect(element['currentDate'].day).to.equal(1); + }); + + it('using keyboard action on the previous month button', async () => { + prevButton.focus(); + await sendKeys({ press: 'Space' }); + await elementUpdated(element); + + expect(element['currentDate'].year).to.equal(fixedYear); + expect(element['currentDate'].month).to.equal(fixedMonth - 1); + expect(element['currentDate'].day).to.equal(1); + + await sendKeys({ press: 'Enter' }); + await elementUpdated(element); + + expect(element['currentDate'].year).to.equal(fixedYear); + expect(element['currentDate'].month).to.equal(fixedMonth - 2); + expect(element['currentDate'].day).to.equal(1); + }); + }); + + describe('via days buttons', () => { + beforeEach(() => { + const focusableDay = element.shadowRoot.querySelector( + "td.tableCell[tabindex='0']" + ) as HTMLElement; + focusableDay.focus(); + }); + + describe('in the current month', () => { + it('using the right arrow key', async () => { + await sendKeys({ press: 'ArrowRight' }); + await elementUpdated(element); + + expect(element['currentDate'].year).to.equal(fixedYear); + expect(element['currentDate'].month).to.equal(fixedMonth); + expect(element['currentDate'].day).to.equal(fixedDay + 1); + }); + + it('using the left arrow key', async () => { + await sendKeys({ press: 'ArrowLeft' }); + await elementUpdated(element); + + expect(element['currentDate'].year).to.equal(fixedYear); + expect(element['currentDate'].month).to.equal(fixedMonth); + expect(element['currentDate'].day).to.equal(fixedDay - 1); + }); + + it('using the up arrow key', async () => { + await sendKeys({ press: 'ArrowUp' }); + await elementUpdated(element); + + expect(element['currentDate'].year).to.equal(fixedYear); + expect(element['currentDate'].month).to.equal(fixedMonth); + expect(element['currentDate'].day).to.equal( + fixedDay - DAYS_PER_WEEK + ); + }); + + it('using the down arrow key', async () => { + await sendKeys({ press: 'ArrowDown' }); + await elementUpdated(element); - const dayEl = el.shadowRoot.querySelector( - FIRST_ACTIVE_DAY_SELECTOR - ); + expect(element['currentDate'].year).to.equal(fixedYear); + expect(element['currentDate'].month).to.equal(fixedMonth); + expect(element['currentDate'].day).to.equal( + fixedDay + DAYS_PER_WEEK + ); + }); + }); - el.addEventListener('change', changeSpy); - dayEl?.click(); + describe('through different months', () => { + it('using the right arrow key', async () => { + const currentEndOfMonthDay = endOfMonth( + element['currentDate'] + ).day; + const nextMonthDay = 4; + + await Promise.all( + Array.from({ + length: + currentEndOfMonthDay - + element['currentDate'].day + + nextMonthDay, + }).map(() => sendKeys({ press: 'ArrowRight' })) + ); + await elementUpdated(element); + expect(element['currentDate'].year).to.equal(fixedYear); + expect(element['currentDate'].month).to.equal( + fixedMonth + 1 + ); + expect(element['currentDate'].day).to.equal(nextMonthDay); + }); + + it('using the left arrow key', async () => { + const previousEndOfMonthDay = endOfMonth( + element['currentDate'].set({ month: fixedMonth - 1 }) + ).day; + const prevMonthDay = 23; + + await Promise.all( + Array.from({ + length: + element['currentDate'].day + + previousEndOfMonthDay - + prevMonthDay, + }).map(() => sendKeys({ press: 'ArrowLeft' })) + ); + await elementUpdated(element); + expect(element['currentDate'].year).to.equal(fixedYear); + expect(element['currentDate'].month).to.equal( + fixedMonth - 1 + ); + expect(element['currentDate'].day).to.equal(prevMonthDay); + }); + + it('using the up arrow key', async () => { + const previousEndOfMonthDay = endOfMonth( + element['currentDate'].set({ month: fixedMonth - 1 }) + ).day; + const initialDay = element['currentDate'].day; + const completedWeeks = Math.floor( + initialDay / DAYS_PER_WEEK + ); + const daysIntoCurrentWeek = initialDay % DAYS_PER_WEEK; + const previousMonthDay = + previousEndOfMonthDay + + daysIntoCurrentWeek - + DAYS_PER_WEEK; + + await Promise.all( + Array.from({ + length: completedWeeks + 1, + }).map(() => sendKeys({ press: 'ArrowUp' })) + ); + await elementUpdated(element); - await elementUpdated(el); + expect(element['currentDate'].year).to.equal(fixedYear); + expect(element['currentDate'].month).to.equal( + fixedMonth - 1 + ); + expect(element['currentDate'].day).to.equal( + previousMonthDay + ); + }); + + it('using the down arrow key', async () => { + const currentEndOfMonthDay = endOfMonth( + element['currentDate'] + ).day; + const initialDay = element['currentDate'].day; + const uncompletedWeeks = Math.floor( + (currentEndOfMonthDay - initialDay) / DAYS_PER_WEEK + ); + const nextMonthDay = + initialDay + + (uncompletedWeeks + 1) * DAYS_PER_WEEK - + currentEndOfMonthDay; + + await Promise.all( + Array.from({ + length: uncompletedWeeks + 1, + }).map(() => sendKeys({ press: 'ArrowDown' })) + ); + await elementUpdated(element); - expect(changeSpy).to.be.calledOnce; + expect(element['currentDate'].year).to.equal(fixedYear); + expect(element['currentDate'].month).to.equal( + fixedMonth + 1 + ); + expect(element['currentDate'].day).to.equal(nextMonthDay); + }); + }); + }); }); - const testCases = [ - { - locale: 'en-US', - current: 'May 2022', - next: 'June 2022', - prev: 'April 2022', - }, - { - locale: 'pt-BR', - current: 'maio de 2022', - next: 'junho de 2022', - prev: 'abril de 2022', - }, - { - locale: 'ko-KR', - current: '2022년 5월', - next: '2022년 6월', - prev: '2022년 4월', - }, - ]; - - testCases.forEach(({ locale, current, next, prev }) => { - describe(`given the locale is "${locale}"`, () => { - let el: Element; - let titleEl: HTMLElement | null | undefined; + describe('Manages min and max constraints', () => { + let min: Date; + let max: Date; + const dayOffset = 5; + + before(async () => { + // Month is 0-indexed in Date but 1-indexed in CalendarDate + min = new Date(fixedYear, fixedMonth - 1, fixedDay - dayOffset); + max = new Date(fixedYear, fixedMonth - 1, fixedDay + dayOffset); + }); + + it('with invalid min date', async () => { + // TODO: with the new value API PR + }); + + it('with invalid max date', async () => { + // TODO: with the new value API PR + }); + + it('with min > max date', async () => { + // TODO: with the new value API PR + }); + it("when a pre-selected value doesn't comply", async () => { + // TODO: with the new value API PR + }); + + it("by not selecting a day that doesn't comply", async () => { + const changeSpy = spy(); + element = await fixtureElement({ + props: { min, max }, + }); + element.addEventListener('change', changeSpy); + const unavailableDateToSelect = new CalendarDate( + fixedYear, + fixedMonth, + fixedDay + dayOffset + 3 + ); + const unavailableDayElement = element.shadowRoot.querySelector( + `[data-value='${unavailableDateToSelect.toString()}']` + ) as HTMLElement; + + unavailableDayElement.focus(); + await sendKeys({ press: 'Enter' }); + await elementUpdated(element); + + const rect = unavailableDayElement.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + await sendMouse({ + type: 'click', + position: [centerX, centerY], + }); + await elementUpdated(element); + + const isInitialCurrentDate = isSameDay( + element['currentDate'], + new CalendarDate(fixedYear, fixedMonth, fixedDay) + ); + + expect(isInitialCurrentDate).to.be.true; + expect(changeSpy.callCount).to.equal(0); + expect(element.selectedDate.toString()).to.equal('Invalid Date'); + }); + + describe('stopping navigation when they are set', () => { beforeEach(async () => { - el = await getCalendar({ locale }); + element = await fixtureElement({ + props: { min, max }, + }); + const focusableDay = element.shadowRoot.querySelector( + "td.tableCell[tabindex='0']" + ) as HTMLElement; + focusableDay.focus(); + }); - titleEl = el.shadowRoot?.querySelector( - CALENDAR_TITLE_SELECTOR + it('using the right arrow key', async () => { + await Promise.all( + Array.from({ length: dayOffset + 3 }).map(() => + sendKeys({ press: 'ArrowRight' }) + ) ); + await elementUpdated(element); + + expect(element['currentDate'].year).to.equal(fixedYear); + expect(element['currentDate'].month).to.equal(fixedMonth); + expect(element['currentDate'].day).to.equal(max.getDate()); }); - it('should render calendar with correct month and year', async () => { - expect( - titleEl?.innerHTML, - `Title of current month when locale is "${locale}"` - ).to.contain(current); + it('using the left arrow key', async () => { + await Promise.all( + Array.from({ length: dayOffset + 3 }).map(() => + sendKeys({ press: 'ArrowLeft' }) + ) + ); + await elementUpdated(element); + + expect(element['currentDate'].year).to.equal(fixedYear); + expect(element['currentDate'].month).to.equal(fixedMonth); + expect(element['currentDate'].day).to.equal(min.getDate()); }); - it('should update the title indicating the next month when clicking the "Next" button', async () => { - const nextBtn = - el.shadowRoot?.querySelector( - NEXT_BUTTON_SELECTOR - ); + it('using the up arrow key', async () => { + await Promise.all( + Array.from({ length: dayOffset / DAYS_PER_WEEK + 1 }).map( + () => sendKeys({ press: 'ArrowUp' }) + ) + ); + await elementUpdated(element); - nextBtn?.click(); - await elementUpdated(el); + expect(element['currentDate'].year).to.equal(fixedYear); + expect(element['currentDate'].month).to.equal(fixedMonth); + expect(element['currentDate'].day).to.equal(min.getDate()); + }); + + it('using the down arrow key', async () => { + await Promise.all( + Array.from({ length: dayOffset / DAYS_PER_WEEK + 1 }).map( + () => sendKeys({ press: 'ArrowDown' }) + ) + ); + await elementUpdated(element); - expect( - titleEl?.innerHTML, - `Title of next month when locale is "${locale}"` - ).to.contain(next); + expect(element['currentDate'].year).to.equal(fixedYear); + expect(element['currentDate'].month).to.equal(fixedMonth); + expect(element['currentDate'].day).to.equal(max.getDate()); }); + }); + }); - it('should update the title indicating the previous month when clicking the "Previous" button', async () => { - const prevBtn = - el.shadowRoot?.querySelector( - PREV_BUTTON_SELECTOR - ); + describe('Correctly changes the selected date', () => { + let changeSpy: sinon.SinonSpy; + let availableDateToSelect: CalendarDate; + let availableDayElement: HTMLElement; + + beforeEach(() => { + changeSpy = spy(); + element.addEventListener('change', changeSpy); + availableDateToSelect = new CalendarDate( + fixedYear, + fixedMonth, + fixedDay + 1 + ); + availableDayElement = element.shadowRoot.querySelector( + `[data-value='${availableDateToSelect.toString()}']` + ) as HTMLElement; + }); - prevBtn?.click(); - await elementUpdated(el); + afterEach(() => { + changeSpy.resetHistory(); + }); + + it('when an available day is clicked', async () => { + const rect = availableDayElement.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; - expect( - titleEl?.innerHTML, - `Title of previous month when locale is "${locale}"` - ).to.contain(prev); + await sendMouse({ + type: 'click', + position: [centerX, centerY], + }); + await elementUpdated(element); + + expect(changeSpy.callCount).to.equal(1); + expect(element.selectedDate.getFullYear()).to.equal( + availableDateToSelect.year + ); + expect(element.selectedDate.getMonth() + 1).to.equal( + availableDateToSelect.month + ); + expect(element.selectedDate.getDate()).to.equal( + availableDateToSelect.day + ); + + changeSpy.resetHistory(); + await sendMouse({ + type: 'click', + position: [centerX, centerY], }); + await elementUpdated(element); + expect(changeSpy.callCount).to.equal(0); }); + + it('when an available day is acted upon using Enter', async () => { + availableDayElement.focus(); + await sendKeys({ press: 'Enter' }); + await elementUpdated(element); + + expect(changeSpy.callCount).to.equal(1); + expect(element.selectedDate.getFullYear()).to.equal( + availableDateToSelect.year + ); + expect(element.selectedDate.getMonth() + 1).to.equal( + availableDateToSelect.month + ); + expect(element.selectedDate.getDate()).to.equal( + availableDateToSelect.day + ); + + changeSpy.resetHistory(); + await sendKeys({ press: 'Enter' }); + await elementUpdated(element); + expect(changeSpy.callCount).to.equal(0); + }); + + it('when an available day is acted upon using Space', async () => { + availableDayElement.focus(); + await sendKeys({ press: 'Space' }); + await elementUpdated(element); + + expect(changeSpy.callCount).to.equal(1); + expect(element.selectedDate.getFullYear()).to.equal( + availableDateToSelect.year + ); + expect(element.selectedDate.getMonth() + 1).to.equal( + availableDateToSelect.month + ); + expect(element.selectedDate.getDate()).to.equal( + availableDateToSelect.day + ); + + changeSpy.resetHistory(); + await sendKeys({ press: 'Space' }); + await elementUpdated(element); + expect(changeSpy.callCount).to.equal(0); + }); + }); + + it('should render localized dates', async () => { + // TODO }); }); diff --git a/packages/date-time-picker/src/DateTimePicker.ts b/packages/date-time-picker/src/DateTimePicker.ts index 1295d2794ee..234d2007119 100644 --- a/packages/date-time-picker/src/DateTimePicker.ts +++ b/packages/date-time-picker/src/DateTimePicker.ts @@ -95,7 +95,7 @@ export class DateTimePicker extends InputSegments {