feat: add calendar (#305)

* feat: add calendar

* chore: update doc

* chore: update

* chore: update
This commit is contained in:
zazzaz
2020-09-16 14:49:47 +08:00
committed by GitHub
parent b01a6f4e81
commit 8791908da7
12 changed files with 571 additions and 31 deletions

View File

@@ -0,0 +1,89 @@
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import Calendar from '../src/index.vue'
const _mount = (template: string, data?, otherObj?) => mount({
components: {
'el-calendar': Calendar,
},
template,
data,
...otherObj,
})
describe('Calendar.vue', () => {
it('create', async() => {
const wrapper = _mount(`
<el-calendar v-model="value"></el-calendar>
`, () => ({ value: new Date('2019-04-01') }))
const titleEl = wrapper.find('.el-calendar__title')
expect(/2019.*April/.test((titleEl.element as HTMLElement).innerHTML)).toBeTruthy()
expect(wrapper.element.querySelectorAll('thead th').length).toBe(7)
const rows = wrapper.element.querySelectorAll('.el-calendar-table__row')
expect(rows.length).toBe(6);
(rows[5].firstElementChild as HTMLElement).click()
await nextTick()
expect(/2019.*May/.test((titleEl.element as HTMLElement).innerHTML)).toBeTruthy()
const vm = wrapper.vm as any
const date = vm.value
expect(date.getFullYear()).toBe(2019)
expect(date.getMonth()).toBe(4)
expect((wrapper.find('.is-selected span').element as HTMLElement).innerHTML).toBe('5')
})
it('range', () => {
const wrapper = _mount(`
<el-calendar :range="[new Date(2019, 2, 4), new Date(2019, 2, 24)]"></el-calendar>
`)
const titleEl = wrapper.find('.el-calendar__title')
expect(/2019.*March/.test((titleEl.element as HTMLElement).innerHTML)).toBeTruthy()
const rows = wrapper.element.querySelectorAll('.el-calendar-table__row')
expect(rows.length).toBe(4)
expect(wrapper.element.querySelector('.el-calendar__button-group')).toBeNull()
})
it('range tow monthes', async() => {
const wrapper = _mount(`
<el-calendar :range="[new Date(2019, 3, 14), new Date(2019, 4, 18)]"></el-calendar>
`)
const titleEl = wrapper.find('.el-calendar__title')
expect(/2019.*April/.test((titleEl.element as HTMLElement).innerHTML)).toBeTruthy()
const dateTables = wrapper.element.querySelectorAll('.el-calendar-table.is-range')
expect(dateTables.length).toBe(2)
const rows = wrapper.element.querySelectorAll('.el-calendar-table__row')
expect(rows.length).toBe(5)
const cell = rows[rows.length - 1].firstElementChild;
(cell as HTMLElement).click()
await nextTick()
expect(/2019.*May/.test((titleEl.element as HTMLElement).innerHTML)).toBeTruthy()
expect(cell.classList.contains('is-selected')).toBeTruthy()
})
it('firstDayOfWeek', async () => {
// default en locale, weekStart 0 Sunday
const wrapper = _mount(`
<el-calendar v-model="value"></el-calendar>
`, () => ({ value: new Date('2019-04-01') }))
const head = wrapper.element.querySelector('.el-calendar-table thead')
expect((head.firstElementChild as HTMLElement).innerHTML).toBe('Sun')
expect((head.lastElementChild as HTMLElement).innerHTML).toBe('Sat')
const firstRow = wrapper.element.querySelector('.el-calendar-table__row')
expect((firstRow.firstElementChild as HTMLElement).innerHTML).toContain('31')
expect((firstRow.lastElementChild as HTMLElement).innerHTML).toContain('6')
})
it('firstDayOfWeek in range mode', async () => {
const wrapper = _mount(`
<el-calendar v-model="value" :first-day-of-week="7" :range="[new Date(2019, 1, 3), new Date(2019, 2, 23)]"></el-calendar>
`, () => ({ value: new Date('2019-03-04') }))
const head = wrapper.element.querySelector('.el-calendar-table thead')
expect((head.firstElementChild as HTMLElement).innerHTML).toBe('Sun')
expect((head.lastElementChild as HTMLElement).innerHTML).toBe('Sat')
const firstRow = wrapper.element.querySelector('.el-calendar-table__row')
expect((firstRow.firstElementChild as HTMLElement).innerHTML).toContain('3')
expect((firstRow.lastElementChild as HTMLElement).innerHTML).toContain('9')
})
})

View File

@@ -0,0 +1,5 @@
import { App } from 'vue'
import Calendar from './src/index.vue'
export default (app: App): void => {
app.component(Calendar.name, Calendar)
}

View File

@@ -0,0 +1,12 @@
{
"name": "@element-plus/calendar",
"version": "0.0.0",
"main": "dist/index.js",
"license": "MIT",
"peerDependencies": {
"vue": "^3.0.0-rc.9"
},
"devDependencies": {
"@vue/test-utils": "^2.0.0-beta.3"
}
}

View File

@@ -0,0 +1,207 @@
<template>
<table
:class="{
'el-calendar-table': true,
'is-range': isInRange
}"
cellspacing="0"
cellpadding="0"
>
<thead v-if="!hideHeader">
<th v-for="day in weekDays" :key="day">{{ day }}</th>
</thead>
<tbody>
<tr
v-for="(row, index) in rows"
:key="index"
:class="{
'el-calendar-table__row': true,
'el-calendar-table__row--hide-border': index === 0 && hideHeader
}"
>
<td
v-for="(cell, key) in row"
:key="key"
:class="getCellClass(cell)"
@click="pickDay(cell)"
>
<div class="el-calendar-day">
<slot
name="dateCell"
:data="getSlotData(cell)"
>
<span>{{ cell.text }}</span>
</slot>
</div>
</td>
</tr>
</tbody>
</table>
</template>
<script lang="ts">
import {
computed,
defineComponent,
ref,
PropType,
} from 'vue'
import dayjs, { Dayjs } from 'dayjs'
import localeData from 'dayjs/plugin/localeData'
dayjs.extend(localeData)
const rangeArr = n => {
return Array.from(Array(n).keys())
}
export const getPrevMonthLastDays = (date: Dayjs, amount) => {
const lastDay = date.subtract(1, 'month').endOf('month').date()
return rangeArr(amount).map((_, index) => lastDay - (amount - index - 1))
}
export const getMonthDays = (date: Dayjs) => {
const days = date.daysInMonth()
return rangeArr(days).map((_, index) => index + 1)
}
export default defineComponent({
props: {
selectedDay: {
type: Dayjs,
},
range: {
type: Array as PropType<Array<Dayjs>>,
},
date: {
type: Dayjs as PropType<Dayjs>,
},
hideHeader: {
type: Boolean,
},
},
emits: ['pick'],
setup(props, ctx) {
const WEEK_DAYS = ref(dayjs().localeData().weekdaysShort())
const now = dayjs()
// todo better way to get Day.js locale object
const firstDayOfWeek = (now as any).$locale().weekStart || 0
const toNestedArr = days => {
return rangeArr(days.length / 7).map((_, index) => {
const start = index * 7
return days.slice(start, start + 7)
})
}
const getFormateDate = (day, type): Dayjs => {
let result
if (type === 'prev') {
result = props.date.startOf('month').subtract(1, 'month').date(day)
} else if (type === 'next') {
result = props.date.startOf('month').add(1, 'month').date(day)
} else {
result = props.date.date(day)
}
return result
}
const getCellClass = ({ text, type }) => {
const classes = [type]
if (type === 'current') {
const date_ = getFormateDate(text, type)
if (date_.isSame(props.selectedDay, 'day')) {
classes.push('is-selected')
}
if (date_.isSame(now, 'day')) {
classes.push('is-today')
}
}
return classes
}
const pickDay = ({ text, type }) => {
const date = getFormateDate(text, type)
ctx.emit('pick', date)
}
const getSlotData = ({ text, type }) => {
const day = getFormateDate(text, type)
return {
isSelected: day.isSame(props.selectedDay),
type: `${type}-month`,
day: day.format('YYYY-MM-DD'),
date: day.toDate(),
}
}
const isInRange = computed(() => {
return props.range && props.range.length
})
const rows = computed(() => {
let days = []
if (isInRange.value) {
const [start, end] = props.range
const currentMonthRange = rangeArr(
end.date() - start.date() + 1,
).map((_, index) => ({
text: start.date() + index,
type: 'current',
}))
let remaining = currentMonthRange.length % 7
remaining = remaining === 0 ? 0 : 7 - remaining
const nextMonthRange = rangeArr(remaining).map((_, index) => ({
text: index + 1,
type: 'next',
}))
days = currentMonthRange.concat(nextMonthRange)
} else {
const firstDay = props.date.startOf('month').day() || 7
const prevMonthDays = getPrevMonthLastDays(
props.date,
firstDay - firstDayOfWeek,
).map(day => ({
text: day,
type: 'prev',
}))
const currentMonthDays = getMonthDays(props.date).map(day => ({
text: day,
type: 'current',
}))
days = [...prevMonthDays, ...currentMonthDays]
const nextMonthDays = rangeArr(42 - days.length).map((_, index) => ({
text: index + 1,
type: 'next',
}))
days = days.concat(nextMonthDays)
}
return toNestedArr(days)
})
const weekDays = computed(() => {
const start = firstDayOfWeek
if (start === 0) {
return WEEK_DAYS.value
} else {
return WEEK_DAYS.value
.slice(start)
.concat(WEEK_DAYS.value.slice(0, start))
}
})
return {
isInRange,
weekDays,
rows,
getCellClass,
pickDay,
getSlotData,
}
},
})
</script>

