feat(components): [calendar] add controller-type and formatter props (#23045)

* feat(components): [calendar] support controller-type prop

* refactor: move handleDateChange to useCalendar

* feat: update

* style: update

* style: update

* feat: support formatter prop

* feat: update

* docs: update version number

* chore: use ExtractPublicPropTypes

* test: update

* chore: improve parameter type

* feat: update version number

* feat: update

* feat: update

* Apply suggestions from code review

Co-authored-by: rzzf <cszhjh@gmail.com>

* docs: update example

---------

Co-authored-by: rzzf <cszhjh@gmail.com>
Co-authored-by: warmthsea <2586244885@qq.com>
This commit is contained in:
Rainbow
2025-12-25 18:04:10 +08:00
committed by GitHub
parent 47f1b36b17
commit 16bbdf7a3b
11 changed files with 288 additions and 5 deletions

View File

@@ -15,6 +15,14 @@ calendar/basic
:::
## Controller Type ^(2.13.1)
:::demo You can set the type of the controller for Calendar header. When setting `select`, you can use `formatter` to customize `label`.
calendar/controller-type
:::
## Custom Content
:::demo Customize what is displayed in the calendar cell by setting `scoped-slot` named `date-cell`. In `scoped-slot` you can get the date (the date of the current cell), data (including the type, isSelected, day attribute). For details, please refer to the API documentation below.
@@ -49,10 +57,12 @@ Note, date time locale (month name, first day of the week ...) are also configur
### Attributes
| Name | Description | Type | Default |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | ------- |
| model-value / v-model | binding value | ^[Date] | — |
| range | time range, including start time and end time. Start time must be start day of week, end time must be end day of week, the time span cannot exceed two months. | ^[array]`[Date, Date]` | — |
| Name | Description | Type | Default |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------- |
| model-value / v-model | binding value | ^[Date] | — |
| range | time range, including start time and end time. Start time must be start day of week, end time must be end day of week, the time span cannot exceed two months. | ^[array]`[Date, Date]` | — |
| controller-type ^(2.13.1) | type of the controller for Calendar header | ^[enum]`'button' \| 'select'` | button |
| formatter ^(2.13.1) | format label when `controller-type` is 'select' | ^[Function]`(value: number, type: 'year' \| 'month') => string \| number` | — |
### Slots

View File

@@ -0,0 +1,15 @@
<template>
<el-radio-group v-model="controllerType">
<el-radio-button label="select" value="select" />
<el-radio-button label="button" value="button" />
</el-radio-group>
<el-calendar v-model="value" :controller-type="controllerType" />
</template>
<script lang="ts" setup>
import { ref } from 'vue'
const controllerType = ref<'select' | 'button'>('select')
const value = ref(new Date())
</script>

View File

@@ -247,4 +247,84 @@ describe('Calendar.vue', () => {
expect(wrapper.find('.el-calendar__header').text()).toEqual(AXIOM)
expect(wrapper.find('.current.is-today').text()).toEqual(AXIOM)
})
it('should work when controller-type is select', async () => {
const wrapper = mount(
defineComponent({
data: () => ({ value: new Date('2025-12-09') }),
render() {
return <Calendar v-model={this.value} controller-type="select" />
},
})
)
await nextTick()
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
const btn = wrapper.find('.el-button')
const yearSelect = selects[0]
const yearOptions = yearSelect.findAllComponents({ name: 'ElOption' })
const monthSelect = selects[1]
const monthOptions = monthSelect.findAllComponents({ name: 'ElOption' })
const yearVm = yearSelect.vm as any
const monthVm = monthSelect.vm as any
const firstRow = wrapper.element.querySelector('.el-calendar-table__row')
expect(yearVm.modelValue).toBe(2025)
expect(monthVm.modelValue).toBe(12)
expect(firstRow?.firstElementChild?.innerHTML).toContain('30')
expect(firstRow?.lastElementChild?.innerHTML).toContain('6')
await yearOptions[9].trigger('click')
expect(yearVm.modelValue).toBe(2024)
expect(monthVm.modelValue).toBe(12)
expect(firstRow?.firstElementChild?.innerHTML).toContain('1')
expect(firstRow?.lastElementChild?.innerHTML).toContain('7')
await monthOptions[10].trigger('click')
expect(yearVm.modelValue).toBe(2024)
expect(monthVm.modelValue).toBe(11)
expect(firstRow?.firstElementChild?.innerHTML).toContain('27')
expect(firstRow?.lastElementChild?.innerHTML).toContain('2')
await btn.trigger('click')
expect(yearVm.modelValue).toBe(2025)
expect(monthVm.modelValue).toBe(12)
expect(firstRow?.firstElementChild?.innerHTML).toContain('30')
expect(firstRow?.lastElementChild?.innerHTML).toContain('6')
})
it('should work with formatter prop', async () => {
const formatter = (value: number, type: 'year' | 'month') => {
if (type === 'year') {
return `${value}`
} else {
return `${value}`
}
}
const wrapper = mount(
defineComponent({
data: () => ({ value: new Date('2025-12-09') }),
render() {
return (
<Calendar
v-model={this.value}
controller-type="select"
formatter={formatter}
/>
)
},
})
)
await nextTick()
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
const yearSelect = selects[0]
const yearOptions = yearSelect.findAllComponents({ name: 'ElOption' })
const monthSelect = selects[1]
const monthOptions = monthSelect.findAllComponents({ name: 'ElOption' })
expect(yearOptions[0].text()).toBe('2015年')
expect(monthOptions[0].text()).toBe('1月')
})
})

