From 8af42b14345f66753d122d739def9b1d9c259c35 Mon Sep 17 00:00:00 2001 From: mrholek Date: Mon, 19 Jan 2026 23:10:47 +0100 Subject: [PATCH 1/3] feat(Calendar, DatePicker, DateRangePicker): allow selection of quarter --- docs/content/components/calendar.md | 18 ++- docs/content/forms/date-picker.md | 26 +++- docs/content/forms/date-range-picker.md | 27 +++- js/src/calendar.js | 71 ++++++++++- js/src/util/calendar.js | 158 ++++++++++++++++++++++-- 5 files changed, 284 insertions(+), 16 deletions(-) diff --git a/docs/content/components/calendar.md b/docs/content/components/calendar.md index 26c4a4f7e..27385e6a5 100644 --- a/docs/content/components/calendar.md +++ b/docs/content/components/calendar.md @@ -61,6 +61,22 @@ Set the `data-coreui-selection-type` to `month` to enable selection of entire mo {{< /example >}} +### Quarters + +Set the `data-coreui-selection-type` property to `quarter` to enable quarters range selection. + +{{< example stackblitz_pro="true" >}} +
+
+
+{{< /example >}} + ### Years Set the `data-coreui-selection-type` to `year` to enable years range selection. @@ -281,7 +297,7 @@ const calendarList = calendarElementList.map(calendarEl => { | `minDate` | date, number, string, null | `null` | Min selectable date. | | `range` | boolean | `false` | Allow range selection | | `selectAdjacementDays` | boolean | `false` | Set whether days in adjacent months shown before or after the current month are selectable. This only applies if the `showAdjacementDays` option is set to true. | -| `selectionType` | `'day'`, `'week'`, `'month'`, `'year'` | `day` | Specify the type of date selection as day, week, month, or year. | +| `selectionType` | `'day'`, `'week'`, `'month'`, `'quarter'`, `'year'` | `day` | Specify the type of date selection as day, week, month, quarter, or year. | | `showAdjacementDays` | boolean | `true` | Set whether to display dates in adjacent months (non-selectable) at the start and end of the current month. | | `showWeekNumber` | boolean | `false` | Set whether to display week numbers in the calendar. | | `startDate` | date, number, string, null | `null` | Initial selected date. | diff --git a/docs/content/forms/date-picker.md b/docs/content/forms/date-picker.md index a5d69ca13..0d4a2cf61 100644 --- a/docs/content/forms/date-picker.md +++ b/docs/content/forms/date-picker.md @@ -109,6 +109,30 @@ Selecting whole month by adding the `data-coreui-selection-type="month"` attribu {{< /example >}} +### Quarters + +Selecting quarter by adding the `data-coreui-selection-type="quarter"` attribute. + +{{< example stackblitz_pro="true" >}} +
+
+
+
+
+
+
+
+
+
+{{< /example >}} + ### Years Add the `data-coreui-selection-type="year"` attribute to allow pick years. @@ -403,7 +427,7 @@ const datePickerList = datePickerElementList.map(datePickerEl => { | `placeholder` | string | `'Select time'` | Specifies a short hint that is visible in the input. | | `previewDateOnHover` | boolean | `true` | Enable live preview of dates in input field when hovering over calendar cells. | | `selectAdjacementDays` | boolean | `false` | Set whether days in adjacent months shown before or after the current month are selectable. This only applies if the `showAdjacementDays` option is set to true. | -| `selectionType` | `'day'`, `'week'`, `'month'`, `'year'` | `day` | Specify the type of date selection as day, week, month, or year. | +| `selectionType` | `'day'`, `'week'`, `'month'`, `'quarter'`, `'year'` | `day` | Specify the type of date selection as day, week, month, quarter, or year. | | `showAdjacementDays` | boolean | `true` | Set whether to display dates in adjacent months (non-selectable) at the start and end of the current month. | | `showWeekNumber` | boolean | `false` | Set whether to display week numbers in the calendar. | | `size` | `'sm'`, `'lg'` | `null` | Size the component small or large. | diff --git a/docs/content/forms/date-range-picker.md b/docs/content/forms/date-range-picker.md index 3f67c1356..0e0e28f26 100644 --- a/docs/content/forms/date-range-picker.md +++ b/docs/content/forms/date-range-picker.md @@ -137,6 +137,31 @@ Select range of months by adding the `data-coreui-selection-type="month"` attrib {{< /example >}} +### Quarters + +Select range of quartes by adding the `data-coreui-selection-type="quarter"` attribute. + +{{< example stackblitz_pro="true" >}} +
+
+
+
+
+
+
+
+
+
+{{< /example >}} + ### Years Add the `data-coreui-selection-type="year"` attribute to allow a pick range of years. @@ -457,7 +482,7 @@ const dateRangePickerList = dateRangePickerElementList.map(dateRangePickerEl => | `ranges` | object | `{}` | Predefined date ranges the user can select from. | | `rangesButtonsClasses` | array, string | `['btn', 'btn-ghost-secondary']` | CSS class names that will be added to ranges buttons | | `selectAdjacementDays` | boolean | `false` | Set whether days in adjacent months shown before or after the current month are selectable. This only applies if the `showAdjacementDays` option is set to true. | -| `selectionType` | `'day'`, `'week'`, `'month'`, `'year'` | `day` | Specify the type of date selection as day, week, month, or year. | +| `selectionType` | `'day'`, `'week'`, `'month'`, `'quarter'`, `'year'` | `day` | Specify the type of date selection as day, week, month, quarter, or year. | | `separator` | boolean | `true` | Toggle visibility or set the content of the inputs separator. | | `showAdjacementDays` | boolean | `true` | Set whether to display dates in adjacent months (non-selectable) at the start and end of the current month. | | `showWeekNumber` | boolean | `false` | Set whether to display week numbers in the calendar. | diff --git a/js/src/calendar.js b/js/src/calendar.js index 3f34b5a9d..da89c6bbc 100644 --- a/js/src/calendar.js +++ b/js/src/calendar.js @@ -26,6 +26,9 @@ import { isMonthDisabled, isMonthInRange, isMonthSelected, + isQuarterDisabled, + isQuarterInRange, + isQuarterSelected, isToday, isYearDisabled, isYearInRange, @@ -206,7 +209,7 @@ class Calendar extends BaseComponent { if (this._view === 'years' && this._config.selectionType !== 'year') { this._setCalendarDate(index ? new Date(cloneDate.setFullYear(cloneDate.getFullYear() - index)) : date) - this._view = 'months' + this._view = this._config.selectionType === 'quarter' ? 'quarters' : 'months' this._updateCalendar(this._focusOnFirstAvailableCell.bind(this)) return } @@ -310,7 +313,7 @@ class Calendar extends BaseComponent { this._modifyCalendarDate(0, event.key === ARROW_RIGHT_KEY || event.key === ARROW_DOWN_KEY ? 1 : -1, callback.bind(this, event.key)) } - if (this._view === 'months') { + if (this._view === 'months' || this._view === 'quarters') { this._modifyCalendarDate(event.key === ARROW_RIGHT_KEY || event.key === ARROW_DOWN_KEY ? 1 : -1, 0, callback.bind(this, event.key)) } @@ -670,6 +673,25 @@ class Calendar extends BaseComponent { }).join('')} ` )).join('') : ''} + ${this._view === 'quarters' ? + ` + ${Array.from({ length: 4 }, (_, index) => { + const date = new Date(calendarDate.getFullYear(), index * 3, 1) + const cellAttributes = this._cellQuarterAttributes(date) + return ( + ` +
+ ${`Q${index + 1}`} +
+ ` + ) + }).join('')} + ` : ''} ${this._view === 'years' ? listOfYears.map(row => ( ` ${row.map(year => { @@ -731,6 +753,7 @@ class Calendar extends BaseComponent { day: 'days', week: 'days', month: 'months', + quarter: 'quarters', year: 'years' } @@ -774,13 +797,26 @@ class Calendar extends BaseComponent { const date = new Date(Manipulator.getDataAttribute(cell, 'date')) let cellAttributes - if (this._view === 'days') { + switch (this._view) { + case 'days': { cellAttributes = this._cellDayAttributes(date, 'current') - } else if (this._view === 'months') { + break + } + + case 'months': { cellAttributes = this._cellMonthAttributes(date) - } else { + break + } + + case 'quarters': { + cellAttributes = this._cellQuarterAttributes(date) + break + } + + default: { cellAttributes = this._cellYearAttributes(date) } + } cell.className = cellAttributes.className cell.tabIndex = cellAttributes.tabIndex @@ -869,6 +905,31 @@ class Calendar extends BaseComponent { } } + _cellQuarterAttributes(date) { + const isDisabled = isQuarterDisabled(date, this._minDate, this._maxDate, this._config.disabledDates) + const isSelected = isQuarterSelected(date, this._startDate, this._endDate) + const isInRange = isQuarterInRange(date, this._startDate, this._endDate) + const isRangeHover = this._config.selectionType === 'quarter' && this._hoverDate && ( + this._selectEndDate ? + isQuarterInRange(date, this._startDate, this._hoverDate) : + isQuarterInRange(date, this._hoverDate, this._endDate) + ) + + const classNames = this._classNames({ + [CLASS_NAME_CALENDAR_CELL]: true, + disabled: isDisabled, + 'range-hover': isRangeHover, + range: isInRange, + selected: isSelected + }) + + return { + className: classNames, + tabIndex: isDisabled ? -1 : 0, + ariaSelected: isSelected + } + } + _cellYearAttributes(date) { const isDisabled = isYearDisabled(date, this._minDate, this._maxDate, this._config.disabledDates) const isSelected = isYearSelected(date, this._startDate, this._endDate) diff --git a/js/src/util/calendar.js b/js/src/util/calendar.js index dd8a9e831..f4a1049da 100644 --- a/js/src/util/calendar.js +++ b/js/src/util/calendar.js @@ -41,6 +41,16 @@ const dateToMonthNumber = date => { return (date.getFullYear() * 12) + date.getMonth() } +/** + * Helper function to convert a date to a quarter number for comparison. + * @param date - The date to convert. + * @returns A number representing year*4 + quarter for easy comparison. + */ +const dateToQuarterNumber = date => { + const quarter = Math.floor(date.getMonth() / 3) + return (date.getFullYear() * 4) + quarter +} + /** * Helper function to check if a value is within min/max range. * @param value - The value to check. @@ -101,6 +111,36 @@ const parseWeekString = dateString => { return convertIsoWeekToDate(dateString) } +/** + * Parses a quarter string and returns a Date object for the first day of that quarter. + * @param dateString - The quarter string to parse. + * @returns The Date object for the first day of the quarter, or null if invalid. + */ +const parseQuarterString = dateString => { + const quarterPatterns = [ + /^(\d{4})-Q(\d{1})$/, // 2023-Q1, 2023-Q4 + /^(\d{4})Q(\d{1})$/, // 2023Q1, 2023Q4 + /^(\d{4})\s+Q(\d{1})$/ // 2023 Q1, 2023 Q4 + ] + + for (const pattern of quarterPatterns) { + const match = dateString.trim().match(pattern) + if (match) { + const parsedYear = parseYearSmart(match[1]) + const parsedQuarter = Number.parseInt(match[2], 10) + + // Validate quarter (1-4) + if (parsedQuarter >= 1 && parsedQuarter <= 4) { + // Calculate the first month of the quarter (Q1=0, Q2=3, Q3=6, Q4=9) + const monthIndex = (parsedQuarter - 1) * 3 + return new Date(parsedYear, monthIndex, 1) + } + } + } + + return null +} + /** * Parses a month string and returns a Date object for the first day of that month. * @param dateString - The month string to parse. @@ -401,7 +441,7 @@ const createDateOnly = groups => { const getExpectedPartsCount = patterns => { if (patterns.length === 0) { return 3 - } // Default fallback + } // Analyze the first pattern to determine expected parts count const firstPattern = patterns[0] @@ -422,8 +462,6 @@ const parseDayString = (dateString, locale, includeTime) => { if (!groups) { // Check if input looks like a complete date (has separators and multiple parts) - // If so, use fallback parsing for formats like "2022/08/17", "2022-08-17" - // If not (like "1", "12", "1/1"), return null const trimmed = dateString.trim() const hasDateSeparators = /[-/.:]/.test(trimmed) const parts = trimmed.split(/[-/.\s:]+/).filter(part => part.length > 0) @@ -441,8 +479,8 @@ const parseDayString = (dateString, locale, includeTime) => { // For day selection, require at least year, month, and day to be present if ("year" in groups && "month" in groups && "day" in groups) { - const { month, day, year } = groups - if (!validateDateComponents(month, day, year)) { + const { month, day } = groups + if (!validateDateComponents(month, day)) { return null } } else { @@ -501,6 +539,10 @@ export const convertToDateObject = ( return parseMonthString(dateString) } + case "quarter": { + return parseQuarterString(dateString) + } + case "year": { return parseYearString(dateString) } @@ -517,7 +559,7 @@ export const convertToDateObject = ( * @param dateString - The date string to parse. * @param locale - The locale to use for date format patterns. * @param includeTime - Whether to include time parsing. - * @param selectionType - The selection type ('day', 'week', 'month', 'year'). + * @param selectionType - The selection type ('day', 'week', 'month', 'quarter', 'year'). * @returns A Date object if parsing succeeds, null if parsing fails. */ export const getLocalDateFromString = ( @@ -563,7 +605,7 @@ export const getCalendarDate = (calendarDate, order, view) => { ) } - if (order !== 0 && view === "months") { + if (order !== 0 && (view === "months" || view === "quarters")) { return new Date( calendarDate.getFullYear() + order, calendarDate.getMonth(), @@ -582,7 +624,7 @@ export const getCalendarDate = (calendarDate, order, view) => { /** * Formats a date based on the selection type. * @param date - The date to format. - * @param selectionType - The type of selection ('day', 'week', 'month', 'year'). + * @param selectionType - The type of selection ('day', 'week', 'month', 'quarter', 'year'). * @returns A formatted date string or the original Date object. */ export const getDateBySelectionType = (date, selectionType) => { @@ -600,6 +642,11 @@ export const getDateBySelectionType = (date, selectionType) => { return `${date.getFullYear()}-${monthNumber}` } + if (selectionType === "quarter") { + const quarter = Math.floor(date.getMonth() / 3) + 1 + return `${date.getFullYear()}Q${quarter}` + } + if (selectionType === "year") { return `${date.getFullYear()}` } @@ -1018,6 +1065,101 @@ export const isMonthInRange = (date, start, end) => { return Boolean(_start && _end && _start <= _date && _date <= _end) } +/** + * Checks if a quarter is disabled based on the 'quarter' period type. + * @param date - The date representing the quarter to check. + * @param min - Minimum allowed date. + * @param max - Maximum allowed date. + * @param disabledDates - Criteria for disabled dates. + * @returns True if the quarter is disabled, false otherwise. + */ +export const isQuarterDisabled = (date, min, max, disabledDates) => { + const current = dateToQuarterNumber(date) + const _min = min ? dateToQuarterNumber(min) : null + const _max = max ? dateToQuarterNumber(max) : null + + if (isOutsideRange(current, _min, _max)) { + return true + } + + if (disabledDates === undefined) { + return false + } + + // Get the start and end of the quarter + const quarter = Math.floor(date.getMonth() / 3) + const quarterStartMonth = quarter * 3 + const quarterEndMonth = quarterStartMonth + 2 + const year = date.getFullYear() + + const quarterStart = new Date(year, quarterStartMonth, 1) + const quarterEnd = new Date(year, quarterEndMonth + 1, 0) // Last day of the quarter + + const startTime = min ? + Math.max(quarterStart.getTime(), min.getTime()) : + quarterStart.getTime() + const endTime = max ? + Math.min(quarterEnd.getTime(), max.getTime()) : + quarterEnd.getTime() + + for ( + const currentDate = new Date(startTime); + currentDate.getTime() <= endTime; + currentDate.setDate(currentDate.getDate() + 1) + ) { + if (!isDateDisabled(currentDate, min, max, disabledDates)) { + return false + } + } + + return false +} + +/** + * Checks if a quarter is selected based on start and end dates. + * @param date - The date representing the quarter. + * @param start - Start date. + * @param end - End date. + * @returns True if the quarter is selected, false otherwise. + */ +export const isQuarterSelected = (date, start, end) => { + const year = date.getFullYear() + const quarter = Math.floor(date.getMonth() / 3) + + if (start !== null) { + const startYear = start.getFullYear() + const startQuarter = Math.floor(start.getMonth() / 3) + if (year === startYear && quarter === startQuarter) { + return true + } + } + + if (end !== null) { + const endYear = end.getFullYear() + const endQuarter = Math.floor(end.getMonth() / 3) + if (year === endYear && quarter === endQuarter) { + return true + } + } + + return false +} + +/** + * Checks if a quarter is within a specified range. + * @param date - The date representing the quarter. + * @param start - Start date. + * @param end - End date. + * @returns True if the quarter is within the range, false otherwise. + */ +export const isQuarterInRange = (date, start, end) => { + const _start = start ? dateToQuarterNumber(start) : null + const _end = end ? dateToQuarterNumber(end) : null + const _date = dateToQuarterNumber(date) + + return Boolean(_start && _end && _start <= _date && _date <= _end) +} + /** * Checks if two dates are the same calendar date. * @param date - First date. From 2f3f6e5d9046fa9d2f04a33b08d8bc708b0ee8c3 Mon Sep 17 00:00:00 2001 From: mrholek Date: Tue, 20 Jan 2026 11:25:25 +0100 Subject: [PATCH 2/3] feat(Calendar, DatePicker, DateRangePicker): allow setting the formats for days, months, and years displayed in the calendar cells --- docs/content/components/calendar.md | 5 ++++- docs/content/forms/date-picker.md | 5 ++++- docs/content/forms/date-range-picker.md | 5 ++++- js/src/calendar.js | 16 +++++++++++----- js/src/date-range-picker.js | 15 ++++++++++++--- 5 files changed, 35 insertions(+), 11 deletions(-) diff --git a/docs/content/components/calendar.md b/docs/content/components/calendar.md index 27385e6a5..05af9151c 100644 --- a/docs/content/components/calendar.md +++ b/docs/content/components/calendar.md @@ -289,20 +289,23 @@ const calendarList = calendarElementList.map(calendarEl => { | `ariaNavPrevYearLabel` | string | `'Previous year'` | A string that provides an accessible label for the button that navigates to the previous year in the calendar. This label helps screen reader users understand the button's function. | | `calendarDate` | date, number, string, null | `null` | Default date of the component. | | `calendars` | number | `2` | The number of calendars that render on desktop devices. | +| `dayFormat` | `'numeric'`, `'2-digit'` | `'numeric'` | Sets the format for days. Accepts a built-in format (`'numeric'` or `'2-digit'`) | | `disabledDates` | array, function, null | `null` | Specify the list of dates that cannot be selected. | | `endDate` | date, number, string, null | `null` | Initial selected to date (range). | | `firstDayOfWeek` | number | `1` |

Sets the day of start week.

| | `locale` | string | `'default'` | Sets the default locale for components. If not set, it is inherited from the navigator.language. | | `maxDate` | date, number, string, null | `null` | Max selectable date. | | `minDate` | date, number, string, null | `null` | Min selectable date. | +| `monthFormat` | `'long'`, `'narrow'`, `'short'`, `'numeric'`, `'2-digit'` | `'short'` | Sets the format for month names. Accepts built-in formats (`'long'`, `'narrow'`, `'short'`, `'numeric'`, `'2-digit'`). | | `range` | boolean | `false` | Allow range selection | | `selectAdjacementDays` | boolean | `false` | Set whether days in adjacent months shown before or after the current month are selectable. This only applies if the `showAdjacementDays` option is set to true. | | `selectionType` | `'day'`, `'week'`, `'month'`, `'quarter'`, `'year'` | `day` | Specify the type of date selection as day, week, month, quarter, or year. | | `showAdjacementDays` | boolean | `true` | Set whether to display dates in adjacent months (non-selectable) at the start and end of the current month. | | `showWeekNumber` | boolean | `false` | Set whether to display week numbers in the calendar. | | `startDate` | date, number, string, null | `null` | Initial selected date. | -| `weekdayFormat` | number, 'long', 'narrow', 'short' | `2` | Set length or format of day name. | +| `weekdayFormat` | number, `'long'`, `'narrow'`, `'short'` | `2` | Set length or format of day name. | | `weekNumbersLabel` | string | `null` | Label displayed over week numbers in the calendar. | +| `yearFormat` | `'numeric'`, `'2-digit'` | `'numeric'` | Sets the format for years. Accepts built-in formats (`'numeric'` or `'2-digit'`) | {{< /bs-table >}} ### Methods diff --git a/docs/content/forms/date-picker.md b/docs/content/forms/date-picker.md index 0d4a2cf61..681186696 100644 --- a/docs/content/forms/date-picker.md +++ b/docs/content/forms/date-picker.md @@ -410,6 +410,7 @@ const datePickerList = datePickerElementList.map(datePickerEl => { | `confirmButtonClasses` | array, string | `['btn', 'btn-sm', 'btn-primary']` | CSS class names that will be added to the confirm button | | `container` | string, element, false | `false` | Appends the dropdown to a specific element. Example: `container: 'body'`. | | `date` | date, number, string, null | `null` | Default value of the component | +| `dayFormat` | `'numeric'`, `'2-digit'` | `'numeric'` | Sets the format for days. Accepts a built-in format (`'numeric'` or `'2-digit'`) | | `disabled` | boolean | `false` | Toggle the disabled state for the component. | | `disabledDates` | array, function, null | `null` | Specify the list of dates that cannot be selected. | | `firstDayOfWeek` | number | `1` |

Sets the day of start week.

| @@ -423,6 +424,7 @@ const datePickerList = datePickerElementList.map(datePickerEl => { | `locale` | string | `'default'` | Sets the default locale for components. If not set, it is inherited from the navigator.language. | | `maxDate` | date, number, string, null | `null` | Max selectable date. | | `minDate` | date, number, string, null | `null` | Min selectable date. | +| `monthFormat` | `'long'`, `'narrow'`, `'short'`, `'numeric'`, `'2-digit'` | `'short'` | Sets the format for month names. Accepts built-in formats (`'long'`, `'narrow'`, `'short'`, `'numeric'`, `'2-digit'`). | | `name` | string, null | `null` | Set the name attribute for the input element. | | `placeholder` | string | `'Select time'` | Specifies a short hint that is visible in the input. | | `previewDateOnHover` | boolean | `true` | Enable live preview of dates in input field when hovering over calendar cells. | @@ -435,8 +437,9 @@ const datePickerList = datePickerElementList.map(datePickerEl => { | `todayButton` | string | `'Today'` | Today button inner HTML | | `todayButtonClasses` | array, string | `['btn', 'btn-sm', 'me-2']` | CSS class names that will be added to the today button | | `valid` | boolean | `false` | Toggle the valid state for the component. | -| `weekdayFormat` | number, 'long', 'narrow', 'short' | `2` | Set length or format of day name. | +| `weekdayFormat` | number, `'long'`, `'narrow'`, `'short'` | `2` | Set length or format of day name. | | `weekNumbersLabel` | string | `null` | Label displayed over week numbers in the calendar. | +| `yearFormat` | `'numeric'`, `'2-digit'` | `'numeric'` | Sets the format for years. Accepts built-in formats (`'numeric'` or `'2-digit'`) | {{< /bs-table >}} ### Methods diff --git a/docs/content/forms/date-range-picker.md b/docs/content/forms/date-range-picker.md index 0e0e28f26..2101cf293 100644 --- a/docs/content/forms/date-range-picker.md +++ b/docs/content/forms/date-range-picker.md @@ -462,6 +462,7 @@ const dateRangePickerList = dateRangePickerElementList.map(dateRangePickerEl => | `confirmButton` | boolean, string | `'OK'` | Confirm button inner HTML | | `confirmButtonClasses` | array, string | `['btn', 'btn-sm', 'btn-primary']` | CSS class names that will be added to the confirm button | | `container` | string, element, false | `false` | Appends the dropdown to a specific element. Example: `container: 'body'`. | +| `dayFormat` | `'numeric'`, `'2-digit'` | `'numeric'` | Sets the format for days. Accepts a built-in format (`'numeric'` or `'2-digit'`) | | `disabled` | boolean | `false` | Toggle the disabled state for the component. | | `disabledDates` | array, function, null | `null` | Specify the list of dates that cannot be selected. | | `endDate` | date, number, string, null | `null` | Initial selected to date (range). | @@ -477,6 +478,7 @@ const dateRangePickerList = dateRangePickerElementList.map(dateRangePickerEl => | `locale` | string | `'default'` | Sets the default locale for components. If not set, it is inherited from the navigator.language. | | `maxDate` | date, number, string, null | `null` | Max selectable date. | | `minDate` | date, number, string, null | `null` | Min selectable date. | +| `monthFormat` | `'long'`, `'narrow'`, `'short'`, `'numeric'`, `'2-digit'` | `'short'` | Sets the format for month names. Accepts built-in formats (`'long'`, `'narrow'`, `'short'`, `'numeric'`, `'2-digit'`). | | `placeholder` | string | `['Start date', 'End date']` | Specifies a short hint that is visible in the input. | | `previewDateOnHover` | boolean | `true` | Enable live preview of dates in input fields when hovering over calendar cells. | | `ranges` | object | `{}` | Predefined date ranges the user can select from. | @@ -493,8 +495,9 @@ const dateRangePickerList = dateRangePickerElementList.map(dateRangePickerEl => | `todayButton` | string | `'Today'` | Today button inner HTML | | `todayButtonClasses` | array, string | `['btn', 'btn-sm', 'me-2']` | CSS class names that will be added to the today button | | `valid` | boolean | `false` | Toggle the valid state for the component. | -| `weekdayFormat` | number, 'long', 'narrow', 'short' | `2` | Set length or format of day name. | +| `weekdayFormat` | number, `'long'`, `'narrow'`, `'short'` | `2` | Set length or format of day name. | | `weekNumbersLabel` | string | `null` | Label displayed over week numbers in the calendar. | +| `yearFormat` | `'numeric'`, `'2-digit'` | `'numeric'` | Sets the format for years. Accepts built-in formats (`'numeric'` or `'2-digit'`) | {{< /bs-table >}} ### Methods diff --git a/js/src/calendar.js b/js/src/calendar.js index da89c6bbc..c2170ebc9 100644 --- a/js/src/calendar.js +++ b/js/src/calendar.js @@ -92,12 +92,14 @@ const Default = { ariaNavPrevYearLabel: 'Previous year', calendarDate: null, calendars: 1, + dayFormat: 'numeric', disabledDates: null, endDate: null, firstDayOfWeek: 1, locale: 'default', maxDate: null, minDate: null, + monthFormat: 'short', range: false, selectAdjacementDays: false, selectEndDate: false, @@ -106,7 +108,8 @@ const Default = { showWeekNumber: false, startDate: null, weekdayFormat: 2, - weekNumbersLabel: null + weekNumbersLabel: null, + yearFormat: 'numeric' } const DefaultType = { @@ -116,12 +119,14 @@ const DefaultType = { ariaNavPrevYearLabel: 'string', calendarDate: '(date|number|string|null)', calendars: 'number', + dayFormat: 'string', disabledDates: '(array|date|function|null)', endDate: '(date|number|string|null)', firstDayOfWeek: 'number', locale: 'string', maxDate: '(date|number|string|null)', minDate: '(date|number|string|null)', + monthFormat: 'string', range: 'boolean', selectAdjacementDays: 'boolean', selectEndDate: 'boolean', @@ -130,7 +135,8 @@ const DefaultType = { showWeekNumber: 'boolean', startDate: '(date|number|string|null)', weekdayFormat: '(number|string)', - weekNumbersLabel: '(string|null)' + weekNumbersLabel: '(string|null)', + yearFormat: 'string' } /** @@ -593,7 +599,7 @@ class Calendar extends BaseComponent { ` const monthDetails = getMonthDetails(year, month, this._config.firstDayOfWeek) - const listOfMonths = createGroupsInArray(getMonthsNames(this._config.locale), 4) + const listOfMonths = createGroupsInArray(getMonthsNames(this._config.locale, this._config.monthFormat), 4) const listOfYears = createGroupsInArray(getYears(calendarDate.getFullYear()), 4) const weekDays = monthDetails[0].days @@ -645,7 +651,7 @@ class Calendar extends BaseComponent { data-coreui-date="${date}" >
- ${date.toLocaleDateString(this._config.locale, { day: 'numeric' })} + ${date.toLocaleDateString(this._config.locale, { day: this._config.dayFormat })}
` : '' @@ -705,7 +711,7 @@ class Calendar extends BaseComponent { data-coreui-date="${date.toDateString()}" >
- ${year} + ${date.toLocaleDateString(this._config.locale, { year: this._config.yearFormat })}
` ) diff --git a/js/src/date-range-picker.js b/js/src/date-range-picker.js index 8b6f270a0..6c291dae0 100644 --- a/js/src/date-range-picker.js +++ b/js/src/date-range-picker.js @@ -90,6 +90,7 @@ const Default = { cleaner: true, container: false, date: null, + dayFormat: 'numeric', disabled: false, disabledDates: null, endDate: null, @@ -105,6 +106,7 @@ const Default = { locale: 'default', maxDate: null, minDate: null, + monthFormat: 'short', name: null, placeholder: ['Start date', 'End date'], previewDateOnHover: true, @@ -126,7 +128,8 @@ const Default = { todayButtonClasses: ['btn', 'btn-sm', 'btn-primary', 'me-auto'], valid: false, weekdayFormat: 2, - weekNumbersLabel: null + weekNumbersLabel: null, + yearFormat: 'numeric' } const DefaultType = { @@ -143,6 +146,7 @@ const DefaultType = { confirmButtonClasses: '(array|string)', container: '(string|element|boolean)', date: '(date|number|string|null)', + dayFormat: 'string', disabledDates: '(array|date|function|null)', disabled: 'boolean', endDate: '(date|number|string|null)', @@ -158,6 +162,7 @@ const DefaultType = { locale: 'string', maxDate: '(date|number|string|null)', minDate: '(date|number|string|null)', + monthFormat: 'string', name: '(string|null)', placeholder: '(array|string)', previewDateOnHover: 'boolean', @@ -179,7 +184,8 @@ const DefaultType = { todayButtonClasses: '(array|string)', valid: 'boolean', weekdayFormat: '(number|string)', - weekNumbersLabel: '(string|null)' + weekNumbersLabel: '(string|null)', + yearFormat: 'string' } /** @@ -556,12 +562,14 @@ class DateRangePicker extends BaseComponent { ariaNavPrevYearLabel: this._config.ariaNavPrevYearLabel, calendarDate: this._calendarDate, calendars: this._mobile ? 1 : this._config.calendars, + dayFormat: this._config.dayFormat, disabledDates: this._config.disabledDates, endDate: this._endDate, firstDayOfWeek: this._config.firstDayOfWeek, locale: this._config.locale, maxDate: this._config.maxDate, minDate: this._config.minDate, + monthFormat: this._config.monthFormat, range: this._config.range, selectAdjacementDays: this._config.selectAdjacementDays, selectEndDate: this._selectEndDate, @@ -570,7 +578,8 @@ class DateRangePicker extends BaseComponent { showWeekNumber: this._config.showWeekNumber, startDate: this._startDate, weekdayFormat: this._config.weekdayFormat, - weekNumbersLabel: this._config.weekNumbersLabel + weekNumbersLabel: this._config.weekNumbersLabel, + yearFormat: this._config.yearFormat } } From af6899c1ebe41832dff421d94d20c8e3b8e8f941 Mon Sep 17 00:00:00 2001 From: mrholek Date: Thu, 22 Jan 2026 11:41:13 +0100 Subject: [PATCH 3/3] feat(Calendar, DatePicker, DateRangePicker): allow to render custom calendar cells --- docs/assets/js/partials/snippets.js | 161 ++++++++++++++++++++++++ docs/content/components/calendar.md | 88 +++++++++++-- docs/content/forms/date-picker.md | 11 ++ docs/content/forms/date-range-picker.md | 11 ++ js/src/calendar.js | 136 ++++++++++++++++---- js/src/date-range-picker.js | 43 +++++++ scss/_calendar.scss | 12 +- scss/_variables.scss | 9 +- 8 files changed, 429 insertions(+), 42 deletions(-) diff --git a/docs/assets/js/partials/snippets.js b/docs/assets/js/partials/snippets.js index 2a2ce2c2b..0c91bda28 100644 --- a/docs/assets/js/partials/snippets.js +++ b/docs/assets/js/partials/snippets.js @@ -657,6 +657,167 @@ export default () => { } // js-docs-end calendar-disabled-dates3 + // js-docs-start calendar-customize-cells + const myCalendarCustomizeCells = document.getElementById('myCalendarCustomizeCells') + if (myCalendarCustomizeCells) { + // Pricing data caches + const pricingData = {} // For day prices + const monthRangeData = {} // For month min/max ranges + const yearRangeData = {} // For year min/max ranges + + // Fetch pricing data for a specific date range and view + const fetchPricingData = async (startDate, endDate, view = 'days', limit = 400) => { + const startDateStr = startDate.toISOString().split('T')[0] + const endDateStr = endDate.toISOString().split('T')[0] + + try { + const response = await fetch(`https://apitest.coreui.io/demos/daily-rates.php?start_date=${startDateStr}&end_date=${endDateStr}&view=${view}&limit=${limit}`) + const data = await response.json() + + // API now returns object with date keys, merge directly into appropriate cache + switch (view) { + case 'days': { + Object.assign(pricingData, data) + break + } + + case 'months': { + Object.assign(monthRangeData, data) + break + } + + case 'years': { + Object.assign(yearRangeData, data) + break + } + + default: { + break + } + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error fetching pricing data:', error) + } + } + + // Get min/max prices for a specific month from cached API data + const getMonthPriceRange = (year, month) => { + // Format: YYYY-MM-01 + const monthKey = `${year}-${String(month + 1).padStart(2, '0')}-01` + return monthRangeData[monthKey] || null + } + + // Get min/max prices for a specific year from cached API data + const getYearPriceRange = year => { + // Format: YYYY-01-01 + const yearKey = `${year}-01-01` + return yearRangeData[yearKey] || null + } + + // Helper to get date range based on view + const getDateRangeForView = (date, view) => { + switch (view) { + case 'days': { + return { + startDate: new Date(date.getFullYear(), date.getMonth(), 1), + endDate: new Date(date.getFullYear(), date.getMonth() + 2, 0) + } + } + + case 'months': { + return { + startDate: new Date(date.getFullYear(), 0, 1), + endDate: new Date(date.getFullYear(), 11, 31) + } + } + + case 'years': { + const startYear = Math.floor(date.getFullYear() / 12) * 12 + return { + startDate: new Date(startYear, 0, 1), + endDate: new Date(startYear + 11, 11, 31) + } + } + + default: { + return null + } + } + } + + const myDefaultAllowList = coreui.Calendar.Default.allowList + myDefaultAllowList.div.push('style') + + // Initialize calendar with data + const initCalendar = async () => { + const initialDate = new Date(2025, 0, 1) + const dateRange = getDateRangeForView(initialDate, 'days') + + // Fetch initial data + await fetchPricingData(dateRange.startDate, dateRange.endDate, 'days') + + const calendar = new coreui.Calendar(myCalendarCustomizeCells, { + calendars: 2, + calendarDate: initialDate, + locale: 'en-US', + minDate: new Date(2022, 0, 1), + maxDate: new Date(2025, 11, 31), + range: true, + renderDayCell(date, meta) { + const dateKey = date.toISOString().split('T')[0] + const price = pricingData[dateKey] + + return `
+
${date.toLocaleDateString('en-US', { day: '2-digit' })}
+
${price ? `$${price}` : '-'}
+
` + }, + renderMonthCell(date, meta) { + const priceRange = getMonthPriceRange(date.getFullYear(), date.getMonth()) + + return `
+
${date.toLocaleDateString('en-US', { month: 'short' })}
+
${priceRange ? `$${priceRange.min}-$${priceRange.max}` : '-'}
+
` + }, + renderYearCell(date, meta) { + const priceRange = getYearPriceRange(date.getFullYear()) + + return `
+
${date.getFullYear()}
+
${priceRange ? `$${priceRange.min}-$${priceRange.max}` : '-'}
+
` + } + }) + + // Fetch data when date or view changes + const handleDataUpdate = (date, view) => { + const dateRange = getDateRangeForView(date, view) + if (dateRange) { + fetchPricingData(dateRange.startDate, dateRange.endDate, view).then(() => { + calendar.refresh() + }) + } + } + + // Listen for calendar date changes + myCalendarCustomizeCells.addEventListener('calendarDateChange.coreui.calendar', event => { + handleDataUpdate(event.date, event.view || 'days') + }) + + // Listen for calendar view changes + myCalendarCustomizeCells.addEventListener('calendarViewChange.coreui.calendar', event => { + if (event.source !== 'cellClick') { + handleDataUpdate(calendar._calendarDate || initialDate, event.view) + } + }) + } + + initCalendar() + } + // js-docs-end calendar-customize-cells + // ------------------------------- // Date Pickers // ------------------------------- diff --git a/docs/content/components/calendar.md b/docs/content/components/calendar.md index 05af9151c..845075b36 100644 --- a/docs/content/components/calendar.md +++ b/docs/content/components/calendar.md @@ -247,6 +247,66 @@ Example of the Bootstrap Calendar component with Persian locale settings. {{< /example >}} +## Custom cell rendering + +The Calendar component provides powerful customization options through render functions that allow you to completely control how calendar cells are displayed. These render functions are particularly useful when you need to add custom content, styling, or data to calendar cells. + +### Render functions + +The component supports four render functions, one for each view type: + +- `renderDayCell(date, meta)` - Customize day cells in the days view +- `renderMonthCell(date, meta)` - Customize month cells in the months view +- `renderQuarterCell(date, meta)` - Customize quarter cells in the quarters view +- `renderYearCell(date, meta)` - Customize year cells in the years view + +Each render function receives two parameters: + +1. `date` - A JavaScript Date object representing the cell's date +2. `meta` - An object containing cell state information: + - `isDisabled` - Whether the cell is disabled + - `isInRange` - Whether the cell is within a selected range (range selection only) + - `isSelected` - Whether the cell is selected + - For `renderDayCell` only: + - `isInCurrentMonth` - Whether the day belongs to the current month + - `isToday` - Whether the day is today + +The render functions should return an HTML string that will be displayed inside the cell. The returned HTML is automatically sanitized to prevent XSS attacks. + +### Format options + +In addition to custom rendering, you can control the display format of calendar elements using format options: + +- `dayFormat` - Controls how day numbers are displayed (`'numeric'` or `'2-digit'`) +- `monthFormat` - Controls how month names are displayed (`'long'`, `'narrow'`, `'short'`, `'numeric'`, or `'2-digit'`) +- `yearFormat` - Controls how year numbers are displayed (`'numeric'` or `'2-digit'`) +- `weekdayFormat` - Controls how weekday names are displayed (number for character length, or `'long'`, `'narrow'`, `'short'`) + +These format options use the JavaScript `Intl.DateTimeFormat` API and respect the `locale` setting. + +### Security considerations + +For security reasons, all HTML returned by render functions is automatically sanitized using the built-in sanitizer. You can: + +- Disable sanitization by setting `sanitize: false` (not recommended) +- Customize allowed HTML tags and attributes using the `allowList` option +- Provide your own sanitization function using the `sanitizeFn` option + +Note that `sanitize`, `sanitizeFn`, and `allowList` options cannot be supplied via data attributes for security reasons. + +### Pricing calendar with custom cells + +This example demonstrates advanced usage of custom cell rendering to display pricing data across different calendar views. It uses `renderDayCell` to show daily prices, `renderMonthCell` to display monthly price ranges, and `renderYearCell` to show annual price ranges. The data is fetched from an external API and cached for performance. + +{{< example stackblitz_pro="true" stackblitz_add_js="true">}} +
+
+
+{{< /example >}} + +{{< js-docs name="calendar-customize-cells" file="docs/assets/js/partials/snippets.js" >}} + + ## Usage {{< bootstrap-compatibility >}} @@ -280,9 +340,14 @@ const calendarList = calendarElementList.map(calendarEl => { {{< partial "js-data-attributes.md" >}} {{< /markdown >}} +{{< callout warning >}} +Note that for security reasons the `sanitize`, `sanitizeFn`, and `allowList` options cannot be supplied using data attributes. +{{< /callout >}} + {{< bs-table >}} | Name | Type | Default | Description | | --- | --- | --- | --- | +| `allowList` | object | [Default value]({{< docsref "/getting-started/javascript#sanitizer" >}}) | Object which contains allowed attributes and tags. | | `ariaNavNextMonthLabel` | string | `'Next month'` | A string that provides an accessible label for the button that navigates to the next month in the calendar. This label is read by screen readers to describe the action associated with the button. | | `ariaNavNextYearLabel` | string | `'Next year'` | A string that provides an accessible label for the button that navigates to the next year in the calendar. This label is intended for screen readers to help users understand the button's functionality. | | `ariaNavPrevMonthLabel` | string | `'Previous month'` | A string that provides an accessible label for the button that navigates to the previous month in the calendar. Screen readers will use this label to explain the purpose of the button. | @@ -298,6 +363,12 @@ const calendarList = calendarElementList.map(calendarEl => { | `minDate` | date, number, string, null | `null` | Min selectable date. | | `monthFormat` | `'long'`, `'narrow'`, `'short'`, `'numeric'`, `'2-digit'` | `'short'` | Sets the format for month names. Accepts built-in formats (`'long'`, `'narrow'`, `'short'`, `'numeric'`, `'2-digit'`). | | `range` | boolean | `false` | Allow range selection | +| `renderDayCell` | function, null | `null` | Custom function to render day cells. Receives `date` and `meta` object (with `isDisabled`, `isInCurrentMonth`, `isInRange`, `isSelected`, `isToday`) as parameters and should return HTML string. | +| `renderMonthCell` | function, null | `null` | Custom function to render month cells. Receives `date` and `meta` object (with `isDisabled`, `isInRange`, `isSelected`) as parameters and should return HTML string. | +| `renderQuarterCell` | function, null | `null` | Custom function to render quarter cells. Receives `date` and `meta` object (with `isDisabled`, `isInRange`, `isSelected`) as parameters and should return HTML string. | +| `renderYearCell` | function, null | `null` | Custom function to render year cells. Receives `date` and `meta` object (with `isDisabled`, `isInRange`, `isSelected`) as parameters and should return HTML string. | +| `sanitize` | boolean | `true` | Enable or disable the sanitization. If activated `renderDayCell`, `renderMonthCell`, `renderQuarterCell`, and `renderYearCell` options will be sanitized. | +| `sanitizeFn` | null, function | `null` | Here you can supply your own sanitize function. This can be useful if you prefer to use a dedicated library to perform sanitization. | | `selectAdjacementDays` | boolean | `false` | Set whether days in adjacent months shown before or after the current month are selectable. This only applies if the `showAdjacementDays` option is set to true. | | `selectionType` | `'day'`, `'week'`, `'month'`, `'quarter'`, `'year'` | `day` | Specify the type of date selection as day, week, month, quarter, or year. | | `showAdjacementDays` | boolean | `true` | Set whether to display dates in adjacent months (non-selectable) at the start and end of the current month. | @@ -322,14 +393,15 @@ const calendarList = calendarElementList.map(calendarEl => { ### Events {{< bs-table >}} -| Method | Description | -| --- | --- | -| `calendarDateChange.coreui.calendar` | Callback fired when the calendar date changed. | -| `calendarMouseleave.coreui.calendar` | Callback fired when the cursor leave the calendar. | -| `cellHover.coreui.calendar` | Callback fired when the user hovers over the calendar cell. | -| `endDateChange.coreui.calendar` | Callback fired when the end date changed. | -| `selectEndChange.coreui.calendar` | Callback fired when the selection type changed. | -| `startDateChange.coreui.calendar` | Callback fired when the start date changed. | +| Method | Description | Event detail | +| --- | --- | --- | +| `calendarDateChange.coreui.calendar` | Fired when the calendar date changes. | `{ date: Date, view: 'days' \| 'months' \| 'quarters' \| 'years' }` | +| `calendarViewChange.coreui.calendar` | Fired when the calendar view changes. | `{ view: 'days' \| 'months' \| 'quarters' \| 'years', source: 'cellClick' \| 'navigation' }` | +| `calendarMouseleave.coreui.calendar` | Fired when the cursor leaves the calendar. | — | +| `cellHover.coreui.calendar` | Fired when the user hovers over a calendar cell. | `{ date: Date \| null }` | +| `endDateChange.coreui.calendar` | Fired when the end date changes. | `{ date: Date \| null }` | +| `selectEndChange.coreui.calendar` | Fired when the selection mode changes. | `{ value: boolean }` | +| `startDateChange.coreui.calendar` | Fired when the start date changes. | `{ date: Date \| null }` | {{< /bs-table >}} ```js diff --git a/docs/content/forms/date-picker.md b/docs/content/forms/date-picker.md index 681186696..314b65c45 100644 --- a/docs/content/forms/date-picker.md +++ b/docs/content/forms/date-picker.md @@ -395,9 +395,14 @@ const datePickerList = datePickerElementList.map(datePickerEl => { {{< partial "js-data-attributes.md" >}} {{< /markdown >}} +{{< callout warning >}} +Note that for security reasons the `sanitize`, `sanitizeFn`, and `allowList` options cannot be supplied using data attributes. +{{< /callout >}} + {{< bs-table >}} | Name | Type | Default | Description | | --- | --- | --- | --- | +| `allowList` | object | [Default value]({{< docsref "/getting-started/javascript#sanitizer" >}}) | Object which contains allowed attributes and tags. | | `ariaNavNextMonthLabel` | string | `'Next month'` | A string that provides an accessible label for the button that navigates to the next month in the calendar. This label is read by screen readers to describe the action associated with the button. | | `ariaNavNextYearLabel` | string | `'Next year'` | A string that provides an accessible label for the button that navigates to the next year in the calendar. This label is intended for screen readers to help users understand the button's functionality. | | `ariaNavPrevMonthLabel` | string | `'Previous month'` | A string that provides an accessible label for the button that navigates to the previous month in the calendar. Screen readers will use this label to explain the purpose of the button. | @@ -428,6 +433,12 @@ const datePickerList = datePickerElementList.map(datePickerEl => { | `name` | string, null | `null` | Set the name attribute for the input element. | | `placeholder` | string | `'Select time'` | Specifies a short hint that is visible in the input. | | `previewDateOnHover` | boolean | `true` | Enable live preview of dates in input field when hovering over calendar cells. | +| `renderDayCell` | function, null | `null` | Custom function to render day cells. Receives `date` and `meta` object (with `isDisabled`, `isInCurrentMonth`, `isInRange`, `isSelected`, `isToday`) as parameters and should return HTML string. | +| `renderMonthCell` | function, null | `null` | Custom function to render month cells. Receives `date` and `meta` object (with `isDisabled`, `isInRange`, `isSelected`) as parameters and should return HTML string. | +| `renderQuarterCell` | function, null | `null` | Custom function to render quarter cells. Receives `date` and `meta` object (with `isDisabled`, `isInRange`, `isSelected`) as parameters and should return HTML string. | +| `renderYearCell` | function, null | `null` | Custom function to render year cells. Receives `date` and `meta` object (with `isDisabled`, `isInRange`, `isSelected`) as parameters and should return HTML string. | +| `sanitize` | boolean | `true` | Enable or disable the sanitization. If activated `renderDayCell`, `renderMonthCell`, `renderQuarterCell`, and `renderYearCell` options will be sanitized. | +| `sanitizeFn` | null, function | `null` | Here you can supply your own sanitize function. This can be useful if you prefer to use a dedicated library to perform sanitization. | | `selectAdjacementDays` | boolean | `false` | Set whether days in adjacent months shown before or after the current month are selectable. This only applies if the `showAdjacementDays` option is set to true. | | `selectionType` | `'day'`, `'week'`, `'month'`, `'quarter'`, `'year'` | `day` | Specify the type of date selection as day, week, month, quarter, or year. | | `showAdjacementDays` | boolean | `true` | Set whether to display dates in adjacent months (non-selectable) at the start and end of the current month. | diff --git a/docs/content/forms/date-range-picker.md b/docs/content/forms/date-range-picker.md index 2101cf293..b02ea1e2c 100644 --- a/docs/content/forms/date-range-picker.md +++ b/docs/content/forms/date-range-picker.md @@ -447,9 +447,14 @@ const dateRangePickerList = dateRangePickerElementList.map(dateRangePickerEl => {{< partial "js-data-attributes.md" >}} {{< /markdown >}} +{{< callout warning >}} +Note that for security reasons the `sanitize`, `sanitizeFn`, and `allowList` options cannot be supplied using data attributes. +{{< /callout >}} + {{< bs-table >}} | Name | Type | Default | Description | | --- | --- | --- | --- | +| `allowList` | object | [Default value]({{< docsref "/getting-started/javascript#sanitizer" >}}) | Object which contains allowed attributes and tags. | | `ariaNavNextMonthLabel` | string | `'Next month'` | A string that provides an accessible label for the button that navigates to the next month in the calendar. This label is read by screen readers to describe the action associated with the button. | | `ariaNavNextYearLabel` | string | `'Next year'` | A string that provides an accessible label for the button that navigates to the next year in the calendar. This label is intended for screen readers to help users understand the button's functionality. | | `ariaNavPrevMonthLabel` | string | `'Previous month'` | A string that provides an accessible label for the button that navigates to the previous month in the calendar. Screen readers will use this label to explain the purpose of the button. | @@ -483,6 +488,12 @@ const dateRangePickerList = dateRangePickerElementList.map(dateRangePickerEl => | `previewDateOnHover` | boolean | `true` | Enable live preview of dates in input fields when hovering over calendar cells. | | `ranges` | object | `{}` | Predefined date ranges the user can select from. | | `rangesButtonsClasses` | array, string | `['btn', 'btn-ghost-secondary']` | CSS class names that will be added to ranges buttons | +| `renderDayCell` | function, null | `null` | Custom function to render day cells. Receives `date` and `meta` object (with `isDisabled`, `isInCurrentMonth`, `isInRange`, `isSelected`, `isToday`) as parameters and should return HTML string. | +| `renderMonthCell` | function, null | `null` | Custom function to render month cells. Receives `date` and `meta` object (with `isDisabled`, `isInRange`, `isSelected`) as parameters and should return HTML string. | +| `renderQuarterCell` | function, null | `null` | Custom function to render quarter cells. Receives `date` and `meta` object (with `isDisabled`, `isInRange`, `isSelected`) as parameters and should return HTML string. | +| `renderYearCell` | function, null | `null` | Custom function to render year cells. Receives `date` and `meta` object (with `isDisabled`, `isInRange`, `isSelected`) as parameters and should return HTML string. | +| `sanitize` | boolean | `true` | Enable or disable the sanitization. If activated `renderDayCell`, `renderMonthCell`, `renderQuarterCell`, and `renderYearCell` options will be sanitized. | +| `sanitizeFn` | null, function | `null` | Here you can supply your own sanitize function. This can be useful if you prefer to use a dedicated library to perform sanitization. | | `selectAdjacementDays` | boolean | `false` | Set whether days in adjacent months shown before or after the current month are selectable. This only applies if the `showAdjacementDays` option is set to true. | | `selectionType` | `'day'`, `'week'`, `'month'`, `'quarter'`, `'year'` | `day` | Specify the type of date selection as day, week, month, quarter, or year. | | `separator` | boolean | `true` | Toggle visibility or set the content of the inputs separator. | diff --git a/js/src/calendar.js b/js/src/calendar.js index c2170ebc9..e5e989636 100644 --- a/js/src/calendar.js +++ b/js/src/calendar.js @@ -10,6 +10,7 @@ import BaseComponent from './base-component.js' import EventHandler from './dom/event-handler.js' import Manipulator from './dom/manipulator.js' import SelectorEngine from './dom/selector-engine.js' +import { DefaultAllowlist, sanitizeHtml } from './util/sanitizer.js' import { defineJQueryPlugin } from './util/index.js' import { convertToDateObject, @@ -44,6 +45,7 @@ const NAME = 'calendar' const DATA_KEY = 'coreui.calendar' const EVENT_KEY = `.${DATA_KEY}` const DATA_API_KEY = '.data-api' +const DISALLOWED_ATTRIBUTES = new Set(['sanitize', 'allowList', 'sanitizeFn']) const ARROW_UP_KEY = 'ArrowUp' const ARROW_RIGHT_KEY = 'ArrowRight' @@ -55,6 +57,7 @@ const SPACE_KEY = 'Space' const EVENT_BLUR = `blur${EVENT_KEY}` const EVENT_CALENDAR_DATE_CHANGE = `calendarDateChange${EVENT_KEY}` const EVENT_CALENDAR_MOUSE_LEAVE = `calendarMouseleave${EVENT_KEY}` +const EVENT_CALENDAR_VIEW_CHANGE = `calendarViewChange${EVENT_KEY}` const EVENT_CELL_HOVER = `cellHover${EVENT_KEY}` const EVENT_END_DATE_CHANGE = `endDateChange${EVENT_KEY}` const EVENT_FOCUS = `focus${EVENT_KEY}` @@ -86,6 +89,7 @@ const SELECTOR_CALENDAR_ROW_CLICKABLE = `${SELECTOR_CALENDAR_ROW}[tabindex="0"]` const SELECTOR_DATA_TOGGLE = '[data-coreui-toggle="calendar"]' const Default = { + allowList: DefaultAllowlist, ariaNavNextMonthLabel: 'Next month', ariaNavNextYearLabel: 'Next year', ariaNavPrevMonthLabel: 'Previous month', @@ -101,6 +105,12 @@ const Default = { minDate: null, monthFormat: 'short', range: false, + renderDayCell: null, + renderMonthCell: null, + renderQuarterCell: null, + renderYearCell: null, + sanitize: true, + sanitizeFn: null, selectAdjacementDays: false, selectEndDate: false, selectionType: 'day', @@ -113,6 +123,7 @@ const Default = { } const DefaultType = { + allowList: 'object', ariaNavNextMonthLabel: 'string', ariaNavNextYearLabel: 'string', ariaNavPrevMonthLabel: 'string', @@ -128,6 +139,12 @@ const DefaultType = { minDate: '(date|number|string|null)', monthFormat: 'string', range: 'boolean', + renderDayCell: '(function|null)', + renderMonthCell: '(function|null)', + renderQuarterCell: '(function|null)', + renderYearCell: '(function|null)', + sanitize: 'boolean', + sanitizeFn: '(null|function)', selectAdjacementDays: 'boolean', selectEndDate: 'boolean', selectionType: 'string', @@ -178,6 +195,12 @@ class Calendar extends BaseComponent { this._createCalendar() } + refresh() { + // Clear the current calendar content + this._element.innerHTML = '' + this._createCalendar() + } + // Private _focusOnFirstAvailableCell() { const cell = SelectorEngine.findOne(SELECTOR_CALENDAR_CELL_CLICKABLE, this._element) @@ -197,7 +220,7 @@ class Calendar extends BaseComponent { } _handleCalendarClick(event) { - const target = event.target.classList.contains(CLASS_NAME_CALENDAR_CELL_INNER) ? event.target.parentElement : event.target + const target = event.target.closest(SELECTOR_CALENDAR_CELL) const date = this._getDate(target) const cloneDate = new Date(date) const index = Manipulator.getDataAttribute(target.closest(SELECTOR_CALENDAR), 'calendar-index') @@ -207,15 +230,15 @@ class Calendar extends BaseComponent { } if (this._view === 'months' && this._config.selectionType !== 'month') { - this._setCalendarDate(index ? new Date(cloneDate.setMonth(cloneDate.getMonth() - index)) : date) - this._view = 'days' + this._setCalendarDate(index ? new Date(cloneDate.setMonth(cloneDate.getMonth() - index)) : date, 'days') + this._setCalendarView('days', 'cellClick') this._updateCalendar(this._focusOnFirstAvailableCell.bind(this)) return } if (this._view === 'years' && this._config.selectionType !== 'year') { - this._setCalendarDate(index ? new Date(cloneDate.setFullYear(cloneDate.getFullYear() - index)) : date) - this._view = this._config.selectionType === 'quarter' ? 'quarters' : 'months' + this._setCalendarDate(index ? new Date(cloneDate.setFullYear(cloneDate.getFullYear() - index)) : date, 'months') + this._setCalendarView(this._config.selectionType === 'quarter' ? 'quarters' : 'months', 'cellClick') this._updateCalendar(this._focusOnFirstAvailableCell.bind(this)) return } @@ -345,7 +368,7 @@ class Calendar extends BaseComponent { } _handleCalendarMouseEnter(event) { - const target = event.target.classList.contains(CLASS_NAME_CALENDAR_CELL_INNER) ? event.target.parentElement : event.target + const target = event.target.closest(SELECTOR_CALENDAR_CELL) const date = this._getDate(target) if (isDateDisabled(date, this._minDate, this._maxDate, this._config.disabledDates)) { @@ -435,11 +458,11 @@ class Calendar extends BaseComponent { [SELECTOR_BTN_NEXT]: () => this._modifyCalendarDate(0, 1), [SELECTOR_BTN_DOUBLE_NEXT]: () => this._modifyCalendarDate(this._view === 'years' ? 10 : 1), [SELECTOR_BTN_MONTH]: () => { - this._view = 'months' + this._setCalendarView('months', 'navigation') this._updateCalendar() }, [SELECTOR_BTN_YEAR]: () => { - this._view = 'years' + this._setCalendarView('years', 'navigation') this._updateCalendar() } } @@ -460,11 +483,21 @@ class Calendar extends BaseComponent { } } - _setCalendarDate(date) { + _setCalendarDate(date, view = this._view) { this._calendarDate = date EventHandler.trigger(this._element, EVENT_CALENDAR_DATE_CHANGE, { - date + date, + view + }) + } + + _setCalendarView(view, source) { + this._view = view + + EventHandler.trigger(this._element, EVENT_CALENDAR_VIEW_CHANGE, { + view, + source }) } @@ -483,11 +516,10 @@ class Calendar extends BaseComponent { this._calendarDate = d - if (this._view === 'days') { - EventHandler.trigger(this._element, EVENT_CALENDAR_DATE_CHANGE, { - date: d - }) - } + EventHandler.trigger(this._element, EVENT_CALENDAR_DATE_CHANGE, { + date: d, + view: this._view + }) this._updateCalendar(callback) } @@ -650,8 +682,8 @@ class Calendar extends BaseComponent { ${cellAttributes.ariaSelected ? 'aria-selected="true"' : ''} data-coreui-date="${date}" > -
- ${date.toLocaleDateString(this._config.locale, { day: this._config.dayFormat })} +
+ ${this._config.renderDayCell ? this._sanitizeHtml(this._config.renderDayCell(date, cellAttributes.meta)) : date.toLocaleDateString(this._config.locale, { day: this._config.dayFormat })}
` : '' @@ -671,8 +703,8 @@ class Calendar extends BaseComponent { ${cellAttributes.ariaSelected ? 'aria-selected="true"' : ''} data-coreui-date="${date.toDateString()}" > -
- ${month} +
+ ${this._config.renderMonthCell ? this._sanitizeHtml(this._config.renderMonthCell(date, cellAttributes.meta)) : month}
` ) @@ -691,8 +723,8 @@ class Calendar extends BaseComponent { ${cellAttributes.ariaSelected ? 'aria-selected="true"' : ''} data-coreui-date="${date.toDateString()}" > -
- ${`Q${index + 1}`} +
+ ${this._config.renderQuarterCell ? this._sanitizeHtml(this._config.renderQuarterCell(date, cellAttributes.meta)) : `Q${index + 1}`}
` ) @@ -710,8 +742,8 @@ class Calendar extends BaseComponent { ${cellAttributes.ariaSelected ? 'aria-selected="true"' : ''} data-coreui-date="${date.toDateString()}" > -
- ${date.toLocaleDateString(this._config.locale, { year: this._config.yearFormat })} +
+ ${this._config.renderYearCell ? this._sanitizeHtml(this._config.renderYearCell(date, cellAttributes.meta)) : date.toLocaleDateString(this._config.locale, { year: this._config.yearFormat })}
` ) @@ -882,7 +914,14 @@ class Calendar extends BaseComponent { return { className: classNames, tabIndex: (isCurrentMonth || this._config.selectAdjacementDays) && !isDisabled ? 0 : -1, - ariaSelected: isSelected + ariaSelected: isSelected, + meta: { + isDisabled, + isInCurrentMonth: isCurrentMonth, + isInRange, + isSelected, + isToday: isTodayDate + } } } @@ -907,7 +946,12 @@ class Calendar extends BaseComponent { return { className: classNames, tabIndex: isDisabled ? -1 : 0, - ariaSelected: isSelected + ariaSelected: isSelected, + meta: { + isDisabled, + isInRange, + isSelected + } } } @@ -932,7 +976,12 @@ class Calendar extends BaseComponent { return { className: classNames, tabIndex: isDisabled ? -1 : 0, - ariaSelected: isSelected + ariaSelected: isSelected, + meta: { + isDisabled, + isInRange, + isSelected + } } } @@ -957,7 +1006,12 @@ class Calendar extends BaseComponent { return { className: classNames, tabIndex: isDisabled ? -1 : 0, - ariaSelected: isSelected + ariaSelected: isSelected, + meta: { + isDisabled, + isInRange, + isSelected + } } } @@ -995,6 +1049,34 @@ class Calendar extends BaseComponent { } } + _sanitizeHtml(html) { + if (this._config.sanitize) { + return sanitizeHtml(html, this._config.allowList, this._config.sanitizeFn) + } + + return html + } + + _getConfig(config) { + const dataAttributes = Manipulator.getDataAttributes(this._element) + + for (const dataAttribute of Object.keys(dataAttributes)) { + if (DISALLOWED_ATTRIBUTES.has(dataAttribute)) { + delete dataAttributes[dataAttribute] + } + } + + config = { + ...dataAttributes, + ...(typeof config === 'object' && config ? config : {}) + } + config = this._mergeConfigObj(config, this._element) + config = this._configAfterMerge(config) + this._typeCheckConfig(config) + + return config + } + // Static static calendarInterface(element, config) { diff --git a/js/src/date-range-picker.js b/js/src/date-range-picker.js index 6c291dae0..fddf833d4 100644 --- a/js/src/date-range-picker.js +++ b/js/src/date-range-picker.js @@ -12,6 +12,7 @@ import TimePicker from './time-picker.js' import EventHandler from './dom/event-handler.js' import Manipulator from './dom/manipulator.js' import SelectorEngine from './dom/selector-engine.js' +import { DefaultAllowlist } from './util/sanitizer.js' import { defineJQueryPlugin, getElement, isRTL } from './util/index.js' import { convertToDateObject, getDateBySelectionType, getLocalDateFromString, isDateDisabled @@ -26,6 +27,7 @@ const NAME = 'date-range-picker' const DATA_KEY = 'coreui.date-range-picker' const EVENT_KEY = `.${DATA_KEY}` const DATA_API_KEY = '.data-api' +const DISALLOWED_ATTRIBUTES = new Set(['sanitize', 'allowList', 'sanitizeFn']) const ENTER_KEY = 'Enter' const ESCAPE_KEY = 'Escape' @@ -77,6 +79,7 @@ const SELECTOR_INPUT = '.date-picker-input' const SELECTOR_WAS_VALIDATED = 'form.was-validated' const Default = { + allowList: DefaultAllowlist, ariaNavNextMonthLabel: 'Next month', ariaNavNextYearLabel: 'Next year', ariaNavPrevMonthLabel: 'Previous month', @@ -113,7 +116,13 @@ const Default = { range: true, ranges: {}, rangesButtonsClasses: ['btn', 'btn-ghost-secondary'], + renderDayCell: null, + renderMonthCell: null, + renderQuarterCell: null, + renderYearCell: null, required: true, + sanitize: true, + sanitizeFn: null, separator: true, size: null, startDate: null, @@ -133,6 +142,7 @@ const Default = { } const DefaultType = { + allowList: 'object', ariaNavNextMonthLabel: 'string', ariaNavNextYearLabel: 'string', ariaNavPrevMonthLabel: 'string', @@ -169,7 +179,13 @@ const DefaultType = { range: 'boolean', ranges: 'object', rangesButtonsClasses: '(array|string)', + renderDayCell: '(function|null)', + renderMonthCell: '(function|null)', + renderQuarterCell: '(function|null)', + renderYearCell: '(function|null)', required: 'boolean', + sanitize: 'boolean', + sanitizeFn: '(null|function)', separator: 'boolean', size: '(string|null)', startDate: '(date|number|string|null)', @@ -556,6 +572,7 @@ class DateRangePicker extends BaseComponent { _getCalendarConfig() { return { + allowList: this._config.allowList, ariaNavNextMonthLabel: this._config.ariaNavNextMonthLabel, ariaNavNextYearLabel: this._config.ariaNavNextYearLabel, ariaNavPrevMonthLabel: this._config.ariaNavPrevMonthLabel, @@ -571,6 +588,12 @@ class DateRangePicker extends BaseComponent { minDate: this._config.minDate, monthFormat: this._config.monthFormat, range: this._config.range, + renderDayCell: this._config.renderDayCell, + renderMonthCell: this._config.renderMonthCell, + renderQuarterCell: this._config.renderQuarterCell, + renderYearCell: this._config.renderYearCell, + sanitize: this._config.sanitize, + sanitizeFn: this._config.sanitizeFn, selectAdjacementDays: this._config.selectAdjacementDays, selectEndDate: this._selectEndDate, selectionType: this._config.selectionType, @@ -1042,6 +1065,26 @@ class DateRangePicker extends BaseComponent { return '' } + _getConfig(config) { + const dataAttributes = Manipulator.getDataAttributes(this._element) + + for (const dataAttribute of Object.keys(dataAttributes)) { + if (DISALLOWED_ATTRIBUTES.has(dataAttribute)) { + delete dataAttributes[dataAttribute] + } + } + + config = { + ...dataAttributes, + ...(typeof config === 'object' && config ? config : {}) + } + config = this._mergeConfigObj(config, this._element) + config = this._configAfterMerge(config) + this._typeCheckConfig(config) + + return config + } + _configAfterMerge(config) { if (config.container === true) { config.container = document.body diff --git a/scss/_calendar.scss b/scss/_calendar.scss index 7757395ce..058ffcc37 100644 --- a/scss/_calendar.scss +++ b/scss/_calendar.scss @@ -26,6 +26,7 @@ --#{$prefix}calendar-nav-btn-hover-border-color: #{$calendar-nav-btn-hover-border-color}; --#{$prefix}calendar-nav-btn-focus-border-color: #{$calendar-nav-btn-focus-border-color}; --#{$prefix}calendar-nav-btn-focus-box-shadow: #{$calendar-nav-btn-focus-box-shadow}; + --#{$prefix}calendar-nav-date-font-weight: #{$calendar-nav-date-font-weight}; --#{$prefix}calendar-nav-date-color: #{$calendar-nav-date-color}; --#{$prefix}calendar-nav-date-hover-color: #{$calendar-nav-date-hover-color}; --#{$prefix}calendar-nav-icon-width: #{$calendar-nav-icon-width}; @@ -36,7 +37,9 @@ --#{$prefix}calendar-nav-icon-prev: #{escape-svg($calendar-nav-icon-prev)}; --#{$prefix}calendar-nav-icon-color: #{$calendar-nav-icon-color}; --#{$prefix}calendar-nav-icon-hover-color: #{$calendar-nav-icon-hover-color}; + --#{$prefix}calendar-cell-header-inner-font-weight: #{$calendar-cell-header-inner-font-weight}; --#{$prefix}calendar-cell-header-inner-color: #{$calendar-cell-header-inner-color}; + --#{$prefix}calendar-cell-week-number-font-weight: #{$calendar-cell-week-number-font-weight}; --#{$prefix}calendar-cell-week-number-color: #{$calendar-cell-week-number-color}; --#{$prefix}calendar-cell-hover-color: #{$calendar-cell-hover-color}; --#{$prefix}calendar-cell-hover-bg: #{$calendar-cell-hover-bg}; @@ -126,7 +129,7 @@ .calendar-nav-btn, // TODO: remove .btn class when no longer needed in v6 .btn { - font-weight: 600; + font-weight: var(--#{$prefix}calendar-nav-date-font-weight); color: var(--#{$prefix}calendar-nav-date-color); &:hover { @@ -164,16 +167,17 @@ } .calendar-header-cell-inner { + position: relative; display: flex; align-items: center; justify-content: center; height: var(--#{$prefix}calendar-table-cell-size); - font-weight: 600; + font-weight: var(--#{$prefix}calendar-cell-header-inner-font-weight); color: var(--#{$prefix}calendar-cell-header-inner-color); } .calendar-cell-week-number { - font-weight: 600; + font-weight: var(--#{$prefix}calendar-cell-week-number-font-weight); color: var(--#{$prefix}calendar-cell-week-number-color); } @@ -181,7 +185,7 @@ display: flex; align-items: center; justify-content: center; - height: var(--#{$prefix}calendar-table-cell-size); + min-height: var(--#{$prefix}calendar-table-cell-size); } .calendar-row, diff --git a/scss/_variables.scss b/scss/_variables.scss index 8d918cc56..1f93d37e5 100644 --- a/scss/_variables.scss +++ b/scss/_variables.scss @@ -2340,7 +2340,6 @@ $calendar-table-cell-size: 2.75rem !default; $calendar-nav-padding: .5rem !default; $calendar-nav-border-width: 1px !default; $calendar-nav-border-color: var(--#{$prefix}border-color) !default; -$calendar-nav-date-color: var(--#{$prefix}body-color) !default; $calendar-nav-btn-padding-y: .25rem !default; $calendar-nav-btn-padding-x: .5rem !default; @@ -2356,7 +2355,10 @@ $calendar-nav-btn-hover-border-color: transparent !default; $calendar-nav-btn-focus-border-color: transparent !default; $calendar-nav-btn-focus-box-shadow: $focus-ring-box-shadow !default; +$calendar-nav-date-font-weight: 600 !default; +$calendar-nav-date-color: var(--#{$prefix}body-color) !default; $calendar-nav-date-hover-color: var(--#{$prefix}primary) !default; + $calendar-nav-icon-width: 1rem !default; $calendar-nav-icon-height: 1rem !default; $calendar-nav-icon-color: var(--#{$prefix}tertiary-color) !default; @@ -2367,8 +2369,8 @@ $calendar-nav-icon-double-prev: url("data:image/svg+xml,") !default; $calendar-nav-icon-prev: url("data:image/svg+xml,") !default; +$calendar-cell-header-inner-font-weight: 600 !default; $calendar-cell-header-inner-color: var(--#{$prefix}secondary-color) !default; -$calendar-cell-week-number-color: var(--#{$prefix}secondary-color) !default; $calendar-cell-hover-color: var(--#{$prefix}body-color) !default; $calendar-cell-hover-bg: var(--#{$prefix}tertiary-bg) !default; @@ -2385,7 +2387,8 @@ $calendar-cell-range-hover-border-color: var(--#{$prefix}primary) !default; $calendar-cell-today-color: var(--#{$prefix}danger) !default; -$calendar-cell-week-number-color: var(--#{$prefix}tertiary-color) !default; +$calendar-cell-week-number-font-weight: 600 !default; +$calendar-cell-week-number-color: var(--#{$prefix}secondary-color) !default; // scss-docs-end calendar-variables // Date Picker