From b44bfc5d1fd4d6738ec6a9ff74c643e67a684094 Mon Sep 17 00:00:00 2001 From: gerjanvangeest Date: Tue, 4 Jul 2023 10:36:18 +0200 Subject: [PATCH] feat: enable navigation to, selecting & give accessible message for calendar disabled dates (#1978) Co-authored-by: Konstantinos Norgias --- .changeset/blue-boats-arrive.md | 9 + docs/components/calendar/use-cases.md | 39 ++ docs/components/input-datepicker/use-cases.md | 2 + .../components/calendar/src/LionCalendar.js | 186 ++++++---- .../components/calendar/src/calendarStyle.js | 26 +- .../calendar/src/utils/createDay.js | 2 + .../calendar/src/utils/dayTemplate.js | 51 +-- .../calendar/src/utils/getDayMonthYear.js | 28 ++ .../calendar/test-helpers/DayObject.js | 4 +- .../calendar/test/lion-calendar.test.js | 226 ++++++------ .../calendar/test/utils/dayTemplate.test.js | 12 +- .../monthTemplate_en-GB_Sunday_2018-12.js | 336 +++++++++++------- packages/ui/components/calendar/types/day.ts | 1 + .../src/LionInputDatepicker.js | 36 +- 14 files changed, 618 insertions(+), 340 deletions(-) create mode 100644 .changeset/blue-boats-arrive.md create mode 100644 packages/ui/components/calendar/src/utils/getDayMonthYear.js diff --git a/.changeset/blue-boats-arrive.md b/.changeset/blue-boats-arrive.md new file mode 100644 index 0000000000..05d02707f4 --- /dev/null +++ b/.changeset/blue-boats-arrive.md @@ -0,0 +1,9 @@ +--- +'@lion/ui': patch +--- + +[calendar] updates: + +- Enables focus to disabled dates to make it more reasonable for screen readers +- Do not automatically force selection of a valid date +- Add helper functions to find next/previous/nearest enabled date diff --git a/docs/components/calendar/use-cases.md b/docs/components/calendar/use-cases.md index 24f37edf0d..1e56069369 100644 --- a/docs/components/calendar/use-cases.md +++ b/docs/components/calendar/use-cases.md @@ -190,3 +190,42 @@ export const combinedDisabledDates = () => { `; }; ``` + +### Finding enabled dates + +The next available date may be multiple days/month in the future/past. +For that we offer convenient helpers as + +- `findNextEnabledDate()` +- `findPreviousEnabledDate()` +- `findNearestEnabledDate()` + +```js preview-story +export const findingEnabledDates = () => { + function getCalendar(ev) { + return ev.target.parentElement.querySelector('.js-calendar'); + } + return html` + + day.getDay() === 6 || day.getDay() === 0} + > + + + + `; +}; +``` diff --git a/docs/components/input-datepicker/use-cases.md b/docs/components/input-datepicker/use-cases.md index 38b663e7d4..6e98f8aab7 100644 --- a/docs/components/input-datepicker/use-cases.md +++ b/docs/components/input-datepicker/use-cases.md @@ -6,6 +6,7 @@ import { MinMaxDate, IsDateDisabled } from '@lion/ui/form-core.js'; import { loadDefaultFeedbackMessages } from '@lion/ui/validate-messages.js'; import { formatDate } from '@lion/ui/localize.js'; import '@lion/ui/define/lion-input-datepicker.js'; +loadDefaultFeedbackMessages(); ``` ## Minimum and maximum date @@ -35,6 +36,7 @@ export const disableSpecificDates = () => html` d.getDate() === 15)]} > `; diff --git a/packages/ui/components/calendar/src/LionCalendar.js b/packages/ui/components/calendar/src/LionCalendar.js index 83eaaf923b..48a7bfb63c 100644 --- a/packages/ui/components/calendar/src/LionCalendar.js +++ b/packages/ui/components/calendar/src/LionCalendar.js @@ -15,6 +15,7 @@ import { dayTemplate } from './utils/dayTemplate.js'; import { getFirstDayNextMonth } from './utils/getFirstDayNextMonth.js'; import { getLastDayPreviousMonth } from './utils/getLastDayPreviousMonth.js'; import { isSameDate } from './utils/isSameDate.js'; +import { getDayMonthYear } from './utils/getDayMonthYear.js'; /** * @typedef {import('../types/day.js').Day} Day @@ -22,6 +23,17 @@ import { isSameDate } from './utils/isSameDate.js'; * @typedef {import('../types/day.js').Month} Month */ +const isDayButton = /** @param {HTMLElement} el */ el => + el.classList.contains('calendar__day-button'); + +/** + * @param {HTMLElement} el + * @returns {boolean} + */ +function isDisabledDayButton(el) { + return el.getAttribute('aria-disabled') === 'true'; +} + /** * @customElement lion-calendar */ @@ -213,19 +225,19 @@ export class LionCalendar extends LocalizeMixin(LitElement) { } goToNextMonth() { - this.__modifyDate(1, { dateType: 'centralDate', type: 'Month', mode: 'both' }); + this.__modifyDate(1, { dateType: 'centralDate', type: 'Month' }); } goToPreviousMonth() { - this.__modifyDate(-1, { dateType: 'centralDate', type: 'Month', mode: 'both' }); + this.__modifyDate(-1, { dateType: 'centralDate', type: 'Month' }); } goToNextYear() { - this.__modifyDate(1, { dateType: 'centralDate', type: 'FullYear', mode: 'both' }); + this.__modifyDate(1, { dateType: 'centralDate', type: 'FullYear' }); } goToPreviousYear() { - this.__modifyDate(-1, { dateType: 'centralDate', type: 'FullYear', mode: 'both' }); + this.__modifyDate(-1, { dateType: 'centralDate', type: 'FullYear' }); } /** @@ -238,9 +250,7 @@ export class LionCalendar extends LocalizeMixin(LitElement) { } focusCentralDate() { - const button = /** @type {HTMLElement} */ ( - this.shadowRoot?.querySelector('button[tabindex="0"]') - ); + const button = /** @type {HTMLElement} */ (this.shadowRoot?.querySelector('[tabindex="0"]')); button.focus(); this.__focusedDate = this.centralDate; } @@ -328,13 +338,8 @@ export class LionCalendar extends LocalizeMixin(LitElement) { return; } - const map = { - disableDates: () => this.__disableDatesChanged(), - centralDate: () => this.__centralDateChanged(), - __focusedDate: () => this.__focusedDateChanged(), - }; - if (map[name]) { - map[name](); + if (name === '__focusedDate') { + this.__focusedDateChanged(); } const updateDataOn = ['centralDate', 'minDate', 'maxDate', 'selectedDate', 'disableDates']; @@ -362,10 +367,8 @@ export class LionCalendar extends LocalizeMixin(LitElement) { */ __calculateInitialCentralDate() { if (this.centralDate === this.__today && this.selectedDate) { - // initialised with selectedDate only if user didn't provide another one + // initialized with selectedDate only if user didn't provide another one this.centralDate = this.selectedDate; - } else { - this.__ensureValidCentralDate(); } /** @type {Date} */ this.__initialCentralDate = this.centralDate; @@ -581,6 +584,35 @@ export class LionCalendar extends LocalizeMixin(LitElement) { return `${this.msgLit(`lion-calendar:${mode}${type}`)}, ${month} ${year}`; } + /** + * + * @private + */ + __getSelectableDateRange() { + const newMinDate = createDay(new Date(this.minDate)); + const newMaxDate = createDay(new Date(this.maxDate)); + + const getSelectableDate = (/** @type {import("../types/day.js").Day} */ date) => { + const { dayNumber, monthName, year } = getDayMonthYear( + date, + getWeekdayNames({ + locale: this.__getLocale(), + style: 'long', + firstDayOfWeek: this.firstDayOfWeek, + }), + ); + return `${dayNumber} ${monthName} ${year}`; + }; + + const earliestSelectableDate = getSelectableDate(newMinDate); + const latestSelectableDate = getSelectableDate(newMaxDate); + + return { + earliestSelectableDate, + latestSelectableDate, + }; + } + /** * * @param {Day} _day @@ -605,13 +637,27 @@ export class LionCalendar extends LocalizeMixin(LitElement) { day.tabindex = day.central ? '0' : '-1'; day.ariaPressed = day.selected ? 'true' : 'false'; day.ariaCurrent = day.today ? 'date' : undefined; + day.disabledInfo = ''; if (this.minDate && normalizeDateTime(day.date) < normalizeDateTime(this.minDate)) { day.disabled = true; + // TODO: turn this into a translated string + day.disabledInfo = `This date is unavailable. Earliest date to select is ${ + this.__getSelectableDateRange().earliestSelectableDate + }. Please select another date.`; } if (this.maxDate && normalizeDateTime(day.date) > normalizeDateTime(this.maxDate)) { day.disabled = true; + // TODO: turn this into a translated string + day.disabledInfo = `This date is unavailable. Latest date to select is ${ + this.__getSelectableDateRange().latestSelectableDate + }. Please select another date.`; + } + + if (day.disabled) { + // TODO: turn this into a translated string + day.disabledInfo = `This date is unavailable. Please select another date`; } return this.dayPreprocessor(day); @@ -641,15 +687,6 @@ export class LionCalendar extends LocalizeMixin(LitElement) { return data; } - /** - * @private - */ - __disableDatesChanged() { - if (this.__connectedCallbackDone) { - this.__ensureValidCentralDate(); - } - } - /** * @param {Date} selectedDate * @private @@ -669,28 +706,37 @@ export class LionCalendar extends LocalizeMixin(LitElement) { /** * @private */ - __centralDateChanged() { - if (this.__connectedCallbackDone) { - this.__ensureValidCentralDate(); + __focusedDateChanged() { + if (this.__focusedDate) { + this.centralDate = this.__focusedDate; } } /** - * @private + * @param {Date} [date] + * @returns */ - __focusedDateChanged() { - if (this.__focusedDate) { - this.centralDate = this.__focusedDate; - } + findNextEnabledDate(date) { + const _date = date || this.centralDate; + return this.__findBestEnabledDateFor(_date, { mode: 'future' }); } /** - * @private + * @param {Date} [date] + * @returns */ - __ensureValidCentralDate() { - if (!this.__isEnabledDate(this.centralDate)) { - this.centralDate = this.__findBestEnabledDateFor(this.centralDate); - } + findPreviousEnabledDate(date) { + const _date = date || this.centralDate; + return this.__findBestEnabledDateFor(_date, { mode: 'past' }); + } + + /** + * @param {Date} [date] + * @returns + */ + findNearestEnabledDate(date) { + const _date = date || this.centralDate; + return this.__findBestEnabledDateFor(_date, { mode: 'both' }); } /** @@ -750,11 +796,8 @@ export class LionCalendar extends LocalizeMixin(LitElement) { * @private */ __clickDateDelegation(ev) { - const isDayButton = /** @param {HTMLElement} el */ el => - el.classList.contains('calendar__day-button'); - - const el = /** @type {HTMLElement & { date: Date }} */ (ev.composedPath()[0]); - if (isDayButton(el)) { + const el = /** @type {HTMLElement & { date: Date }} */ (ev.target); + if (isDayButton(el) && !isDisabledDayButton(el)) { this.__dateSelectedByUser(el.date); } } @@ -763,9 +806,6 @@ export class LionCalendar extends LocalizeMixin(LitElement) { * @private */ __focusDateDelegation() { - const isDayButton = /** @param {HTMLElement} el */ el => - el.classList.contains('calendar__day-button'); - if ( !this.__focusedDate && isDayButton(/** @type {HTMLElement} el */ (this.shadowRoot?.activeElement)) @@ -780,9 +820,6 @@ export class LionCalendar extends LocalizeMixin(LitElement) { * @private */ __blurDateDelegation() { - const isDayButton = /** @param {HTMLElement} el */ el => - el.classList.contains('calendar__day-button'); - setTimeout(() => { if ( this.shadowRoot?.activeElement && @@ -793,42 +830,65 @@ export class LionCalendar extends LocalizeMixin(LitElement) { }, 1); } + /** + * @param {HTMLElement & { date: Date }} el + * @private + */ + __dayButtonSelection(el) { + if (isDayButton(el)) { + this.__dateSelectedByUser(el.date); + } + } + /** * @param {KeyboardEvent} ev * @private */ __keyboardNavigationEvent(ev) { - const preventedKeys = ['ArrowUp', 'ArrowDown', 'PageDown', 'PageUp']; + const preventedKeys = [ + 'ArrowLeft', + 'ArrowUp', + 'ArrowRight', + 'ArrowDown', + 'PageDown', + 'PageUp', + ' ', + 'Enter', + ]; if (preventedKeys.includes(ev.key)) { ev.preventDefault(); } switch (ev.key) { + case ' ': + case 'Enter': + this.__dayButtonSelection(/** @type {HTMLElement & { date: Date }} */ (ev.target)); + break; case 'ArrowUp': - this.__modifyDate(-7, { dateType: '__focusedDate', type: 'Date', mode: 'past' }); + this.__modifyDate(-7, { dateType: '__focusedDate', type: 'Date' }); break; case 'ArrowDown': - this.__modifyDate(7, { dateType: '__focusedDate', type: 'Date', mode: 'future' }); + this.__modifyDate(7, { dateType: '__focusedDate', type: 'Date' }); break; case 'ArrowLeft': - this.__modifyDate(-1, { dateType: '__focusedDate', type: 'Date', mode: 'past' }); + this.__modifyDate(-1, { dateType: '__focusedDate', type: 'Date' }); break; case 'ArrowRight': - this.__modifyDate(1, { dateType: '__focusedDate', type: 'Date', mode: 'future' }); + this.__modifyDate(1, { dateType: '__focusedDate', type: 'Date' }); break; case 'PageDown': if (ev.altKey === true) { - this.__modifyDate(1, { dateType: '__focusedDate', type: 'FullYear', mode: 'future' }); + this.__modifyDate(1, { dateType: '__focusedDate', type: 'FullYear' }); } else { - this.__modifyDate(1, { dateType: '__focusedDate', type: 'Month', mode: 'future' }); + this.__modifyDate(1, { dateType: '__focusedDate', type: 'Month' }); } break; case 'PageUp': if (ev.altKey === true) { - this.__modifyDate(-1, { dateType: '__focusedDate', type: 'FullYear', mode: 'past' }); + this.__modifyDate(-1, { dateType: '__focusedDate', type: 'FullYear' }); } else { - this.__modifyDate(-1, { dateType: '__focusedDate', type: 'Month', mode: 'past' }); + this.__modifyDate(-1, { dateType: '__focusedDate', type: 'Month' }); } break; case 'Tab': @@ -844,11 +904,10 @@ export class LionCalendar extends LocalizeMixin(LitElement) { * @param {Object} opts * @param {string} opts.dateType * @param {string} opts.type - * @param {string} opts.mode * @private */ - __modifyDate(modify, { dateType, type, mode }) { - let tmpDate = new Date(this.centralDate); + __modifyDate(modify, { dateType, type }) { + const tmpDate = new Date(this.centralDate); // if we're not working with days, reset // day count to first day of the month if (type !== 'Date') { @@ -861,9 +920,6 @@ export class LionCalendar extends LocalizeMixin(LitElement) { const maxDays = new Date(tmpDate.getFullYear(), tmpDate.getMonth() + 1, 0).getDate(); tmpDate.setDate(Math.min(this.centralDate.getDate(), maxDays)); } - if (!this.__isEnabledDate(tmpDate)) { - tmpDate = this.__findBestEnabledDateFor(tmpDate, { mode }); - } this[dateType] = tmpDate; } diff --git a/packages/ui/components/calendar/src/calendarStyle.js b/packages/ui/components/calendar/src/calendarStyle.js index e2f74bf6f8..695cf324c1 100644 --- a/packages/ui/components/calendar/src/calendarStyle.js +++ b/packages/ui/components/calendar/src/calendarStyle.js @@ -54,6 +54,16 @@ export const calendarStyle = css` padding: 0; min-width: 40px; min-height: 40px; + /** give div[role=button][aria-disabled] same display type as native btn */ + display: inline-flex; + justify-content: center; + align-items: center; + box-sizing: border-box; + } + + .calendar__day-button:focus { + border: 1px solid blue; + outline: none; } .calendar__day-button__text { @@ -77,9 +87,23 @@ export const calendarStyle = css` border: 1px solid green; } - .calendar__day-button[disabled] { + .calendar__day-button[aria-disabled='true'] { background-color: #fff; color: #eee; outline: none; } + + .u-sr-only { + position: absolute; + top: 0; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(100%); + clip: rect(1px, 1px, 1px, 1px); + white-space: nowrap; + border: 0; + margin: 0; + padding: 0; + } `; diff --git a/packages/ui/components/calendar/src/utils/createDay.js b/packages/ui/components/calendar/src/utils/createDay.js index e5498a0f87..c16111300d 100644 --- a/packages/ui/components/calendar/src/utils/createDay.js +++ b/packages/ui/components/calendar/src/utils/createDay.js @@ -16,6 +16,7 @@ export function createDay( today = false, future = false, disabled = false, + disabledInfo = '', } = {}, ) { return { @@ -34,5 +35,6 @@ export function createDay( tabindex: '-1', ariaPressed: 'false', ariaCurrent: undefined, + disabledInfo, }; } diff --git a/packages/ui/components/calendar/src/utils/dayTemplate.js b/packages/ui/components/calendar/src/utils/dayTemplate.js index 8bc2769938..b98d44c993 100644 --- a/packages/ui/components/calendar/src/utils/dayTemplate.js +++ b/packages/ui/components/calendar/src/utils/dayTemplate.js @@ -1,20 +1,7 @@ import { html } from 'lit'; import { ifDefined } from 'lit/directives/if-defined.js'; +import { defaultMonthLabels, getDayMonthYear } from './getDayMonthYear.js'; -const defaultMonthLabels = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December', -]; const firstWeekDays = [1, 2, 3, 4, 5, 6, 7]; const lastDaysOfYear = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; @@ -24,10 +11,15 @@ const lastDaysOfYear = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; * @param {{ weekdays: string[], monthsLabels?: string[] }} opts */ export function dayTemplate(day, { weekdays, monthsLabels = defaultMonthLabels }) { - const dayNumber = day.date.getDate(); - const monthName = monthsLabels[day.date.getMonth()]; - const year = day.date.getFullYear(); - const weekdayName = day.weekOrder ? weekdays[day.weekOrder] : weekdays[0]; + const { dayNumber, monthName, year, weekdayName } = getDayMonthYear(day, weekdays, monthsLabels); + + function __getFullDate() { + return `${monthName} ${year} ${weekdayName}`; + } + + function __getAccessibleMessage() { + return `${day.disabledInfo}`; + } const firstDay = dayNumber === 1; const endOfFirstWeek = day.weekOrder === 6 && firstWeekDays.includes(dayNumber); @@ -57,20 +49,14 @@ export function dayTemplate(day, { weekdays, monthsLabels = defaultMonthLabels } ?start-of-last-week=${startOfLastWeek} ?last-day=${lastDay} > - + ${dayNumber} + ${__getFullDate()} ${__getAccessibleMessage()} + `; } diff --git a/packages/ui/components/calendar/src/utils/getDayMonthYear.js b/packages/ui/components/calendar/src/utils/getDayMonthYear.js new file mode 100644 index 0000000000..e985a91dd3 --- /dev/null +++ b/packages/ui/components/calendar/src/utils/getDayMonthYear.js @@ -0,0 +1,28 @@ +export const defaultMonthLabels = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + +/** + * @param {import('../../types/day.js').Day} day + * @param {string[]} weekdays + * @param {string[] } monthsLabels + */ +export function getDayMonthYear(day, weekdays, monthsLabels = defaultMonthLabels) { + const dayNumber = day.date.getDate(); + const monthName = monthsLabels[day.date.getMonth()]; + const year = day.date.getFullYear(); + const weekdayName = day.weekOrder ? weekdays[day.weekOrder] : weekdays[0]; + + return { dayNumber, monthName, year, weekdayName }; +} diff --git a/packages/ui/components/calendar/test-helpers/DayObject.js b/packages/ui/components/calendar/test-helpers/DayObject.js index 7967d2f4d9..fe3b80f149 100644 --- a/packages/ui/components/calendar/test-helpers/DayObject.js +++ b/packages/ui/components/calendar/test-helpers/DayObject.js @@ -34,7 +34,7 @@ export class DayObject { */ get isDisabled() { - return this.buttonEl.hasAttribute('disabled'); + return this.buttonEl.getAttribute('aria-disabled') === 'true'; } get isSelected() { @@ -54,7 +54,7 @@ export class DayObject { } get monthday() { - return Number(this.buttonEl.textContent); + return Number(this.buttonEl.children[0].textContent); } /** diff --git a/packages/ui/components/calendar/test/lion-calendar.test.js b/packages/ui/components/calendar/test/lion-calendar.test.js index aceaf0c88e..cab8bc2496 100644 --- a/packages/ui/components/calendar/test/lion-calendar.test.js +++ b/packages/ui/components/calendar/test/lion-calendar.test.js @@ -396,7 +396,7 @@ describe('', () => { clock.restore(); }); - it('should set centralDate to the unique valid value when minDate and maxDate are equal', async () => { + it('requires the user to set an appropriate centralDate even when minDate and maxDate are equal', async () => { const clock = sinon.useFakeTimers({ now: new Date('2019/06/03').getTime() }); const el = await fixture(html` @@ -405,9 +405,18 @@ describe('', () => { .maxDate="${new Date('2019/07/03')}" > `); - expect(isSameDate(el.centralDate, new Date('2019/07/03')), 'central date').to.be.true; + const elSetting = await fixture(html` + + `); clock.restore(); + expect(isSameDate(el.centralDate, new Date('2019/06/03')), 'central date').to.be.true; + expect(isSameDate(elSetting.centralDate, new Date('2019/07/03')), 'central date').to.be + .true; }); describe('Normalization', () => { @@ -482,6 +491,98 @@ describe('', () => { }); describe('Navigation', () => { + describe('finding enabled dates', () => { + it('has helper for `findNextEnabledDate()`, `findPreviousEnabledDate()`, `findNearestEnabledDate()`', async () => { + const el = await fixture(html` + date.getDate() === 3 || date.getDate() === 4 + } + > + `); + const elObj = new CalendarObject(el); + + el.focusDate(el.findNextEnabledDate()); + await el.updateComplete; + expect(elObj.focusedDayObj?.monthday).to.equal(5); + + el.focusDate(el.findPreviousEnabledDate()); + await el.updateComplete; + expect(elObj.focusedDayObj?.monthday).to.equal(2); + + el.focusDate(el.findNearestEnabledDate()); + await el.updateComplete; + expect(elObj.focusedDayObj?.monthday).to.equal(1); + }); + + it('future dates take precedence over past dates when "distance" between dates is equal', async () => { + const clock = sinon.useFakeTimers({ now: new Date('2000/12/15').getTime() }); + + const el = await fixture(html` + + `); + el.focusDate(el.findNearestEnabledDate()); + await el.updateComplete; + + const elObj = new CalendarObject(el); + expect(elObj.centralDayObj?.monthday).to.equal(16); + + clock.restore(); + }); + + it('will search 750 days in the past', async () => { + const clock = sinon.useFakeTimers({ now: new Date('2000/12/15').getTime() }); + + const el = await fixture(html` + + `); + el.focusDate(el.findNearestEnabledDate()); + await el.updateComplete; + + expect(el.centralDate.getFullYear()).to.equal(1998); + expect(el.centralDate.getMonth()).to.equal(11); + expect(el.centralDate.getDate()).to.equal(31); + + clock.restore(); + }); + + it('will search 750 days in the future', async () => { + const clock = sinon.useFakeTimers({ now: new Date('2000/12/15').getTime() }); + + const el = await fixture(html` + + `); + + el.focusDate(el.findNearestEnabledDate()); + await el.updateComplete; + + expect(el.centralDate.getFullYear()).to.equal(2002); + expect(el.centralDate.getMonth()).to.equal(0); + expect(el.centralDate.getDate()).to.equal(1); + + clock.restore(); + }); + + it('throws if no available date can be found within +/- 750 days', async () => { + const el = await fixture(html` + + `); + + expect(() => { + el.findNextEnabledDate(new Date('1900/01/01')); + }).to.throw(Error, 'Could not find a selectable date within +/- 750 day for 1900/1/1'); + }); + }); + describe('Year', () => { it('has a button for navigation to previous year', async () => { const el = await fixture( @@ -655,7 +756,7 @@ describe('', () => { await el.updateComplete; expect(elObj.activeMonth).to.equal('November'); expect(elObj.activeYear).to.equal('2000'); - expect(isSameDate(el.centralDate, new Date('2000/11/20'))).to.be.true; + expect(isSameDate(el.centralDate, new Date('2000/11/15'))).to.be.true; clock.restore(); }); @@ -677,7 +778,7 @@ describe('', () => { await el.updateComplete; expect(elObj.activeMonth).to.equal('January'); expect(elObj.activeYear).to.equal('2001'); - expect(isSameDate(el.centralDate, new Date('2001/01/10'))).to.be.true; + expect(isSameDate(el.centralDate, new Date('2001/01/15'))).to.be.true; clock.restore(); }); @@ -696,17 +797,21 @@ describe('', () => { expect(remote.activeMonth).to.equal('September'); expect(remote.activeYear).to.equal('2019'); expect(remote.centralDayObj?.el).dom.to.equal(` - + + September 2019 Monday + + `); }); }); @@ -801,7 +906,7 @@ describe('', () => { ).to.equal(true); }); - it('adds "disabled" attribute to disabled dates', async () => { + it('adds aria-disabled="true" attribute to disabled dates', async () => { const clock = sinon.useFakeTimers({ now: new Date('2000/12/15').getTime() }); const el = await fixture(html` @@ -815,7 +920,7 @@ describe('', () => { const elObj = new CalendarObject(el); expect( elObj.checkForAllDayObjs( - /** @param {DayObject} d */ d => d.el.hasAttribute('disabled'), + /** @param {DayObject} d */ d => d.el.getAttribute('aria-disabled') === 'true', [1, 2, 30, 31], ), ).to.equal(true); @@ -972,7 +1077,7 @@ describe('', () => { expect(elObj.focusedDayObj?.monthday).to.equal(12 + 1); }); - it('navigates (sets focus) to next selectable column item via [arrow right] key', async () => { + it('navigates (sets focus) to next column item via [arrow right] key', async () => { const el = await fixture(html` ', () => { new KeyboardEvent('keydown', { key: 'ArrowRight' }), ); await el.updateComplete; - expect(elObj.focusedDayObj?.monthday).to.equal(5); + expect(elObj.focusedDayObj?.monthday).to.equal(3); }); it('navigates (sets focus) to next row via [arrow right] key if last item in row', async () => { @@ -1108,77 +1213,6 @@ describe('', () => { clock.restore(); }); - - it('is on day closest to today, if today (and surrounding dates) is/are disabled', async () => { - const el = await fixture(html` - - `); - const elObj = new CalendarObject(el); - expect(elObj.centralDayObj?.monthday).to.equal(17); - - el.disableDates = d => d.getDate() >= 12; - await el.updateComplete; - expect(elObj.centralDayObj?.monthday).to.equal(11); - }); - - it('future dates take precedence over past dates when "distance" between dates is equal', async () => { - const clock = sinon.useFakeTimers({ now: new Date('2000/12/15').getTime() }); - - const el = await fixture(html` - - `); - const elObj = new CalendarObject(el); - expect(elObj.centralDayObj?.monthday).to.equal(16); - - clock.restore(); - }); - - it('will search 750 days in the past', async () => { - const clock = sinon.useFakeTimers({ now: new Date('2000/12/15').getTime() }); - - const el = await fixture(html` - - `); - expect(el.centralDate.getFullYear()).to.equal(1998); - expect(el.centralDate.getMonth()).to.equal(11); - expect(el.centralDate.getDate()).to.equal(31); - - clock.restore(); - }); - - it('will search 750 days in the future', async () => { - const clock = sinon.useFakeTimers({ now: new Date('2000/12/15').getTime() }); - - const el = await fixture(html` - - `); - expect(el.centralDate.getFullYear()).to.equal(2002); - expect(el.centralDate.getMonth()).to.equal(0); - expect(el.centralDate.getDate()).to.equal(1); - - clock.restore(); - }); - - it('throws if no available date can be found within +/- 750 days', async () => { - const el = await fixture(html` - - `); - - expect(() => { - el.centralDate = new Date('1900/01/01'); - }).to.throw(Error, 'Could not find a selectable date within +/- 750 day for 1900/1/1'); - }); }); /** @@ -1226,7 +1260,8 @@ describe('', () => { it('renders each day as a button inside a table cell', async () => { const elObj = new CalendarObject(await fixture(html``)); - const hasBtn = /** @param {DayObject} d */ d => d.el.tagName === 'BUTTON'; + const hasBtn = /** @param {DayObject} d */ d => + d.el.tagName === 'DIV' && d.el.getAttribute('role') === 'button'; expect(elObj.checkForAllDayObjs(hasBtn)).to.equal(true); }); @@ -1301,31 +1336,6 @@ describe('', () => { expect(elObj.checkForAllDayObjs(hasAriaPressed, [12])).to.equal(true); }); - // This implementation mentions "button" inbetween and doesn't mention table - // column and row. As an alternative, see Deque implementation below. - // it(`on focus on a day, the screen reader pronounces "day of the week", "day number" - // and "month" (in this order)', async () => { - // // implemented by labelelledby referencing row and column names - // const el = await fixture(''); - // }); - - // Alternative: Deque implementation - it(`sets aria-label on button, that consists of - "{day number} {month name} {year} {weekday name}"`, async () => { - const elObj = new CalendarObject( - await fixture(html` - - `), - ); - expect( - elObj.checkForAllDayObjs( - /** @param {DayObject} d */ d => - d.buttonEl.getAttribute('aria-label') === - `${d.monthday} November 2000 ${d.weekdayNameLong}`, - ), - ).to.equal(true); - }); - /** * Not in scope: * - reads the new focused day on month navigation" diff --git a/packages/ui/components/calendar/test/utils/dayTemplate.test.js b/packages/ui/components/calendar/test/utils/dayTemplate.test.js index 47387baabc..e8ff04a7af 100644 --- a/packages/ui/components/calendar/test/utils/dayTemplate.test.js +++ b/packages/ui/components/calendar/test/utils/dayTemplate.test.js @@ -11,14 +11,18 @@ describe('dayTemplate', () => { const el = await fixture(dayTemplate(day, { weekdays })); expect(el).dom.to.equal(` - + + April 2019 Friday + + `); }); diff --git a/packages/ui/components/calendar/test/utils/snapshots/monthTemplate_en-GB_Sunday_2018-12.js b/packages/ui/components/calendar/test/utils/snapshots/monthTemplate_en-GB_Sunday_2018-12.js index 42ed39f0bc..5bf45b5f57 100644 --- a/packages/ui/components/calendar/test/utils/snapshots/monthTemplate_en-GB_Sunday_2018-12.js +++ b/packages/ui/components/calendar/test/utils/snapshots/monthTemplate_en-GB_Sunday_2018-12.js @@ -52,434 +52,518 @@ export default html` - + November 2018 Sunday + - + November 2018 Monday + - + November 2018 Tuesday + - + November 2018 Wednesday + - + November 2018 Thursday + - + November 2018 Friday + - + December 2018 Saturday + - + December 2018 Sunday + - + December 2018 Monday + - + December 2018 Tuesday + - + December 2018 Wednesday + - + December 2018 Thursday + - + December 2018 Friday + - + December 2018 Saturday + - + December 2018 Sunday + - + December 2018 Monday + - + December 2018 Tuesday + - + December 2018 Wednesday + - + December 2018 Thursday + - + December 2018 Friday + - + December 2018 Saturday + - + December 2018 Sunday + - + December 2018 Monday + - + December 2018 Tuesday + - + December 2018 Wednesday + - + December 2018 Thursday + - + December 2018 Friday + - + December 2018 Saturday + - + December 2018 Sunday + - + December 2018 Monday + - + December 2018 Tuesday + - + December 2018 Wednesday + - + December 2018 Thursday + - + December 2018 Friday + - + December 2018 Saturday + - + December 2018 Sunday + - + December 2018 Monday + - + January 2019 Tuesday + - + January 2019 Wednesday + - + January 2019 Thursday + - + January 2019 Friday + - + January 2019 Saturday + diff --git a/packages/ui/components/calendar/types/day.ts b/packages/ui/components/calendar/types/day.ts index ec988c95b2..22e0158ca9 100644 --- a/packages/ui/components/calendar/types/day.ts +++ b/packages/ui/components/calendar/types/day.ts @@ -14,6 +14,7 @@ export declare interface Day { tabindex?: string; ariaPressed?: string; ariaCurrent?: string | undefined; + disabledInfo?: string | undefined; } export declare interface Week { diff --git a/packages/ui/components/input-datepicker/src/LionInputDatepicker.js b/packages/ui/components/input-datepicker/src/LionInputDatepicker.js index d32569d272..ddf1b60aea 100644 --- a/packages/ui/components/input-datepicker/src/LionInputDatepicker.js +++ b/packages/ui/components/input-datepicker/src/LionInputDatepicker.js @@ -96,10 +96,28 @@ export class LionInputDatepicker extends ScopedElementsMixin( __calendarDisableDates: { attribute: false, + type: Array, }, }; } + _inputFormatter = new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts; + + /** + * @param {Date} date + */ + // eslint-disable-next-line class-methods-use-this + _formatDate(date) { + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = String(date.getFullYear()); + return `${day}/${month}/${year}`; + } + get slots() { return { ...super.slots, @@ -346,6 +364,7 @@ export class LionInputDatepicker extends ScopedElementsMixin( } /** + * Triggered when a user selects a date from the calendar overlay * @param {{ target: { selectedDate: Date }}} opts */ _onCalendarUserSelectedChanged({ target: { selectedDate } }) { @@ -355,8 +374,21 @@ export class LionInputDatepicker extends ScopedElementsMixin( if (this._syncOnUserSelect) { // Synchronize new selectedDate value to input this._isHandlingUserInput = true; - this._isHandlingCalendarUserInput = true; - this.modelValue = selectedDate; + + if ( + Array.isArray(this.__calendarDisableDates) && + this.__calendarDisableDates.includes(selectedDate) + ) { + // If the selected date is disabled, reset the values + this.value = ''; + this.formattedValue = ''; + this.modelValue = undefined; + } else { + this.formattedValue = this._formatDate(selectedDate); + this.value = this.formattedValue; + this.modelValue = selectedDate; + } + this._isHandlingUserInput = false; this._isHandlingCalendarUserInput = false; }