View File

@@ -33,6 +33,22 @@ export const calendarProps = buildProps({
type: definePropType<[Date, Date]>(Array),
validator: isValidRange,
},
/**
* @description type of the controller for the Calendar header
*/
controllerType: {
type: String,
values: ['button', 'select'],
default: 'button',
},
/**
* @description format label when `controller-type` is 'select'
*/
formatter: {
type: definePropType<
(value: number, type: 'year' | 'month') => string | number
>(Function),
},
} as const)
export type CalendarProps = ExtractPropTypes<typeof calendarProps>
export type CalendarPropsPublic = ExtractPublicPropTypes<typeof calendarProps>

View File

@@ -3,7 +3,10 @@
<div :class="ns.e('header')">
<slot name="header" :date="i18nDate">
<div :class="ns.e('title')">{{ i18nDate }}</div>
<div v-if="validatedRange.length === 0" :class="ns.e('button-group')">
<div
v-if="validatedRange.length === 0 && controllerType === 'button'"
:class="ns.e('button-group')"
>
<el-button-group>
<el-button size="small" @click="selectDate('prev-month')">
{{ t('el.datepicker.prevMonth') }}
@@ -16,6 +19,16 @@
</el-button>
</el-button-group>
</div>
<div
v-else-if="validatedRange.length === 0 && controllerType === 'select'"
:class="ns.e('select-controller')"
>
<select-controller
:date="date"
:formatter="formatter"
@date-change="handleDateChange"
/>
</div>
</slot>
</div>
<div v-if="validatedRange.length === 0" :class="ns.e('body')">
@@ -50,6 +63,7 @@ import { useLocale, useNamespace } from '@element-plus/hooks'
import DateTable from './date-table.vue'
import { useCalendar } from './use-calendar'
import { calendarEmits, calendarProps } from './calendar'
import SelectController from './select-controller.vue'
const ns = useNamespace('calendar')
@@ -68,6 +82,7 @@ const {
realSelectedDay,
selectDate,
validatedRange,
handleDateChange,
} = useCalendar(props, emit, COMPONENT_NAME)
const { t } = useLocale()

View File

