mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-23 13:02:21 +08:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
@@ -0,0 +1,625 @@
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
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 { clamp, 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 _maxYear = 2099;
|
||||
|
||||
private _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`<button
|
||||
tabindex=${cell.tabIndex ?? -1}
|
||||
aria-label=${dateRaw}
|
||||
data-date=${dateRaw}
|
||||
class=${classes}
|
||||
@click=${() => {
|
||||
this._onChange(cell.date);
|
||||
}}
|
||||
>
|
||||
${cell.label}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
private _dateContent() {
|
||||
return html` <div class="date-picker-header">
|
||||
<div class="date-picker-header__buttons">
|
||||
<button
|
||||
class="date-picker-header__date interactive"
|
||||
@click=${() => this.toggleMonthSelector()}
|
||||
>
|
||||
<div>${this.monthLabel}</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="date-picker-header__date interactive"
|
||||
@click=${() => this.toggleYearSelector()}
|
||||
>
|
||||
<div>${this.yearLabel}</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${this._navAction(
|
||||
() => this._moveMonth(-1),
|
||||
() => this._moveMonth(1),
|
||||
html`<button
|
||||
tabindex="0"
|
||||
aria-label="today"
|
||||
class="action-label interactive today"
|
||||
@click=${() => {
|
||||
this._onChange(new Date());
|
||||
}}
|
||||
>
|
||||
<span>TODAY</span>
|
||||
</button>`
|
||||
)}
|
||||
</div>
|
||||
${this._dayHeaderRenderer()}
|
||||
<div class="date-picker-weeks">
|
||||
${this._matrix.map(
|
||||
week =>
|
||||
html`<div class="date-picker-week">
|
||||
${week.map(cell => this._cellRenderer(cell))}
|
||||
</div>`
|
||||
)}
|
||||
</div>
|
||||
${this.onClear
|
||||
? html`<div class="date-picker-footer">
|
||||
<button
|
||||
tabindex="0"
|
||||
aria-label="clear"
|
||||
class="footer-button interactive"
|
||||
@click=${() => this.onClear?.()}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>`
|
||||
: nothing}`;
|
||||
}
|
||||
|
||||
/** Week header */
|
||||
private _dayHeaderRenderer() {
|
||||
return html`<div class="days-header">
|
||||
${days.map(day => html`<div class="date-cell">${day}</div>`)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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` <div class="date-picker-header">
|
||||
<button
|
||||
class="date-picker-header__date interactive"
|
||||
@click=${() => this.toggleMonthSelector()}
|
||||
>
|
||||
<div>${this._monthPickYearCursor}</div>
|
||||
</button>
|
||||
|
||||
${this._navAction(
|
||||
{
|
||||
action: () => this._monthPickYearCursor--,
|
||||
disable: this._monthPickYearCursor <= this._minYear,
|
||||
},
|
||||
{
|
||||
action: () => this._monthPickYearCursor++,
|
||||
disable: this._monthPickYearCursor >= this._maxYear,
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<div class="date-picker-month">
|
||||
${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`<button
|
||||
tabindex=${this._monthCursor === index ? 0 : -1}
|
||||
aria-label=${month}
|
||||
class=${classes}
|
||||
@click=${() => {
|
||||
this._cursor.setMonth(index);
|
||||
this._cursor.setFullYear(this._monthPickYearCursor);
|
||||
this._mode = 'date';
|
||||
this._getMatrix();
|
||||
}}
|
||||
>
|
||||
${month}
|
||||
</button>`;
|
||||
})}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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`<div class=${classes}>
|
||||
<button
|
||||
aria-label="previous month"
|
||||
class="date-picker-small-action interactive left"
|
||||
@click=${onPrev}
|
||||
?disabled=${prevDisable}
|
||||
>
|
||||
${arrowLeftIcon}
|
||||
</button>
|
||||
${slot ?? nothing}
|
||||
<button
|
||||
aria-label="next month"
|
||||
class="date-picker-small-action interactive right"
|
||||
@click=${onNext}
|
||||
?disabled=${nextDisable}
|
||||
>
|
||||
${arrowLeftIcon}
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _onChange(date: Date, emit = true) {
|
||||
this._cursor = date;
|
||||
this.value = date.getTime();
|
||||
this._getMatrix();
|
||||
emit && this.onChange?.(date);
|
||||
}
|
||||
|
||||
private _switchMode<T>(map: Record<typeof this._mode, T>) {
|
||||
return (map[this._mode] as T) ?? nothing;
|
||||
}
|
||||
|
||||
private _yearContent() {
|
||||
const startYear = this._yearMatrix[0];
|
||||
const endYear = this._yearMatrix[this._yearMatrix.length - 1];
|
||||
return html`<div class="date-picker-header">
|
||||
<button
|
||||
class="date-picker-header__date interactive"
|
||||
@click=${() => this.toggleYearSelector()}
|
||||
>
|
||||
<div>${startYear}-${endYear}</div>
|
||||
</button>
|
||||
${this._navAction(
|
||||
{
|
||||
action: () => this._modeDecade(-12),
|
||||
disable: startYear <= this._minYear,
|
||||
},
|
||||
{
|
||||
action: () => this._modeDecade(12),
|
||||
disable: endYear >= this._maxYear,
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<div class="date-picker-year">
|
||||
${this._yearMatrix.map(year => {
|
||||
const isActive = year === this._cursor.getFullYear();
|
||||
const classes = classMap({
|
||||
'year-cell': true,
|
||||
interactive: true,
|
||||
active: isActive,
|
||||
});
|
||||
return html`<button
|
||||
tabindex=${this._yearCursor === year ? 0 : -1}
|
||||
aria-label=${year}
|
||||
class=${classes}
|
||||
@click=${() => {
|
||||
this._cursor.setFullYear(year);
|
||||
this._mode = 'date';
|
||||
this._getMatrix();
|
||||
}}
|
||||
>
|
||||
${year}
|
||||
</button>`;
|
||||
})}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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._minYear, this._maxYear, this.year);
|
||||
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`<div style=${wrapperStyle} class="date-picker-height-wrapper">
|
||||
<div class=${classes} style=${styleMap(this._cardStyle)}>
|
||||
${this._switchMode({
|
||||
date: this._dateContent(),
|
||||
month: this._monthContent(),
|
||||
year: this._yearContent(),
|
||||
})}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user