From a7a56e502c8d43e343183309079433592d5fb3cd Mon Sep 17 00:00:00 2001 From: Christian Klemp Date: Thu, 1 Jul 2021 13:38:56 -0500 Subject: [PATCH 1/7] feat(b-calendar): add day, month, and year type options --- src/components/calendar/README.md | 112 +++++++ src/components/calendar/_calendar.scss | 10 + src/components/calendar/calendar.js | 362 +++++++++++++++++------ src/components/calendar/calendar.spec.js | 347 ++++++++++++++++++++++ src/components/calendar/package.json | 43 ++- src/constants/date.js | 4 + 6 files changed, 782 insertions(+), 96 deletions(-) diff --git a/src/components/calendar/README.md b/src/components/calendar/README.md index 014420993cc..17730dd97a1 100644 --- a/src/components/calendar/README.md +++ b/src/components/calendar/README.md @@ -58,6 +58,118 @@ If no date is selected, `` returns an empty string `''`, or returns Note that when `value-as-date` prop is set, the returned `Date` object will be in the browser's default timezone. +## Types + +v2.XX.0+ + +Displaying different calendar types can now be specified by the `type` prop. Valid options are +`'date'`, `'day'`, `'month'`, or `'year'`, with the default of `'date'`. + +Setting to `'day'` will display the days of the week. The return value will be the date of the day +of the current week selected. + +```html + + + + +``` + +Setting to `'month'` will display the months of the year. The return value will be the first day of +the selected month. + +```html + + + + +``` + +Setting to `'year'` will display years in a decade. The return value will be the first day of the +selected year. + +```html + + + + +``` + ## Disabled and readonly states Setting the `disabled` prop will remove all interactivity of the `` component. diff --git a/src/components/calendar/_calendar.scss b/src/components/calendar/_calendar.scss index 0bf309f14ab..3cab5583e45 100644 --- a/src/components/calendar/_calendar.scss +++ b/src/components/calendar/_calendar.scss @@ -60,6 +60,16 @@ margin: 3px auto; padding: 9px 0; } + + &.day, + &.month, + &.year { + .btn { + width: auto; + height: auto; + padding: 9px 12px; + } + } } } diff --git a/src/components/calendar/calendar.js b/src/components/calendar/calendar.js index b68013a1910..33226bb150d 100644 --- a/src/components/calendar/calendar.js +++ b/src/components/calendar/calendar.js @@ -5,6 +5,10 @@ import { CALENDAR_LONG, CALENDAR_NARROW, CALENDAR_SHORT, + CALENDAR_TYPE_DATE, + CALENDAR_TYPE_DAY, + CALENDAR_TYPE_MONTH, + CALENDAR_TYPE_YEAR, DATE_FORMAT_2_DIGIT, DATE_FORMAT_NUMERIC } from '../../constants/date' @@ -88,6 +92,8 @@ const { event: MODEL_EVENT_NAME } = makeModelMixin('value', { type: PROP_TYPE_DATE_STRING }) +export const NO_DATE_SELECTED = 'No date selected' + // --- Props --- export const props = makePropsConfigurable( @@ -105,6 +111,25 @@ export const props = makePropsConfigurable( day: DATE_FORMAT_NUMERIC, weekday: CALENDAR_LONG }), + // `Intl.DateTimeFormat` object + dayFormatOptions: makeProp(PROP_TYPE_OBJECT, { + year: undefined, + month: undefined, + day: undefined, + weekday: CALENDAR_LONG + }), + // `Intl.DateTimeFormat` object + monthFormatOptions: makeProp(PROP_TYPE_OBJECT, { + year: undefined, + month: CALENDAR_LONG, + day: undefined + }), + // `Intl.DateTimeFormat` object + yearFormatOptions: makeProp(PROP_TYPE_OBJECT, { + year: DATE_FORMAT_NUMERIC, + month: undefined, + day: undefined + }), // Function to set a class of (classes) on the date cell // if passed a string or an array // TODO: @@ -128,13 +153,16 @@ export const props = makePropsConfigurable( initialDate: makeProp(PROP_TYPE_DATE_STRING), // Labels for buttons and keyboard shortcuts labelCalendar: makeProp(PROP_TYPE_STRING, 'Calendar'), + labelCurrentDecade: makeProp(PROP_TYPE_STRING, 'Current decade'), labelCurrentMonth: makeProp(PROP_TYPE_STRING, 'Current month'), - labelHelp: makeProp(PROP_TYPE_STRING, 'Use cursor keys to navigate calendar dates'), + labelDays: makeProp(PROP_TYPE_STRING, 'Days'), + labelHelp: makeProp(PROP_TYPE_STRING, 'Use cursor keys to navigate calendar'), + labelMonths: makeProp(PROP_TYPE_STRING, 'Months'), labelNav: makeProp(PROP_TYPE_STRING, 'Calendar navigation'), labelNextDecade: makeProp(PROP_TYPE_STRING, 'Next decade'), labelNextMonth: makeProp(PROP_TYPE_STRING, 'Next month'), labelNextYear: makeProp(PROP_TYPE_STRING, 'Next year'), - labelNoDateSelected: makeProp(PROP_TYPE_STRING, 'No date selected'), + labelNoDateSelected: makeProp(PROP_TYPE_STRING, NO_DATE_SELECTED), labelPrevDecade: makeProp(PROP_TYPE_STRING, 'Previous decade'), labelPrevMonth: makeProp(PROP_TYPE_STRING, 'Previous month'), labelPrevYear: makeProp(PROP_TYPE_STRING, 'Previous year'), @@ -161,6 +189,12 @@ export const props = makePropsConfigurable( startWeekday: makeProp(PROP_TYPE_NUMBER_STRING, 0), // Variant color to use for today's date (defaults to `selectedVariant`) todayVariant: makeProp(PROP_TYPE_STRING), + type: makeProp(PROP_TYPE_STRING, CALENDAR_TYPE_DATE, type => { + return arrayIncludes( + [CALENDAR_TYPE_DATE, CALENDAR_TYPE_DAY, CALENDAR_TYPE_MONTH, CALENDAR_TYPE_YEAR], + type + ) + }), // Always return the `v-model` value as a date object valueAsDate: makeProp(PROP_TYPE_BOOLEAN, false), // Format of the weekday names at the top of the calendar @@ -272,6 +306,9 @@ export const BCalendar = Vue.extend({ } return locale }, + calendarDay() { + return this.activeDate.getDay() + }, calendarYear() { return this.activeDate.getFullYear() }, @@ -290,6 +327,12 @@ export const BCalendar = Vue.extend({ date.setMonth(date.getMonth() + 1, 0) return date.getDate() }, + calendarFirstYear() { + return this.calendarYear - parseInt(`${this.calendarYear}`[3]) + }, + calendarLastYear() { + return parseInt(`${parseInt(this.calendarYear / 10, 10) + 1}0`) + }, computedVariant() { return `btn-${this.selectedVariant || 'primary'}` }, @@ -321,7 +364,7 @@ export const BCalendar = Vue.extend({ selectedDate, selectedFormatted: selectedDate ? this.formatDateString(selectedDate) - : this.labelNoDateSelected, + : this.labelNoSelection, // Which date cell is considered active due to navigation activeYMD, activeDate, @@ -364,6 +407,7 @@ export const BCalendar = Vue.extend({ }, // Computed props that return date formatter functions formatDateString() { + const dateFormatOptions = this[`${this.type}FormatOptions`] || {} // Returns a date formatter function return createDateFormatter(this.calendarLocale, { // Ensure we have year, month, day shown for screen readers/ARIA @@ -373,7 +417,7 @@ export const BCalendar = Vue.extend({ month: DATE_FORMAT_2_DIGIT, day: DATE_FORMAT_2_DIGIT, // Merge in user supplied options - ...this.dateFormatOptions, + ...dateFormatOptions, // Ensure hours/minutes/seconds are not shown // As we do not support the time portion (yet) hour: undefined, @@ -383,6 +427,31 @@ export const BCalendar = Vue.extend({ calendar: CALENDAR_GREGORY }) }, + formatDayName() { + // Returns a date formatter function + return createDateFormatter(this.calendarLocale, { + year: undefined, + month: undefined, + weekday: CALENDAR_LONG, + calendar: CALENDAR_GREGORY + }) + }, + formatMonth() { + // Returns a date formatter function + return createDateFormatter(this.calendarLocale, { + year: undefined, + month: CALENDAR_LONG, + calendar: CALENDAR_GREGORY + }) + }, + formatYear() { + // Returns a date formatter function + return createDateFormatter(this.calendarLocale, { + year: DATE_FORMAT_NUMERIC, + month: undefined, + calendar: CALENDAR_GREGORY + }) + }, formatYearMonth() { // Returns a date formatter function return createDateFormatter(this.calendarLocale, { @@ -420,6 +489,20 @@ export const BCalendar = Vue.extend({ // Return a formatter function instance return date => nf.format(date.getDate()) }, + labelCurrent() { + if (this.type === CALENDAR_TYPE_YEAR) { + return this.labelCurrentDecade + } + + return this.labelCurrentMonth + }, + labelNoSelection() { + if (this.labelNoDateSelected !== NO_DATE_SELECTED) { + return this.labelNoDateSelected + } + + return `No ${this.type} selected` + }, // Disabled states for the nav buttons prevDecadeDisabled() { const min = this.computedMin @@ -452,55 +535,133 @@ export const BCalendar = Vue.extend({ // Calendar dates generation calendar() { const matrix = [] - const firstDay = this.calendarFirstDay - const calendarYear = firstDay.getFullYear() - const calendarMonth = firstDay.getMonth() - const daysInMonth = this.calendarDaysInMonth - const startIndex = firstDay.getDay() // `0`..`6` - const weekOffset = (this.computedWeekStarts > startIndex ? 7 : 0) - this.computedWeekStarts - // Build the calendar matrix - let currentDay = 0 - weekOffset - startIndex - for (let week = 0; week < 6 && currentDay < daysInMonth; week++) { - // For each week - matrix[week] = [] - // The following could be a map function - for (let j = 0; j < 7; j++) { - // For each day in week - currentDay++ - const date = createDate(calendarYear, calendarMonth, currentDay) - const month = date.getMonth() - const dayYMD = formatYMD(date) - const dayDisabled = this.dateDisabled(date) - // TODO: This could be a normalizer method - let dateInfo = this.computedDateInfoFn(dayYMD, parseYMD(dayYMD)) - dateInfo = - isString(dateInfo) || isArray(dateInfo) - ? /* istanbul ignore next */ { class: dateInfo } - : isPlainObject(dateInfo) - ? { class: '', ...dateInfo } - : /* istanbul ignore next */ { class: '' } - matrix[week].push({ - ymd: dayYMD, - // Cell content - day: this.formatDay(date), - label: this.formatDateString(date), - // Flags for styling - isThisMonth: month === calendarMonth, - isDisabled: dayDisabled, - // TODO: Handle other dateInfo properties such as notes/events - info: dateInfo + if (this.type === CALENDAR_TYPE_YEAR) { + const firstYear = this.calendarFirstYear + const lastYear = this.calendarLastYear + + let currentYear = firstYear + + for (let row = 0; row < 6 && currentYear < lastYear; row++) { + matrix[row] = [] + + for (let y = 0; y < 2; y++) { + matrix[row].push({ + ymd: `${currentYear}-01-01`, + day: currentYear, + label: currentYear, + info: { + class: 'year' + } + }) + + currentYear++ + } + } + } else if (this.type === CALENDAR_TYPE_MONTH) { + let currentMonth = 0 + + for (let row = 0; row < 7 && currentMonth < 12; row++) { + matrix[row] = [] + + for (let y = 0; y < 2; y++) { + const date = createDate(this.calendarYear, currentMonth, 1) + + matrix[row].push({ + ymd: formatYMD(date), + day: this.formatMonth(date), + label: this.formatMonth(date), + info: { + class: 'month' + } + }) + + currentMonth++ + } + } + } else if (this.type === CALENDAR_TYPE_DAY) { + const today = createDate() + const offset = today.getDay() + let currentDay = createDate( + this.calendarYear, + this.calendarMonth, + today.getDate() - offset + ).getDate() + + console.log({ + currentDay, + today, + offset + }) + + for (let row = 0; row < 7; row++) { + matrix[row] = [] + const date = createDate(this.calendarYear, this.calendarMonth, currentDay) + + matrix[row].push({ + ymd: formatYMD(date), + day: this.formatDayName(date), + label: this.formatDayName(date), + info: { + class: 'day' + } }) + + currentDay++ + } + } else { + const firstDay = this.calendarFirstDay + const calendarYear = firstDay.getFullYear() + const calendarMonth = firstDay.getMonth() + const daysInMonth = this.calendarDaysInMonth + const startIndex = firstDay.getDay() // `0`..`6` + const weekOffset = (this.computedWeekStarts > startIndex ? 7 : 0) - this.computedWeekStarts + // Build the calendar matrix + let currentDay = 0 - weekOffset - startIndex + for (let week = 0; week < 6 && currentDay < daysInMonth; week++) { + // For each week + matrix[week] = [] + // The following could be a map function + for (let j = 0; j < 7; j++) { + // For each day in week + currentDay++ + const date = createDate(calendarYear, calendarMonth, currentDay) + const month = date.getMonth() + const dayYMD = formatYMD(date) + const dayDisabled = this.dateDisabled(date) + // TODO: This could be a normalizer method + let dateInfo = this.computedDateInfoFn(dayYMD, parseYMD(dayYMD)) + dateInfo = + isString(dateInfo) || isArray(dateInfo) + ? /* istanbul ignore next */ { class: dateInfo } + : isPlainObject(dateInfo) + ? { class: '', ...dateInfo } + : /* istanbul ignore next */ { class: '' } + matrix[week].push({ + ymd: dayYMD, + // Cell content + day: this.formatDay(date), + label: this.formatDateString(date), + // Flags for styling + isThisMonth: month === calendarMonth, + isDisabled: dayDisabled, + // TODO: Handle other dateInfo properties such as notes/events + info: dateInfo + }) + } } } + return matrix }, calendarHeadings() { - return this.calendar[0].map(d => { - return { - text: this.formatWeekdayNameShort(parseYMD(d.ymd)), - label: this.formatWeekdayName(parseYMD(d.ymd)) - } - }) + return this.type === CALENDAR_TYPE_DATE + ? this.calendar[0].map(d => { + return { + text: this.formatWeekdayNameShort(parseYMD(d.ymd)), + label: this.formatWeekdayName(parseYMD(d.ymd)) + } + }) + : [] } }, watch: { @@ -804,7 +965,7 @@ export const BCalendar = Vue.extend({ h('bdi', { staticClass: 'sr-only' }, ` (${toString(this.labelSelected)}) `), h('bdi', this.formatDateString(this.selectedDate)) ] - : this.labelNoDateSelected || '\u00a0' // ' ' + : this.labelNoSelection || '\u00a0' // ' ' ) $header = h( this.headerTag, @@ -879,59 +1040,69 @@ export const BCalendar = Vue.extend({ } }, [ - hideDecadeNav - ? h() - : makeNavBtn( + !hideDecadeNav || this.type === CALENDAR_TYPE_YEAR + ? makeNavBtn( $prevDecadeIcon, this.labelPrevDecade, this.gotoPrevDecade, this.prevDecadeDisabled, 'Ctrl+Alt+PageDown' - ), - makeNavBtn( - $prevYearIcon, - this.labelPrevYear, - this.gotoPrevYear, - this.prevYearDisabled, - 'Alt+PageDown' - ), - makeNavBtn( - $prevMonthIcon, - this.labelPrevMonth, - this.gotoPrevMonth, - this.prevMonthDisabled, - 'PageDown' - ), - makeNavBtn( - $thisMonthIcon, - this.labelCurrentMonth, - this.gotoCurrentMonth, - this.thisMonthDisabled, - 'Home' - ), - makeNavBtn( - $nextMonthIcon, - this.labelNextMonth, - this.gotoNextMonth, - this.nextMonthDisabled, - 'PageUp' - ), - makeNavBtn( - $nextYearIcon, - this.labelNextYear, - this.gotoNextYear, - this.nextYearDisabled, - 'Alt+PageUp' - ), - hideDecadeNav - ? h() - : makeNavBtn( + ) + : h(), + this.type === CALENDAR_TYPE_DATE + ? makeNavBtn( + $prevYearIcon, + this.labelPrevYear, + this.gotoPrevYear, + this.prevYearDisabled, + 'Alt+PageDown' + ) + : h(), + this.type === CALENDAR_TYPE_DATE + ? makeNavBtn( + $prevMonthIcon, + this.labelPrevMonth, + this.gotoPrevMonth, + this.prevMonthDisabled, + 'PageDown' + ) + : h(), + this.type !== CALENDAR_TYPE_DAY && this.type !== CALENDAR_TYPE_MONTH + ? makeNavBtn( + $thisMonthIcon, + this.labelCurrent, + this.gotoCurrentMonth, + this.thisMonthDisabled, + 'Home' + ) + : h(), + this.type === CALENDAR_TYPE_DATE + ? makeNavBtn( + $nextMonthIcon, + this.labelNextMonth, + this.gotoNextMonth, + this.nextMonthDisabled, + 'PageUp' + ) + : h(), + this.type === CALENDAR_TYPE_DATE + ? makeNavBtn( + $nextYearIcon, + this.labelNextYear, + this.gotoNextYear, + this.nextYearDisabled, + 'Alt+PageUp' + ) + : h(), + !hideDecadeNav || this.type === CALENDAR_TYPE_YEAR + ? makeNavBtn( $nextDecadeIcon, this.labelNextDecade, this.gotoNextDecade, this.nextDecadeDisabled, 'Ctrl+Alt+PageUp' ) + : h() ] ) @@ -948,7 +1119,13 @@ export const BCalendar = Vue.extend({ }, key: 'grid-caption' }, - this.formatYearMonth(this.calendarFirstDay) + this.type === CALENDAR_TYPE_DATE + ? this.formatYearMonth(this.calendarFirstDay) + : this.type === CALENDAR_TYPE_DAY + ? this.labelDays + : this.type === CALENDAR_TYPE_MONTH + ? this.labelMonths + : `${this.calendarFirstYear} - ${this.calendarLastYear - 1}` ) // Calendar weekday headings @@ -986,7 +1163,7 @@ export const BCalendar = Vue.extend({ const $btn = h( 'span', { - staticClass: 'btn border-0 rounded-circle text-nowrap', + staticClass: 'btn border-0 text-nowrap', // Should we add some classes to signify if today/selected/etc? class: { // Give the fake button a focus ring @@ -996,6 +1173,7 @@ export const BCalendar = Vue.extend({ active: isSelected, // makes the button look "pressed" // Selected date style (need to computed from variant) [this.computedVariant]: isSelected, + 'rounded-circle': this.type === CALENDAR_TYPE_DATE, // Today day style (if not selected), same variant color as selected date [this.computedTodayVariant]: isToday && highlightToday && !isSelected && day.isThisMonth, diff --git a/src/components/calendar/calendar.spec.js b/src/components/calendar/calendar.spec.js index 2878cf2dbc4..53fe774f7ac 100644 --- a/src/components/calendar/calendar.spec.js +++ b/src/components/calendar/calendar.spec.js @@ -463,4 +463,351 @@ describe('calendar', () => { wrapper.destroy() }) + + describe('types', () => { + describe('date', () => { + it('has expected header output when no value is set', async () => { + const wrapper = mount(BCalendar, { + attachTo: createContainer(), + propsData: { + type: 'date' + } + }) + + expect(wrapper.vm).toBeDefined() + await waitNT(wrapper.vm) + await waitRAF() + + const $header = wrapper.find('.b-calendar>div>header') + expect($header.exists()).toBe(true) + expect($header.find('output').exists()).toBe(true) + expect($header.find('output').text()).toEqual('No date selected') + + wrapper.destroy() + }) + + it('has the correct navigation buttons for day `type` picker', async () => { + const wrapper = mount(BCalendar, { + attachTo: createContainer(), + propsData: { + value: '2021-01-01' // January 1, 2021 + } + }) + + expect(wrapper.vm).toBeDefined() + await waitNT(wrapper.vm) + await waitRAF() + + const $grid = wrapper.find('[role="application"]') + expect($grid.exists()).toBe(true) + expect($grid.attributes('data-month')).toBe('2021-01') + expect($grid.find('.b-calendar-grid-caption').text()).toEqual('January 2021') + + const $navBtns = wrapper.findAll('.b-calendar-nav button') + expect($navBtns.length).toBe(5) + + wrapper.destroy() + }) + + it('grid contains days', async () => { + const wrapper = mount(BCalendar, { + attachTo: createContainer() + }) + + expect(wrapper.vm).toBeDefined() + await waitNT(wrapper.vm) + await waitRAF() + + const $grid = wrapper.find('[role="application"]') + expect($grid.exists()).toBe(true) + const $gridBody = $grid.find('.b-calendar-grid-body') + expect($gridBody.findAll('.row').length).toBeGreaterThanOrEqual(4) + expect($gridBody.find('.row').findAll('.col').length).toBe(7) + + wrapper.destroy() + }) + }) + + describe('day', () => { + it('has expected header output when no value is set', async () => { + const wrapper = mount(BCalendar, { + attachTo: createContainer(), + propsData: { + type: 'day' + } + }) + + expect(wrapper.vm).toBeDefined() + await waitNT(wrapper.vm) + await waitRAF() + + const $header = wrapper.find('.b-calendar>div>header') + expect($header.exists()).toBe(true) + expect($header.find('output').exists()).toBe(true) + expect($header.find('output').text()).toEqual('No day selected') + + wrapper.destroy() + }) + + it('navigation buttons do not exist', async () => { + const wrapper = mount(BCalendar, { + attachTo: createContainer(), + propsData: { + value: '2021-01-01', + type: 'day' + } + }) + + expect(wrapper.vm).toBeDefined() + await waitNT(wrapper.vm) + await waitRAF() + + const $grid = wrapper.find('[role="application"]') + expect($grid.exists()).toBe(true) + expect($grid.attributes('data-month')).toBe('2021-01') + expect($grid.find('.b-calendar-grid-caption').text()).toEqual('Days') + + const $navBtns = wrapper.findAll('.b-calendar-nav button') + expect($navBtns.length).toBe(0) + + wrapper.destroy() + }) + + it('grid contains days of the week', async () => { + const wrapper = mount(BCalendar, { + attachTo: createContainer() + }) + + expect(wrapper.vm).toBeDefined() + await waitNT(wrapper.vm) + await waitRAF() + + const $grid = wrapper.find('[role="application"]') + expect($grid.exists()).toBe(true) + const $gridBody = $grid.find('.b-calendar-grid-body') + expect($gridBody.findAll('.row').length).toBeGreaterThanOrEqual(4) + expect($gridBody.find('.row').findAll('.col').length).toBe(7) + + wrapper.destroy() + }) + }) + + describe('month', () => { + it('has expected header output when no value is set', async () => { + const wrapper = mount(BCalendar, { + attachTo: createContainer(), + propsData: { + type: 'month' + } + }) + + expect(wrapper.vm).toBeDefined() + await waitNT(wrapper.vm) + await waitRAF() + + const $header = wrapper.find('.b-calendar>div>header') + expect($header.exists()).toBe(true) + expect($header.find('output').exists()).toBe(true) + expect($header.find('output').text()).toEqual('No month selected') + + wrapper.destroy() + }) + + it('clicking a month selects it', async () => { + const wrapper = mount(BCalendar, { + attachTo: createContainer(), + propsData: { + value: '2021-01-01', // January + type: 'month' + } + }) + + expect(wrapper.vm).toBeDefined() + await waitNT(wrapper.vm) + await waitRAF() + + const $grid = wrapper.find('[role="application"]') + expect($grid.exists()).toBe(true) + + const $cell = wrapper.find('[data-date="2021-02-01"]') // February + expect($cell.exists()).toBe(true) + expect($cell.attributes('aria-selected')).toBeUndefined() + expect($cell.attributes('id')).toBeDefined() + const $btn = $cell.find('.btn') + expect($btn.exists()).toBe(true) + expect($cell.attributes('id')).toBeDefined() + expect($grid.attributes('aria-activedescendant')).toBeDefined() + expect($grid.attributes('aria-activedescendant')).not.toEqual($cell.attributes('id')) + + await $btn.trigger('click') + + expect($cell.attributes('aria-selected')).toBeDefined() + expect($cell.attributes('aria-selected')).toEqual('true') + expect($grid.attributes('aria-activedescendant')).toEqual($cell.attributes('id')) + + expect(wrapper.vm.selectedYMD).toBe('2021-02-01') + + wrapper.destroy() + }) + + it('navigation buttons do not exist', async () => { + const wrapper = mount(BCalendar, { + attachTo: createContainer(), + propsData: { + type: 'month', + value: '2021-01-01' // January + } + }) + + expect(wrapper.vm).toBeDefined() + await waitNT(wrapper.vm) + await waitRAF() + + const $grid = wrapper.find('[role="application"]') + expect($grid.exists()).toBe(true) + expect($grid.attributes('data-month')).toBe('2021-01') + expect($grid.find('.b-calendar-grid-caption').text()).toEqual('Months') + + const $navBtns = wrapper.findAll('.b-calendar-nav button') + expect($navBtns.length).toBe(0) + + wrapper.destroy() + }) + + it('grid contains months', async () => { + const wrapper = mount(BCalendar, { + attachTo: createContainer(), + propsData: { + type: 'month' + } + }) + + expect(wrapper.vm).toBeDefined() + await waitNT(wrapper.vm) + await waitRAF() + + const $grid = wrapper.find('[role="application"]') + expect($grid.exists()).toBe(true) + const $gridBody = $grid.find('.b-calendar-grid-body') + expect($gridBody.findAll('.row').length).toBe(6) + expect($gridBody.findAll('.row .col.month').length).toBe(12) + + wrapper.destroy() + }) + }) + + describe('year', () => { + it('has expected header output when no value is set', async () => { + const wrapper = mount(BCalendar, { + attachTo: createContainer(), + propsData: { + type: 'year' + } + }) + + expect(wrapper.vm).toBeDefined() + await waitNT(wrapper.vm) + await waitRAF() + + const $header = wrapper.find('.b-calendar>div>header') + expect($header.exists()).toBe(true) + expect($header.find('output').exists()).toBe(true) + expect($header.find('output').text()).toEqual('No year selected') + + wrapper.destroy() + }) + + it('clicking a year selects it', async () => { + const wrapper = mount(BCalendar, { + attachTo: createContainer(), + propsData: { + value: '2021-01-01', // 2021 + type: 'year' + } + }) + + expect(wrapper.vm).toBeDefined() + await waitNT(wrapper.vm) + await waitRAF() + + const $grid = wrapper.find('[role="application"]') + expect($grid.exists()).toBe(true) + + const $cell = wrapper.find('[data-date="2022-01-01"]') // 2022 + expect($cell.exists()).toBe(true) + expect($cell.attributes('aria-selected')).toBeUndefined() + expect($cell.attributes('id')).toBeDefined() + const $btn = $cell.find('.btn') + expect($btn.exists()).toBe(true) + expect($cell.attributes('id')).toBeDefined() + expect($grid.attributes('aria-activedescendant')).toBeDefined() + expect($grid.attributes('aria-activedescendant')).not.toEqual($cell.attributes('id')) + + await $btn.trigger('click') + + expect($cell.attributes('aria-selected')).toBeDefined() + expect($cell.attributes('aria-selected')).toEqual('true') + expect($grid.attributes('aria-activedescendant')).toEqual($cell.attributes('id')) + + expect(wrapper.vm.selectedYMD).toBe('2022-01-01') + + wrapper.destroy() + }) + + it('decade navigation buttons work', async () => { + const wrapper = mount(BCalendar, { + attachTo: createContainer(), + propsData: { + type: 'year', + value: '2021-01-01' // 2021 + } + }) + + expect(wrapper.vm).toBeDefined() + await waitNT(wrapper.vm) + await waitRAF() + + const $grid = wrapper.find('[role="application"]') + expect($grid.exists()).toBe(true) + expect($grid.attributes('data-month')).toBe('2021-01') + expect($grid.find('.b-calendar-grid-caption').text()).toEqual('2020 - 2029') + + const $navBtns = wrapper.findAll('.b-calendar-nav button') + expect($navBtns.length).toBe(3) + + // Prev Decade + await $navBtns.at(0).trigger('click') + expect($grid.attributes('data-month')).toBe('2011-01') + expect($grid.find('.b-calendar-grid-caption').text()).toEqual('2010 - 2019') + + // Next Decade + await $navBtns.at(2).trigger('click') + expect($grid.attributes('data-month')).toBe('2021-01') + expect($grid.find('.b-calendar-grid-caption').text()).toEqual('2020 - 2029') + + wrapper.destroy() + }) + + it('grid contains years', async () => { + const wrapper = mount(BCalendar, { + attachTo: createContainer(), + propsData: { + type: 'year' + } + }) + + expect(wrapper.vm).toBeDefined() + await waitNT(wrapper.vm) + await waitRAF() + + const $grid = wrapper.find('[role="application"]') + expect($grid.exists()).toBe(true) + const $gridBody = $grid.find('.b-calendar-grid-body') + expect($gridBody.findAll('.row').length).toBe(5) + expect($gridBody.findAll('.row .col.year').length).toBe(10) + + wrapper.destroy() + }) + }) + }) }) diff --git a/src/components/calendar/package.json b/src/components/calendar/package.json index 60e7f43da44..14f50ae8dce 100644 --- a/src/components/calendar/package.json +++ b/src/components/calendar/package.json @@ -22,7 +22,22 @@ { "prop": "dateFormatOptions", "version": "2.6.0", - "description": "Format object for displayed text string that is passed to `Intl.DateTimeFormat`" + "description": "Format object for displayed text string that is passed to `Intl.DateTimeFormat` for `date` `type` calendar. Ignored for `day`, `month`, and `year` `type` calendars." + }, + { + "prop": "dayFormatOptions", + "version": "2.XX.0", + "description": "Format object for displayed text string that is passed to `Intl.DateTimeFormat` for `day` `type` calendar. Ignored for `date`, `month`, and `year` `type` calendars." + }, + { + "prop": "monthFormatOptions", + "version": "2.XX.0", + "description": "Format object for displayed text string that is passed to `Intl.DateTimeFormat` for `month` `type` calendar. Ignored for `date`, `day`, and `year` `type` calendars." + }, + { + "prop": "yearFormatOptions", + "version": "2.XX.0", + "description": "Format object for displayed text string that is passed to `Intl.DateTimeFormat` for `year` `type` calendar. Ignored for `date`, `day`, and `month` `type` calendars." }, { "prop": "dateInfoFn", @@ -60,12 +75,27 @@ }, { "prop": "labelCurrentMonth", - "description": "Value of the `aria-label` and `title` attributes on the `Current Month` navigation button" + "description": "Value of the `aria-label` and `title` attributes on the `Current Month` navigation button. Utilized when the `type` is set to `day`." + }, + { + "prop": "labelCurrentDecade", + "version": "2.XX.0", + "description": "Value of the `aria-label` and `title` attributes on the `Current Decade` navigation button. Only utilized the the `type` is set to `year`." + }, + { + "prop": "labelDays", + "version": "2.XX.0", + "description": "The heading of the picker when `type` is set to `day`" }, { "prop": "labelHelp", "description": "Help text that appears at the bottom of the calendar grid" }, + { + "prop": "labelMonths", + "version": "2.XX.0", + "description": "The heading of the picker when `type` is set to `month`" + }, { "prop": "labelNav", "description": "Value of the `aria-label` attribute on to the calendar navigation button wrapper" @@ -156,7 +186,12 @@ }, { "prop": "todayVariant", - "description": "Theme color variant to use for highlighting todays date button. Defaults to the `selectedVariant` prop" + "description": "Theme color variant to use for highlighting today's date button. Defaults to the `selectedVariant` prop" + }, + { + "prop": "type", + "version": "2.XX.0", + "description": "The type of calendar to display. Accepts `date`, `day`, `month`, or `year`." }, { "prop": "value", @@ -183,7 +218,7 @@ "args": [ { "arg": "context", - "description": "The context object. See documentaion for details", + "description": "The context object. See documentation for details", "type": [ "Object" ] diff --git a/src/constants/date.js b/src/constants/date.js index ba556a08d96..18de4db5b5f 100644 --- a/src/constants/date.js +++ b/src/constants/date.js @@ -2,6 +2,10 @@ export const CALENDAR_GREGORY = 'gregory' export const CALENDAR_LONG = 'long' export const CALENDAR_NARROW = 'narrow' export const CALENDAR_SHORT = 'short' +export const CALENDAR_TYPE_DATE = 'date' +export const CALENDAR_TYPE_DAY = 'day' +export const CALENDAR_TYPE_MONTH = 'month' +export const CALENDAR_TYPE_YEAR = 'year' export const DATE_FORMAT_2_DIGIT = '2-digit' export const DATE_FORMAT_NUMERIC = 'numeric' From 3ef148f7e2cd8623ab2e8038eac6827436dc1f20 Mon Sep 17 00:00:00 2001 From: Christian Klemp Date: Thu, 1 Jul 2021 13:41:12 -0500 Subject: [PATCH 2/7] feat(b-form-datepicker): add day, month, and year type picker options --- src/components/form-datepicker/README.md | 51 ++++++++ .../form-datepicker/form-datepicker.js | 46 +++++++- .../form-datepicker/form-datepicker.spec.js | 111 ++++++++++++++++++ src/components/form-datepicker/package.json | 41 ++++++- 4 files changed, 240 insertions(+), 9 deletions(-) diff --git a/src/components/form-datepicker/README.md b/src/components/form-datepicker/README.md index 1bc488b6a31..816eb47acb8 100644 --- a/src/components/form-datepicker/README.md +++ b/src/components/form-datepicker/README.md @@ -55,6 +55,57 @@ will have its name attribute set to the value of the `name` prop, and the value set to the selected date as a `YYYY-MM-DD` string. This will allow the `` selected value to be submitted via native browser form submission. +## Picker Types + +v2.XX.0+ + +Different picker types can now be specified by the `type` prop. Valid options are `'date'`, `'day'`, +`'month'`, or `'year'`, with the default of `'date'`. + +Setting to `'day'` will allow for selection of a day of the week, `'month'` will allow for selection +of a month, and `'year'` will allow for selection of a year in a decade. The return value of `'day'` +will be the date of the current week's day selected, the first of the month for `'month'`, and the +first of the year for `'year'` for selected value consistency across all types. + +```html + + + + + +``` + ## Disabled and readonly states Setting the `disabled` prop will remove all interactivity of the `` component. diff --git a/src/components/form-datepicker/form-datepicker.js b/src/components/form-datepicker/form-datepicker.js index 942bd87bf82..272f8efe028 100644 --- a/src/components/form-datepicker/form-datepicker.js +++ b/src/components/form-datepicker/form-datepicker.js @@ -1,5 +1,6 @@ import { Vue } from '../../vue' import { NAME_FORM_DATEPICKER } from '../../constants/components' +import { CALENDAR_TYPE_DAY, CALENDAR_TYPE_MONTH, CALENDAR_TYPE_YEAR } from '../../constants/date' import { EVENT_NAME_CONTEXT, EVENT_NAME_HIDDEN, EVENT_NAME_SHOWN } from '../../constants/events' import { PROP_TYPE_BOOLEAN, PROP_TYPE_DATE_STRING, PROP_TYPE_STRING } from '../../constants/props' import { SLOT_NAME_BUTTON_CONTENT } from '../../constants/slots' @@ -10,9 +11,18 @@ import { makeModelMixin } from '../../utils/model' import { omit, pick, sortKeys } from '../../utils/object' import { makeProp, makePropsConfigurable, pluckProps } from '../../utils/props' import { idMixin, props as idProps } from '../../mixins/id' -import { BIconCalendar, BIconCalendarFill } from '../../icons/icons' +import { + BIconCalendar, + BIconCalendarFill, + BIconCalendar3, + BIconCalendar3Fill, + BIconCalendarDay, + BIconCalendarDayFill, + BIconCalendarMonth, + BIconCalendarMonthFill +} from '../../icons/icons' import { BButton } from '../button/button' -import { BCalendar, props as BCalendarProps } from '../calendar/calendar' +import { BCalendar, NO_DATE_SELECTED, props as BCalendarProps } from '../calendar/calendar' import { BVFormBtnLabelControl, props as BVFormBtnLabelControlProps @@ -103,6 +113,13 @@ export const BFormDatepicker = /*#__PURE__*/ Vue.extend({ }, computedResetValue() { return formatYMD(constrainDate(this.resetValue)) || '' + }, + labelNoSelection() { + if (this.labelNoDateSelected !== NO_DATE_SELECTED) { + return this.labelNoDateSelected + } + + return `No ${this.type} selected` } }, watch: { @@ -194,15 +211,32 @@ export const BFormDatepicker = /*#__PURE__*/ Vue.extend({ }, // Render helpers defaultButtonFn({ isHovered, hasFocus }) { - return this.$createElement(isHovered || hasFocus ? BIconCalendarFill : BIconCalendar, { - attrs: { 'aria-hidden': 'true' } - }) + return this.$createElement( + isHovered || hasFocus + ? this.type === CALENDAR_TYPE_MONTH + ? BIconCalendarMonthFill + : this.type === CALENDAR_TYPE_DAY + ? BIconCalendarDayFill + : this.type === CALENDAR_TYPE_YEAR + ? BIconCalendar3Fill + : BIconCalendarFill + : this.type === CALENDAR_TYPE_MONTH + ? BIconCalendarMonth + : this.type === CALENDAR_TYPE_DAY + ? BIconCalendarDay + : this.type === CALENDAR_TYPE_YEAR + ? BIconCalendar3 + : BIconCalendar, + { + attrs: { 'aria-hidden': 'true' } + } + ) } }, render(h) { const { localYMD, disabled, readonly, dark, $props, $scopedSlots } = this const placeholder = isUndefinedOrNull(this.placeholder) - ? this.labelNoDateSelected + ? this.labelNoSelection : this.placeholder // Optional footer buttons diff --git a/src/components/form-datepicker/form-datepicker.spec.js b/src/components/form-datepicker/form-datepicker.spec.js index 300ece98659..e4c5f5b5c35 100644 --- a/src/components/form-datepicker/form-datepicker.spec.js +++ b/src/components/form-datepicker/form-datepicker.spec.js @@ -559,4 +559,115 @@ describe('form-date', () => { wrapper.destroy() }) + + it('type prop gets passed to the b-calendar component correctly', async () => { + const wrapper = mount(BFormDatepicker, { + attachTo: createContainer(), + propsData: { + id: 'type-test', + type: 'year', + value: '' + } + }) + + expect(wrapper.vm).toBeDefined() + await waitNT(wrapper.vm) + await waitRAF() + + const $toggle = wrapper.find('button#type-test') + $toggle.trigger('click') + await waitNT(wrapper.vm) + await waitRAF() + + const $picker = wrapper.find('.b-calendar') + expect($picker.findAll('.col.year').length).toEqual(10) + + wrapper.destroy() + }) + + it('formatting based on type is correct', async () => { + const wrapper = mount(BFormDatepicker, { + attachTo: createContainer(), + propsData: { + type: 'year', + value: '2021-01-01' + } + }) + + await waitNT(wrapper.vm) + await waitRAF() + expect(wrapper.vm.formattedValue).toBe('2021') + wrapper.setProps({ + type: 'month' + }) + + await waitNT(wrapper.vm) + await waitRAF() + expect(wrapper.vm.formattedValue).toBe('January') + wrapper.setProps({ + type: 'day' + }) + + await waitNT(wrapper.vm) + await waitRAF() + expect(wrapper.vm.formattedValue).toBe('Friday') + + wrapper.destroy() + }) + + it('proper icon is used based on type', async () => { + const wrapper = mount(BFormDatepicker, { + attachTo: createContainer(), + propsData: { + id: 'test-type-icon', + type: 'date', + value: '' + } + }) + + expect(wrapper.vm).toBeDefined() + await waitNT(wrapper.vm) + await waitRAF() + + const typeIcons = { + date: 'calendar', + day: 'calendar-day', + month: 'calendar-month', + year: 'calendar3' + } + + const $toggle = wrapper.find('button#test-type-icon') + const $label = wrapper.find('button#test-type-icon ~ label') + + for (const type of Object.keys(typeIcons)) { + wrapper.setProps({ + type + }) + + await waitNT(wrapper.vm) + await waitRAF() + + expect($toggle.find(`svg.bi-${typeIcons[type]}`).exists()).toBe(true) + expect($toggle.find(`svg.bi-${typeIcons[type]}-fill`).exists()).toBe(false) + + await $toggle.trigger('mouseenter') + expect($toggle.find(`svg.bi-${typeIcons[type]}`).exists()).toBe(false) + expect($toggle.find(`svg.bi-${typeIcons[type]}-fill`).exists()).toBe(true) + + await $toggle.trigger('mouseleave') + expect($toggle.find(`svg.bi-${typeIcons[type]}`).exists()).toBe(true) + expect($toggle.find(`svg.bi-${typeIcons[type]}-fill`).exists()).toBe(false) + + await $label.trigger('mouseenter') + expect($toggle.find(`svg.bi-${typeIcons[type]}`).exists()).toBe(false) + expect($toggle.find(`svg.bi-${typeIcons[type]}-fill`).exists()).toBe(true) + + await $label.trigger('mouseleave') + expect($toggle.find(`svg.bi-${typeIcons[type]}`).exists()).toBe(true) + expect($toggle.find(`svg.bi-${typeIcons[type]}-fill`).exists()).toBe(false) + } + + expect.assertions(Object.keys(typeIcons).length * 10 + 1) + wrapper.destroy() + }) }) diff --git a/src/components/form-datepicker/package.json b/src/components/form-datepicker/package.json index 4d8b46a1e6b..6132a4e5f16 100644 --- a/src/components/form-datepicker/package.json +++ b/src/components/form-datepicker/package.json @@ -53,7 +53,22 @@ { "prop": "dateFormatOptions", "version": "2.6.0", - "description": "Format object for displayed text string that is passed to `Intl.DateTimeFormat`" + "description": "Format object for displayed text string that is passed to `Intl.DateTimeFormat` for `date` `type` calendar. Ignored for `day`, `month`, and `year` `type` calendars." + }, + { + "prop": "dayFormatOptions", + "version": "2.XX.0", + "description": "Format object for displayed text string that is passed to `Intl.DateTimeFormat` for `day` `type` calendar. Ignored for `date`, `month`, and `year` `type` calendars." + }, + { + "prop": "monthFormatOptions", + "version": "2.XX.0", + "description": "Format object for displayed text string that is passed to `Intl.DateTimeFormat` for `month` `type` calendar. Ignored for `date`, `day`, and `year` `type` calendars." + }, + { + "prop": "yearFormatOptions", + "version": "2.XX.0", + "description": "Format object for displayed text string that is passed to `Intl.DateTimeFormat` for `year` `type` calendar. Ignored for `date`, `day`, and `month` `type` calendars." }, { "prop": "dateInfoFn", @@ -99,12 +114,27 @@ }, { "prop": "labelCurrentMonth", - "description": "Value of the `aria-label` and `title` attributes on the `Current Month` navigation button" + "description": "Value of the `aria-label` and `title` attributes on the `Current Month` navigation button. Utilized when the `type` is set to `day`." + }, + { + "prop": "labelCurrentDecade", + "version": "2.XX.0", + "description": "Value of the `aria-label` and `title` attributes on the `Current Decade` navigation button. Only utilized the the `type` is set to `year`." + }, + { + "prop": "labelDays", + "version": "2.XX.0", + "description": "The heading of the picker when `type` is set to `day`" }, { "prop": "labelHelp", "description": "Help text that appears at the bottom of the calendar grid" }, + { + "prop": "labelMonths", + "version": "2.XX.0", + "description": "The heading of the picker when `type` is set to `month`" + }, { "prop": "labelNav", "description": "Value of the `aria-label` attribute on to the calendar navigation button wrapper" @@ -248,7 +278,12 @@ }, { "prop": "todayVariant", - "description": "Theme color variant to use for highlighting todays date button. Defaults to the `selectedVariant` prop" + "description": "Theme color variant to use for highlighting today's date button. Defaults to the `selectedVariant` prop" + }, + { + "prop": "type", + "version": "2.XX.0", + "description": "The type of calendar to use for the picker. Accepts `date`, `day`, `month`, or `year`." }, { "prop": "value", From 28af57a414a269ee73969384b24eb061874f8e4a Mon Sep 17 00:00:00 2001 From: Christian Klemp Date: Thu, 7 Oct 2021 08:34:16 -0500 Subject: [PATCH 3/7] docs(b-calendar): update example documentation to be simpler --- src/components/calendar/README.md | 95 +++++++------------------------ 1 file changed, 22 insertions(+), 73 deletions(-) diff --git a/src/components/calendar/README.md b/src/components/calendar/README.md index 17730dd97a1..31752193901 100644 --- a/src/components/calendar/README.md +++ b/src/components/calendar/README.md @@ -65,84 +65,32 @@ default timezone. Displaying different calendar types can now be specified by the `type` prop. Valid options are `'date'`, `'day'`, `'month'`, or `'year'`, with the default of `'date'`. -Setting to `'day'` will display the days of the week. The return value will be the date of the day -of the current week selected. +Setting to `'day'` will display the days of the week, and the return value will be the date of the +day of the current week selected. Setting to `'month'` will display the months of the year, and the +return value will be the first day of the selected month. Setting to `'year'` will display years in +a decade, and the return value will be the first day of the selected year. ```html - - - -``` - -Setting to `'month'` will display the months of the year. The return value will be the first day of -the selected month. - -```html - - - - -``` - -Setting to `'year'` will display years in a decade. The return value will be the first day of the -selected year. - -```html -