@@ -0,0 +1,32 @@
import {
buildProps,
definePropType,
isObject,
isString,
} from '@element-plus/utils'
import type { ExtractPropTypes, ExtractPublicPropTypes } from 'vue'
import type { Dayjs } from 'dayjs'
export const selectControllerProps = buildProps({
date: {
type: definePropType<Dayjs>(Object),
required: true,
},
formatter: {
type: definePropType<
(value: number, type: 'year' | 'month') => string | number
>(Function),
},
} as const)
export type SelectControllerProps = ExtractPropTypes<
typeof selectControllerProps
>
export type SelectControllerPropsPublic = ExtractPublicPropTypes<
typeof selectControllerProps
>
export const selectControllerEmits = {
'date-change': (date: Dayjs | 'today') => isObject(date) || isString(date),
}
export type SelectControllerEmits = typeof selectControllerEmits

View File

@@ -0,0 +1,90 @@
<template>
<el-select
:model-value="yearValue"
size="small"
:class="nsSelect.e('year')"
:validate-event="false"
:options="yearOptions"
@change="handleYearChange"
/>
<el-select
:model-value="monthValue"
size="small"
:class="nsSelect.e('month')"
:validate-event="false"
:options="monthOptions"
@change="handleMonthChange"
/>
<el-button size="small" @click="selectToday">
{{ t('el.datepicker.today') }}
</el-button>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import dayjs from 'dayjs'
import { useLocale, useNamespace } from '@element-plus/hooks'
import ElSelect from '@element-plus/components/select'
import { ElButton } from '@element-plus/components/button'
import { isFunction } from '@element-plus/utils'
import {
selectControllerEmits,
selectControllerProps,
} from './select-controller'
defineOptions({
name: 'SelectController',
})
const props = defineProps(selectControllerProps)
const emit = defineEmits(selectControllerEmits)
const nsSelect = useNamespace('calendar-select')
const { t, lang } = useLocale()
const monthOptions = Array.from({ length: 12 }, (_, index) => {
const actualMonth = index + 1
const label = isFunction(props.formatter)
? props.formatter(actualMonth, 'month')
: actualMonth
return {
value: actualMonth,
label,
}
})
const yearValue = computed(() => props.date.year())
const monthValue = computed(() => props.date.month() + 1)
// Get an array of 20 years
const yearOptions = computed(() => {
const years = []
for (let i = -10; i < 10; i++) {
const year = yearValue.value + i
if (year > 0) {
const label = isFunction(props.formatter)
? props.formatter(year, 'year')
: year
years.push({ value: year, label })
}
}
return years
})
const handleYearChange = (year: number) => {
emit(
'date-change',
dayjs(new Date(year, monthValue.value - 1, 1)).locale(lang.value)
)
}
const handleMonthChange = (month: number) => {
emit(
'date-change',
dayjs(new Date(yearValue.value, month - 1, 1)).locale(lang.value)
)
}
const selectToday = () => {
emit('date-change', 'today')
}
</script>

View File

@@ -178,6 +178,14 @@ export const useCalendar = (
}
}
const handleDateChange = (date: Dayjs | 'today') => {
if (date === 'today') {
selectDate('today')
} else {
pickDay(date)
}
}
return {
calculateValidatedDateRange,
date,
@@ -185,5 +193,6 @@ export const useCalendar = (
pickDay,
selectDate,
validatedRange,
handleDateChange,
}
}

View File

@@ -2,3 +2,4 @@ import '@element-plus/components/base/style/css'
import '@element-plus/theme-chalk/el-calendar.css'
import '@element-plus/components/button/style/css'
import '@element-plus/components/button-group/style/css'
import '@element-plus/components/select/style/css'

View File

@@ -2,3 +2,4 @@ import '@element-plus/components/base/style'
import '@element-plus/theme-chalk/src/calendar.scss'
import '@element-plus/components/button/style'
import '@element-plus/components/button-group/style'
import '@element-plus/components/select/style'

View File

@@ -22,6 +22,20 @@
@include e(body) {
padding: 12px 20px 35px;
}
@include e(select-controller) {
.#{$namespace}-select {
margin-right: 8px;
}
.#{$namespace}-calendar-select__year {
width: 120px;
}
.#{$namespace}-calendar-select__month {
width: 60px;
}
}
}
@include b(calendar-table) {