View File

@@ -0,0 +1,222 @@
<template>
<div class="el-calendar">
<div class="el-calendar__header">
<div class="el-calendar__title">{{ i18nDate }}</div>
<div v-if="validatedRange.length === 0" class="el-calendar__button-group">
<el-button-group>
<el-button
size="mini"
@click="selectDate('prev-month')"
>
{{ t('el.datepicker.prevMonth') }}
</el-button>
<el-button size="mini" @click="selectDate('today')">
{{
t('el.datepicker.today')
}}
</el-button>
<el-button
size="mini"
@click="selectDate('next-month')"
>
{{ t('el.datepicker.nextMonth') }}
</el-button>
</el-button-group>
</div>
</div>
<div v-if="validatedRange.length === 0" class="el-calendar__body">
<date-table
:date="date"
:selected-day="realSelectedDay"
@pick="pickDay"
>
<template v-if="$slots.dateCell" #dateCell="data">
<slot name="dateCell" v-bind="data"></slot>
</template>
</date-table>
</div>
<div v-else class="el-calendar__body">
<date-table
v-for="(range_, index) in validatedRange"
:key="index"
:date="range_[0]"
:selected-day="realSelectedDay"
:range="range_"
:hide-header="index !== 0"
@pick="pickDay"
>
<template v-if="$slots.dateCell" #dateCell="data">
<slot name="dateCell" v-bind="data"></slot>
</template>
</date-table>
</div>
</div>
</template>
<script lang="ts">
import { t } from '@element-plus/locale'
import ElButton from '@element-plus/button/src/button.vue'
import ElButtonGroup from '@element-plus/button/src/button-group.vue'
import DateTable from './date-table.vue'
import {
ref,
ComputedRef,
PropType,
computed,
defineComponent,
} from 'vue'
import dayjs, { Dayjs } from 'dayjs'
export default defineComponent({
name: 'ElCalendar',
components: {
DateTable,
ElButton,
ElButtonGroup,
},
props: {
modelValue: {
type: Date,
},
range: {
type: Array as PropType<Array<Date>>,
validator: (range: Date): boolean => {
if (Array.isArray(range)) {
return (
range.length === 2 &&
range.every(
item => item instanceof Date,
)
)
}
return false
},
},
},
emits: ['input', 'update:modelValue'],
setup(props, ctx) {
const selectedDay = ref(null)
const now = dayjs()
const prevMonthDayjs = computed(() => {
return date.value.subtract(1, 'month')
})
const curMonthDatePrefix = computed(() => {
return dayjs(date.value).format('YYYY-MM')
})
const nextMonthDayjs = computed(() => {
return date.value.add(1, 'month')
})
const i18nDate = computed(() => {
const pickedMonth = `el.datepicker.month${date.value.format('M')}`
return `${date.value.year()} ${t('el.datepicker.year')} ${t(pickedMonth)}`
})
const realSelectedDay = computed({
get() {
if (!props.modelValue) return selectedDay.value
return date.value
},
set(val: Dayjs) {
selectedDay.value = val
const result = val.toDate()
ctx.emit('input', result)
ctx.emit('update:modelValue', result)
},
})
const date: ComputedRef<Dayjs> = computed(() => {
if (!props.modelValue) {
if (realSelectedDay.value) {
return realSelectedDay.value
} else if (validatedRange.value.length) {
return validatedRange.value[0][0]
}
return now
} else {
return dayjs(props.modelValue)
}
})
// if range is valid, we get a two-digit array
const validatedRange = computed(() => {
if (!props.range) return []
const rangeArrDayjs = props.range.map(_ => dayjs(_))
const [startDayjs, endDayjs] = rangeArrDayjs
if (startDayjs.isAfter(endDayjs)) {
console.warn(
'[ElementCalendar]end time should be greater than start time',
)
return []
}
if (startDayjs.isSame(endDayjs, 'month')) {
// same month
return [[
startDayjs.startOf('week'),
endDayjs.endOf('week'),
]]
} else {
// two months
if (startDayjs.add(1, 'month').month() !== endDayjs.month()) {
console.warn(
'[ElementCalendar]start time and end time interval must not exceed two months',
)
return []
}
const endMonthFirstDay = endDayjs.startOf('month')
const endMonthFirstWeekDay = endMonthFirstDay.startOf('week')
let endMonthStart = endMonthFirstDay
if (!endMonthFirstDay.isSame(endMonthFirstWeekDay, 'month')) {
endMonthStart = endMonthFirstDay.endOf('week').add(1, 'day')
}
return [
[
startDayjs.startOf('week'),
startDayjs.endOf('month'),
],
[
endMonthStart,
endDayjs.endOf('week'),
],
]
}
})
const pickDay = (day: Dayjs) => {
realSelectedDay.value = day
}
const selectDate = type => {
let day: Dayjs
if (type === 'prev-month') {
day = prevMonthDayjs.value
} else if (type === 'next-month') {
day = nextMonthDayjs.value
} else {
day = now
}
if (day.isSame(date.value, 'day')) return
pickDay(day)
}
return {
selectedDay,
curMonthDatePrefix,
i18nDate,
realSelectedDay,
date,
validatedRange,
pickDay,
selectDate,
t,
}
},
})
</script>

