diff --git a/src/components/calendar/README.md b/src/components/calendar/README.md index fde90bef268..2debb6ab272 100644 --- a/src/components/calendar/README.md +++ b/src/components/calendar/README.md @@ -230,6 +230,14 @@ fit the width of the parent element. The `width` prop has no effect when `block` Note it is _not recommended_ to set a width below `260px`, otherwise truncation and layout issues with the component may occur. +### Initial open calendar date + +By default, when no date is selected, the calendar view will be set to the current month (or the +`min` or `max` date if today's date is out of range of `min` or `max`). You can change this +behaviour by specifying a date via the `initial-date` prop. The initial date prop will be used to +determine the calendar month to be initially presented to the user. It does not set the component's +value. + ### Date string format v2.6.0+ @@ -632,5 +640,5 @@ verbosity and to provide consistency across various screen readers (NVDA, when e ## See also - [`` Date picker custom form input](/docs/components/form-datepicker) -- [`` Time picker custom form input](/docs/comonents/form-timepicker) +- [`` Time picker custom form input](/docs/components/form-timepicker) - [`` Time date selection widget](/docs/components/calendar) diff --git a/src/components/calendar/calendar.js b/src/components/calendar/calendar.js index d9723e48a8d..8f7c1e48869 100644 --- a/src/components/calendar/calendar.js +++ b/src/components/calendar/calendar.js @@ -7,6 +7,7 @@ import { getComponentConfig } from '../../utils/config' import { createDate, createDateFormatter, + constrainDate, datesEqual, firstDateOfMonth, formatYMD, @@ -58,6 +59,13 @@ export const BCalendar = Vue.extend({ type: Boolean, default: false }, + initialDate: { + // This specifies the calendar year/month/day that will be shown when + // first opening the datepicker if no v-model value is provided + // Default is the current date (or `min`/`max`) + type: [String, Date], + default: null + }, disabled: { type: Boolean, default: false @@ -212,7 +220,9 @@ export const BCalendar = Vue.extend({ // Selected date selectedYMD: selected, // Date in calendar grid that has `tabindex` of `0` - activeYMD: selected || formatYMD(this.getToday()), + activeYMD: + selected || + formatYMD(constrainDate(this.initialDate || this.getToday()), this.min, this.max), // Will be true if the calendar grid has/contains focus gridHasFocus: false, // Flag to enable the `aria-live` region(s) after mount @@ -361,6 +371,7 @@ export const BCalendar = Vue.extend({ // Merge in user supplied options ...this.dateFormatOptions, // Ensure hours/minutes/seconds are not shown + // As we do not support the time portion (yet) hour: undefined, minute: undefined, second: undefined, @@ -487,7 +498,9 @@ export const BCalendar = Vue.extend({ }, hidden(newVal) { // Reset the active focused day when hidden - this.activeYMD = this.selectedYMD || formatYMD(this.value) || formatYMD(this.getToday()) + this.activeYMD = + this.selectedYMD || + formatYMD(this.value || this.constrainDate(this.initialDate || this.getToday())) // Enable/disable the live regions this.setLive(!newVal) } @@ -541,10 +554,7 @@ export const BCalendar = Vue.extend({ constrainDate(date) { // Constrains a date between min and max // returns a new `Date` object instance - date = parseYMD(date) - const min = this.computedMin || date - const max = this.computedMax || date - return createDate(date < min ? min : date > max ? max : date) + return constrainDate(date, this.computedMin, this.computedMax) }, emitSelected(date) { // Performed in a `$nextTick()` to (probably) ensure @@ -573,6 +583,7 @@ export const BCalendar = Vue.extend({ let activeDate = createDate(this.activeDate) let checkDate = createDate(this.activeDate) const day = activeDate.getDate() + const constrainedToday = this.constrainDate(this.getToday()) const isRTL = this.isRTL if (keyCode === PAGEUP) { // PAGEUP - Previous month/year @@ -605,11 +616,11 @@ export const BCalendar = Vue.extend({ checkDate = activeDate } else if (keyCode === HOME) { // HOME - Today - activeDate = this.getToday() + activeDate = constrainedToday checkDate = activeDate } else if (keyCode === END) { // END - Selected date, or today if no selected date - activeDate = parseYMD(this.selectedDate) || this.getToday() + activeDate = parseYMD(this.selectedDate) || constrainedToday checkDate = activeDate } if (!this.dateOutOfRange(checkDate) && !datesEqual(activeDate, this.activeDate)) { @@ -664,7 +675,7 @@ export const BCalendar = Vue.extend({ }, gotoCurrentMonth() { // TODO: Maybe this goto date should be configurable? - this.activeYMD = formatYMD(this.getToday()) + this.activeYMD = formatYMD(this.constrainDate(this.getToday())) }, gotoNextMonth() { this.activeYMD = formatYMD(this.constrainDate(oneMonthAhead(this.activeDate))) @@ -694,7 +705,7 @@ export const BCalendar = Vue.extend({ // Flag for making the `aria-live` regions live const isLive = this.isLive // Pre-compute some IDs - // Thes should be computed props + // This should be computed props const idValue = safeId() const idWidget = safeId('_calendar-wrapper_') const idNav = safeId('_calendar-nav_') diff --git a/src/components/calendar/package.json b/src/components/calendar/package.json index 6bf274e46bf..14f5d2544f3 100644 --- a/src/components/calendar/package.json +++ b/src/components/calendar/package.json @@ -20,6 +20,11 @@ "prop": "valueAsDate", "description": "Returns a `Date` object for the v-model instead of a `YYYY-MM-DD` string" }, + { + "prop": "initialDate", + "version": "2.7.0", + "description": "When a `value` is not specified, sets the initial calendar month date that will be presented to the user. Accepts a value in `YYYY-MM-DD` format or a `Date` object. Defaults to the current date (or min or max if the current date is out of range)" + }, { "prop": "disabled", "description": "Places the calendar in a non-interactive disabled state" diff --git a/src/components/form-datepicker/README.md b/src/components/form-datepicker/README.md index 322d1f77bca..6c25c05716d 100644 --- a/src/components/form-datepicker/README.md +++ b/src/components/form-datepicker/README.md @@ -288,6 +288,10 @@ The text for the optional buttons can be set via the `label-today-button`, `labe the `label-close-button` props. Due to the limited width of the footer section, it is recommended to keep these labels short. +Note that the `Set Today` button may not set the control today's date, if today's date is outside of +the `min` or `max` date range restrictions. In the case it is outside of the range, it will set to +either `min` or `max` (depending on which is closes to today's date). + ### Dropdown placement Use the dropdown props `right`, `dropup`, `dropright`, `dropleft`, `no-flip`, and `offset` to @@ -296,6 +300,14 @@ control the positioning of the popup calendar. Refer to the [`` documentation](/docs/components/dropdown) for details on the effects and usage of these props. +### Initial open calendar date + +By default, when no date is selected, the calendar view will be set to the current month (or the +`min` or `max` date if today's date is out of range of `min` or `max`) when opened. You can change +this behaviour by specifying a date via the `initial-date` prop. The initial date prop will be used +to determine the calendar month to be initially presented to the user. It does not set the +component's value. + ### Dark mode Want a fancy popup with a dark background instead of a light background? Set the `dark` prop to diff --git a/src/components/form-datepicker/form-datepicker.js b/src/components/form-datepicker/form-datepicker.js index 7fb362790a9..09c857426c5 100644 --- a/src/components/form-datepicker/form-datepicker.js +++ b/src/components/form-datepicker/form-datepicker.js @@ -1,7 +1,7 @@ import Vue from '../../utils/vue' import { BVFormBtnLabelControl, dropdownProps } from '../../utils/bv-form-btn-label-control' import { getComponentConfig } from '../../utils/config' -import { createDate, formatYMD, parseYMD } from '../../utils/date' +import { createDate, constrainDate, formatYMD, parseYMD } from '../../utils/date' import { isUndefinedOrNull } from '../../utils/inspect' import idMixin from '../../mixins/id' import { BButton } from '../button/button' @@ -31,6 +31,14 @@ const propsMixin = { type: [String, Date], default: '' }, + initialDate: { + // This specifies the calendar year/month/day that will be shown when + // first opening the datepicker if no v-model value is provided + // Default is the current date (or `min`/`max`) + // Passed directly to + type: [String, Date], + default: null + }, placeholder: { type: String, // Defaults to `labelNoDateSelected` from calendar context @@ -241,13 +249,13 @@ export const BFormDatepicker = /*#__PURE__*/ Vue.extend({ return { // We always use `YYYY-MM-DD` value internally localYMD: formatYMD(this.value) || '', + // If the popup is open + isVisible: false, // Context data from BCalendar localLocale: null, isRTL: false, formattedValue: '', - activeYMD: '', - // If the popup is open - isVisible: false + activeYMD: '' } }, computed: { @@ -265,6 +273,7 @@ export const BFormDatepicker = /*#__PURE__*/ Vue.extend({ value: self.localYMD, min: self.min, max: self.max, + initialDate: self.initialDate, readonly: self.readonly, disabled: self.disabled, locale: self.locale, @@ -293,7 +302,7 @@ export const BFormDatepicker = /*#__PURE__*/ Vue.extend({ return (this.localLocale || '').replace(/-u-.*$/i, '') || null }, computedResetValue() { - return formatYMD(this.resetValue) || '' + return formatYMD(constrainDate(this.resetValue)) || '' } }, watch: { @@ -361,7 +370,8 @@ export const BFormDatepicker = /*#__PURE__*/ Vue.extend({ this.$emit('context', ctx) }, onTodayButton() { - this.setAndClose(formatYMD(createDate())) + // Set to today (or min/max if today is out of range) + this.setAndClose(formatYMD(constrainDate(createDate(), this.min, this.max))) }, onResetButton() { this.setAndClose(this.computedResetValue) diff --git a/src/components/form-datepicker/package.json b/src/components/form-datepicker/package.json index 036cd59b1d9..073a2515dbb 100644 --- a/src/components/form-datepicker/package.json +++ b/src/components/form-datepicker/package.json @@ -28,6 +28,11 @@ "prop": "resetValue", "description": "When the optional `reset` button is clicked, the selected date will be set to this value. Default is to clear the selected value" }, + { + "prop": "initialDate", + "version": "2.7.0", + "description": "When a `value` is not specified, sets the initial calendar month date that will be presented to the user. Accepts a value in `YYYY-MM-DD` format or a `Date` object. Defaults to the current date (or min or max if the current date is out of range)" + }, { "prop": "disabled", "description": "Places the calendar in a non-interactive disabled state" diff --git a/src/components/form-timepicker/package.json b/src/components/form-timepicker/package.json index 08ccf8316d4..92342824416 100644 --- a/src/components/form-timepicker/package.json +++ b/src/components/form-timepicker/package.json @@ -36,7 +36,7 @@ }, { "prop": "showSeconds", - "description": "When true, shows the seconds spinbutton. If `false` the seconds spin button will not be shown and the seconds portion of hte time will always be `0`" + "description": "When true, shows the seconds spinbutton. If `false` the seconds spin button will not be shown and the seconds portion of the time will always be `0`" }, { "prop": "hour12", diff --git a/src/components/time/package.json b/src/components/time/package.json index 7ba9b4a2f24..fbbbfad3950 100644 --- a/src/components/time/package.json +++ b/src/components/time/package.json @@ -17,7 +17,7 @@ }, { "prop": "showSeconds", - "description": "When true, shows the seconds spinbutton. If `false` the seconds spin button will not be shown and the seconds portion of hte time will always be `0`" + "description": "When true, shows the seconds spinbutton. If `false` the seconds spin button will not be shown and the seconds portion of the time will always be `0`" }, { "prop": "hour12", diff --git a/src/utils/date.js b/src/utils/date.js index e6bac5624a0..64774d66e15 100644 --- a/src/utils/date.js +++ b/src/utils/date.js @@ -110,3 +110,14 @@ export const oneYearAhead = date => { } return date } + +// Helper function to constrain a date between two values +// Always returns a `Date` object or `null` if no date passed +export const constrainDate = (date, min = null, max = null) => { + // Ensure values are `Date` objects (or `null`) + date = parseYMD(date) + min = parseYMD(min) || date + max = parseYMD(max) || date + // Return a new `Date` object (or `null`) + return date ? (date < min ? min : date > max ? max : date) : null +} diff --git a/src/utils/date.spec.js b/src/utils/date.spec.js index 31db86c8339..be014e1c406 100644 --- a/src/utils/date.spec.js +++ b/src/utils/date.spec.js @@ -7,7 +7,8 @@ import { oneMonthAgo, oneMonthAhead, oneYearAgo, - oneYearAhead + oneYearAhead, + constrainDate } from './date' describe('utils/date', () => { @@ -94,4 +95,35 @@ describe('utils/date', () => { expect(formatYMD(oneYearAhead(parseYMD('2020-11-30')))).toEqual('2021-11-30') expect(formatYMD(oneYearAhead(parseYMD('2020-12-31')))).toEqual('2021-12-31') }) + + it('costrainDate works', async () => { + const min = parseYMD('2020-01-05') + const max = parseYMD('2020-01-15') + const date1 = parseYMD('2020-01-10') + const date2 = parseYMD('2020-01-01') + const date3 = parseYMD('2020-01-20') + + expect(constrainDate(null, null, null)).toEqual(null) + expect(constrainDate(null, min, max)).toEqual(null) + + expect(constrainDate(date1, null, null)).not.toEqual(null) + expect(constrainDate(date1, null, null).toISOString()).toEqual(date1.toISOString()) + + expect(constrainDate(date1, min, max)).not.toEqual(null) + expect(constrainDate(date1, min, max).toISOString()).toEqual(date1.toISOString()) + + expect(constrainDate(date2, min, max)).not.toEqual(null) + expect(constrainDate(date2, min, max).toISOString()).toEqual(min.toISOString()) + expect(constrainDate(date2, '', max)).not.toEqual(null) + expect(constrainDate(date2, '', max).toISOString()).toEqual(date2.toISOString()) + expect(constrainDate(date2, null, max)).not.toEqual(null) + expect(constrainDate(date2, null, max).toISOString()).toEqual(date2.toISOString()) + + expect(constrainDate(date3, min, max)).not.toEqual(null) + expect(constrainDate(date3, min, max).toISOString()).toEqual(max.toISOString()) + expect(constrainDate(date3, min, '')).not.toEqual(null) + expect(constrainDate(date3, min, '').toISOString()).toEqual(date3.toISOString()) + expect(constrainDate(date3, min, null)).not.toEqual(null) + expect(constrainDate(date3, min, null).toISOString()).toEqual(date3.toISOString()) + }) })