import { clamp } from '@blocksuite/global/gfx'; import { WithDisposable } from '@blocksuite/global/lit'; import { isSameDay, isSameMonth, isToday } from 'date-fns'; import { html, LitElement, nothing, type PropertyValues, type TemplateResult, } from 'lit'; import { property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { styleMap } from 'lit/directives/style-map.js'; import { arrowLeftIcon } from './icons.js'; import { datePickerStyle } from './style.js'; import { getMonthMatrix, toDate } from './utils.js'; const days = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; const months = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ]; export interface DateCell { date: Date; label: string; isToday: boolean; notCurrentMonth: boolean; selected?: boolean; tabIndex?: number; } type NavActionArg = { action: () => void; disable?: boolean; }; /** * Date picker */ export class DatePicker extends WithDisposable(LitElement) { static override styles = datePickerStyle; /** current active month */ private _cursor = new Date(); private readonly _maxYear = 2099; private readonly _minYear = 1970; get _cardStyle() { return { '--cell-size': `${this.size}px`, '--gap-h': `${this.gapH}px`, '--gap-v': `${this.gapV}px`, 'min-width': `${this.cardWidth}px`, 'min-height': `${this.cardHeight}px`, padding: `${this.padding}px`, }; } get cardHeight() { const rowNum = 7; return this.size * rowNum + this.padding * 2 + this.gapV * (rowNum - 1) - 2; } get cardWidth() { const colNum = 7; return this.size * colNum + this.padding * 2 + this.gapH * (colNum - 1); } get date() { return this._cursor.getDate(); } get day() { return this._cursor.getDay(); } get dayLabel() { return days[this.day]; } get minHeight() { const rowNum = this._matrix.length; return this.size * rowNum + this.padding * 2 + this.gapV * (rowNum - 1) - 2; } get month() { return this._cursor.getMonth(); } get monthLabel() { return months[this.month]; } get year() { return this._cursor.getFullYear(); } get yearLabel() { return this.year; } /** Cell */ private _cellRenderer(cell: DateCell) { const classes = classMap({ interactive: true, 'date-cell': true, 'date-cell--today': cell.isToday, 'date-cell--not-curr-month': cell.notCurrentMonth, 'date-cell--selected': !!cell.selected, }); const dateRaw = `${cell.date.getFullYear()}-${cell.date.getMonth()}-${cell.date.getDate()}(${cell.date.getDay()})`; return html``; } private _dateContent() { return html`
${this._navAction( () => this._moveMonth(-1), () => this._moveMonth(1), html`` )}
${this._dayHeaderRenderer()}
${this._matrix.map( week => html`
${week.map(cell => this._cellRenderer(cell))}
` )}
${this.onClear ? html`` : nothing}`; } /** Week header */ private _dayHeaderRenderer() { return html`
${days.map(day => html`
${day}
`)}
`; } private _getMatrix() { this._matrix = getMonthMatrix(this._cursor).map(row => { return row.map(date => { const tabIndex = isSameDay(date, this._cursor) ? 0 : -1; return { date, label: date.getDate().toString(), isToday: isToday(date), notCurrentMonth: !isSameMonth(date, this._cursor), selected: this.value ? isSameDay(date, toDate(this.value)) : false, tabIndex, } satisfies DateCell; }); }); } private _getYearMatrix() { // every decade has 12 years const no = Math.floor((this._yearCursor - this._minYear) / 12); const decade = no * 12; const start = this._minYear + decade; const end = start + 12; this._yearMatrix = Array.from( { length: end - start }, (_, i) => start + i ).filter(v => v >= this._minYear && v <= this._maxYear); } private _modeDecade(offset: number) { this._yearCursor = clamp( this._minYear, this._maxYear, this._yearCursor + offset ); this._getYearMatrix(); } private _monthContent() { return html`
${this._navAction( { action: () => this._monthPickYearCursor--, disable: this._monthPickYearCursor <= this._minYear, }, { action: () => this._monthPickYearCursor++, disable: this._monthPickYearCursor >= this._maxYear, } )}
${months.map((month, index) => { const isActive = this.value ? isSameMonth( this.value, new Date(this._monthPickYearCursor, index, 1) ) : false; const classes = classMap({ 'month-cell': true, interactive: true, active: isActive, }); return html``; })}
`; } private _moveMonth(offset: number) { this._cursor.setMonth(this._cursor.getMonth() + offset); this._getMatrix(); } /** Actions */ private _navAction( prev: NavActionArg | NavActionArg['action'], curr: NavActionArg | NavActionArg['action'], slot?: TemplateResult ) { const onPrev = typeof prev === 'function' ? prev : prev.action; const onNext = typeof curr === 'function' ? curr : curr.action; const prevDisable = typeof prev === 'function' ? false : prev.disable; const nextDisable = typeof curr === 'function' ? false : curr.disable; const classes = classMap({ 'date-picker-header__action': true, 'with-slot': !!slot, }); return html`
${slot ?? nothing}
`; } private _onChange(date: Date, emit = true) { this._cursor = date; this.value = date.getTime(); this._getMatrix(); emit && this.onChange?.(date); } private _switchMode(map: Record) { return (map[this._mode] as T) ?? nothing; } private _yearContent() { const startYear = this._yearMatrix[0]; const endYear = this._yearMatrix[this._yearMatrix.length - 1]; return html`
${this._navAction( { action: () => this._modeDecade(-12), disable: startYear <= this._minYear, }, { action: () => this._modeDecade(12), disable: endYear >= this._maxYear, } )}
${this._yearMatrix.map(year => { const isActive = year === this._cursor.getFullYear(); const classes = classMap({ 'year-cell': true, interactive: true, active: isActive, }); return html``; })}
`; } closeMonthSelector() { this._mode = 'date'; } closeYearSelector() { this._mode = 'date'; } override connectedCallback(): void { super.connectedCallback(); if (this.value) this._cursor = toDate(this.value); this._getMatrix(); } override firstUpdated(): void { this._disposables.addFromEvent( this, 'keydown', e => { e.stopPropagation(); const directions = new Set([ 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', ]); if (directions.has(e.key) && this.isDateCellFocused()) { e.preventDefault(); if (e.key === 'ArrowLeft') { this._cursor.setDate(this._cursor.getDate() - 1); } else if (e.key === 'ArrowRight') { this._cursor.setDate(this._cursor.getDate() + 1); } else if (e.key === 'ArrowUp') { this._cursor.setDate(this._cursor.getDate() - 7); } else if (e.key === 'ArrowDown') { this._cursor.setDate(this._cursor.getDate() + 7); } this._getMatrix(); setTimeout(this.focusDateCell.bind(this)); } if (directions.has(e.key) && this.isMonthCellFocused()) { e.preventDefault(); if (e.key === 'ArrowLeft') { this._monthCursor = (this._monthCursor - 1 + 12) % 12; } else if (e.key === 'ArrowRight') { this._monthCursor = (this._monthCursor + 1) % 12; } else if (e.key === 'ArrowUp') { this._monthCursor = (this._monthCursor - 3 + 12) % 12; } else if (e.key === 'ArrowDown') { this._monthCursor = (this._monthCursor + 3) % 12; } setTimeout(this.focusMonthCell.bind(this)); } if (directions.has(e.key) && this.isYearCellFocused()) { e.preventDefault(); if (e.key === 'ArrowLeft') { this._modeDecade(-1); } else if (e.key === 'ArrowRight') { this._modeDecade(1); } else if (e.key === 'ArrowUp') { this._modeDecade(-3); } else if (e.key === 'ArrowDown') { this._modeDecade(3); } setTimeout(this.focusYearCell.bind(this)); } if (e.key === 'Tab') { setTimeout(() => { const focused = this.shadowRoot?.activeElement as HTMLElement; const firstEl = this.shadowRoot?.querySelector('button'); // check if focus the last element, then focus the first element if (!e.shiftKey && !focused) firstEl?.focus(); // check if focused element is inside current date-picker if (e.shiftKey && !this.shadowRoot?.contains(focused)) this.focusDateCell(); }); } if (e.key === 'Escape') { this.onEscape?.(toDate(this.value)); } }, true ); } /** * Focus on date-cell */ focusDateCell() { const lastEl = this.shadowRoot?.querySelector( 'button.date-cell[tabindex="0"]' ) as HTMLElement; lastEl?.focus(); } focusMonthCell() { const lastEl = this.shadowRoot?.querySelector( 'button.month-cell[tabindex="0"]' ) as HTMLElement; lastEl?.focus(); } focusYearCell() { const lastEl = this.shadowRoot?.querySelector( 'button.year-cell[tabindex="0"]' ) as HTMLElement; lastEl?.focus(); } /** * check if date-cell is focused * @returns */ isDateCellFocused() { const focused = this.shadowRoot?.activeElement as HTMLElement; return focused?.classList.contains('date-cell'); } isMonthCellFocused() { const focused = this.shadowRoot?.activeElement as HTMLElement; return focused?.classList.contains('month-cell'); } isYearCellFocused() { const focused = this.shadowRoot?.activeElement as HTMLElement; return focused?.classList.contains('year-cell'); } openMonthSelector() { this._monthCursor = this.month; this._monthPickYearCursor = this.year; this._mode = 'month'; } openYearSelector() { this._yearCursor = clamp(this.year, this._minYear, this._maxYear); this._mode = 'year'; this._getYearMatrix(); } override render() { const classes = classMap({ 'date-picker': true, [`date-picker--mode-${this._mode}`]: true, popup: this.popup, }); const wrapperStyle = styleMap({ 'min-height': `${this.minHeight}px`, }); return html`
${this._switchMode({ date: this._dateContent(), month: this._monthContent(), year: this._yearContent(), })}
`; } toggleMonthSelector() { if (this._mode === 'month') this.closeMonthSelector(); else this.openMonthSelector(); } toggleYearSelector() { if (this._mode === 'year') this.closeYearSelector(); else this.openYearSelector(); } override updated(_changedProperties: PropertyValues): void { if (_changedProperties.has('value')) { // this._getMatrix(); if (this.value) this._onChange(toDate(this.value), false); else this._getMatrix(); } } /** date matrix */ @property({ attribute: false }) private accessor _matrix: DateCell[][] = []; @property({ attribute: false }) private accessor _mode: 'date' | 'month' | 'year' = 'date'; /** web-accessibility for month select */ @property({ attribute: false }) private accessor _monthCursor = 0; @property({ attribute: false }) private accessor _monthPickYearCursor = 0; @property({ attribute: false }) private accessor _yearCursor = 0; @property({ attribute: false }) private accessor _yearMatrix: number[] = []; /** horizontal gap between cells in px */ @property({ type: Number }) accessor gapH = 10; /** vertical gap between cells in px */ @property({ type: Number }) accessor gapV = 8; @property({ attribute: false }) accessor onChange: ((value: Date) => void) | undefined = undefined; @property({ attribute: false }) accessor onClear: (() => void) | undefined = undefined; @property({ attribute: false }) accessor onEscape: ((value: Date) => void) | undefined = undefined; /** card padding in px */ @property({ type: Number }) accessor padding = 20; @property({ type: Boolean }) accessor popup: boolean = false; /** cell size in px */ @property({ type: Number }) accessor size = 28; /** Checked date timestamp */ @property({ type: Number }) accessor value: number | undefined = undefined; } declare global { interface HTMLElementTagNameMap { 'date-picker': DatePicker; } }