View File

@@ -34,8 +34,10 @@ import ElSlider from '@element-plus/slider'
import ElInput from '@element-plus/input'
import ElTransfer from '@element-plus/transfer'
import ElDialog from '@element-plus/dialog'
import ElCalendar from '@element-plus/calendar'
import ElInfiniteScroll from '@element-plus/infinite-scroll'
export {
ElAlert,
ElAvatar,
@@ -71,6 +73,7 @@ export {
ElInput,
ElTransfer,
ElDialog,
ElCalendar,
ElInfiniteScroll,
}
@@ -110,6 +113,7 @@ const install = (app: App): void => {
ElInput(app)
ElTransfer(app)
ElDialog(app)
ElCalendar(app)
ElInfiniteScroll(app)
}

View File

@@ -93,18 +93,20 @@
{{ displayedLang }}
<i class="el-icon-arrow-down el-icon--right"></i>
</span>
<el-dropdown-menu
class="nav-dropdown-list"
@input="handleLangDropdownToggle"
>
<el-dropdown-item
v-for="(value, key) in langs"
:key="key"
@click="switchLang(key)"
<template #dropdown>
<el-dropdown-menu
class="nav-dropdown-list"
@input="handleLangDropdownToggle"
>
{{ value }}
</el-dropdown-item>
</el-dropdown-menu>
<el-dropdown-item
v-for="(value, key) in langs"
:key="key"
@click="switchLang(key)"
>
{{ value }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</li>
</ul>

View File

@@ -26,12 +26,11 @@ Display date.
:::demo Customize what is displayed in the calendar cell by setting `scoped-slot` named `dateCell`. 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.
```html
<el-calendar>
<!-- Use 2.5 slot syntax. If you use Vue 2.6, please use new slot syntax-->
<template
slot="dateCell"
slot-scope="{date, data}">
#dateCell="{data}"
>
<p :class="data.isSelected ? 'is-selected' : ''">
{{ data.day.split('-').slice(1).join('-') }} {{ data.isSelected ? '✔️' : ''}}
{{ data.day.split('-').slice(1).join('-') }} {{ data.isSelected ? '✔️' : '' }}
</p>
</template>
</el-calendar>
@@ -47,7 +46,7 @@ Display date.
:::demo Set the `range` attribute to specify the display range of the calendar. Start time must be Monday, end time must be Sunday, and the time span cannot exceed two months.
```html
<el-calendar :range="['2019-03-04', '2019-03-24']">
<el-calendar :range="[new Date(2019, 2, 4), new Date(2019, 2, 24)]">
</el-calendar>
```
:::

View File

@@ -27,12 +27,11 @@ Muestra fechas.
```html
<el-calendar>
<!-- Use 2.5 slot syntax. If you use Vue 2.6, please use new slot syntax-->
<template
slot="dateCell"
slot-scope="{date, data}">
#dateCell="{data}"
>
<p :class="data.isSelected ? 'is-selected' : ''">
{{ data.day.split('-').slice(1).join('-') }} {{ data.isSelected ? '✔️' : ''}}
{{ data.day.split('-').slice(1).join('-') }} {{ data.isSelected ? '✔️' : '' }}
</p>
</template>
</el-calendar>
@@ -48,7 +47,7 @@ Muestra fechas.
:::demo Defina el atributo `range` para especificar el rango de visualización del calendario. El tiempo de inicio debe ser el lunes, el tiempo de finalización debe ser el domingo y el período no puede exceder los dos meses.
```html
<el-calendar :range="['2019-03-04', '2019-03-24']">
<el-calendar :range="[new Date(2019, 2, 4), new Date(2019, 2, 24)]">
</el-calendar>
```
:::

View File

@@ -26,12 +26,11 @@ Affiche un calendrier.
:::demo Personnalisez le contenu du calendrier en utilisant le `scoped-slot` appelé `dateCell`. Dans ce `scoped-slot` vous aurez accès au paramètres date (date de la cellule courante), data (incluant les attributs type, isSelected et day). Pour plus d'informations, référez-vous à la documentation ci-dessous.
```html
<el-calendar>
<!-- Use 2.5 slot syntax. If you use Vue 2.6, please use new slot syntax-->
<template
slot="dateCell"
slot-scope="{date, data}">
#dateCell="{data}"
>
<p :class="data.isSelected ? 'is-selected' : ''">
{{ data.day.split('-').slice(1).join('-') }} {{ data.isSelected ? '✔️' : ''}}
{{ data.day.split('-').slice(1).join('-') }} {{ data.isSelected ? '✔️' : '' }}
</p>
</template>
</el-calendar>
@@ -47,7 +46,7 @@ Affiche un calendrier.
:::demo Utilisez l'attribut `range` pour afficher un intervalle particulier. Le début doit être un lundi et la fin un dimanche, l'intervalle ne pouvant excéder deux mois.
```html
<el-calendar :range="['2019-03-04', '2019-03-24']">
<el-calendar :range="[new Date(2019, 2, 4), new Date(2019, 2, 24)]">
</el-calendar>
```
:::

View File

@@ -26,12 +26,11 @@
:::demo 通过设置名为 `dateCell``scoped-slot` 来自定义日历单元格中显示的内容。在 `scoped-slot` 可以获取到 date当前单元格的日期, data包括 typeisSelectedday 属性)。详情解释参考下方的 API 文档。
```html
<el-calendar>
<!-- 这里使用的是 2.5 slot 语法,对于新项目请使用 2.6 slot 语法-->
<template
slot="dateCell"
slot-scope="{date, data}">
#dateCell="{data}"
>
<p :class="data.isSelected ? 'is-selected' : ''">
{{ data.day.split('-').slice(1).join('-') }} {{ data.isSelected ? '✔️' : ''}}
{{ data.day.split('-').slice(1).join('-') }} {{ data.isSelected ? '✔️' : '' }}
</p>
</template>
</el-calendar>
@@ -47,7 +46,7 @@
:::demo 设置 `range` 属性指定日历的显示范围。开始时间必须是周起始日,结束时间必须是周结束日,且时间跨度不能超过两个月。
```html
<el-calendar :range="['2019-03-04', '2019-03-24']">
<el-calendar :range="[new Date(2019, 2, 4), new Date(2019, 2, 24)]">
</el-calendar>
```
:::

View File

@@ -12,6 +12,9 @@ import './demo-styles/index.scss'
import './assets/styles/common.css'
import './assets/styles/fonts/style.css'
import icon from './icon.json'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
dayjs.locale('zh-cn') // todo: locale based on Doc site lang
import App from './app.vue'
import ElementPlus from 'element-plus'