Angular Material 18+ 高级教程 – Datepicker の Calendar & Custom DateAdapter (Temporal)

cnblogs 2024-09-14 08:11:01 阅读 94

前言

本篇只会教 Angular Material Datepicker 里最关键的组件 -- Calendar 组件。

还有如何自定义 DateAdapter,让 Calendar 支持TC39 Temporal。

有兴趣想学完整 Datepicker 的朋友们,请移步官网。

只对 Calendar 组件和自定义 DateAdapter 感兴趣的人,留下!

Let's start 🚀。

Angular Material DateAdapter

App 组件

@Component({

selector: 'app-root',

standalone: true,

imports: [MatDatepickerModule], // 导入 Datepicker 模块

templateUrl: './app.component.html',

styleUrl: './app.component.scss',

providers: [],

})

export class AppComponent {}

App Template

<mat-calendar [style.width.px]="256" />

效果

直接报错了,因为我们缺少了 DateAdapter。

Date Library

在讲解 DateAdapter 之前,我们先来聊聊 JavaScript 的 Date 和 Date Library。

JavaScript 使用 Date 来管理日期。

但凡用过 Date 的人都知道它很烂。

这个 Date 是 1995 年仿照 Java 设计出来的。

后来 Java 在 1997 年 1.1 版和 2014 年 8.0 版,对 Date 进行了大量的改进。

而 JavaScript 却一直固步自封,把 1995 年的 Date 沿用到今天...🙈

这就导致了,在真实项目中,Date 根本不够用,我们只能借助第三方 Date Library 来实现需求。

目前比较火的 Date Library 有:

    <li>

    Moment.js(2020 已废弃,当年最火)

  1. Day.js(light weight)

  2. date-fns(Node.js)

  3. Luxon(Moment.js 的接棒者)

四个 Library 都很受欢迎😱。

Why need DateAdapter?

对于开发者来说,有多个 Date Library 选择是一件好事,但对于 framework / library 而言就不好了。

像 Angular Material Datepicker 就需要同时支持所有受欢迎的 Date Library,否则就会出现一堆繁琐的转换操作。(比如:convert Date to Luxon / Moment / Dayjs / date-fns date object)

相同的功能 (interface),却有着不同的实现 (different library),这...这不就是传说中面向对象 23 种设计模式之一的适配器模式吗😱?

Angular Material built-inDateAdapter

Angular Material 替我们做好了不同 Date Library 的 DateAdapter:

  1. moment-adapter

  2. Day.js(开发中...)

  3. date-fns-adapter

  4. luxon-adapter

没想到最火的竟然是废弃了的 moment-adapter🙈...

除了这 3 个 Date Library Adapter,还有一个是 JavaScript 原生 Date 的Adapter --NativeDateAdapter,总共 4 个 DateAdapter 可选。

NativeDateAdapter

回到 App 组件,添加一个 provider

import { Component } from '@angular/core';

import { provideNativeDateAdapter } from '@angular/material/core';

import { MatDatepickerModule } from '@angular/material/datepicker';

@Component({

selector: 'app-root',

standalone: true,

imports: [MatDatepickerModule],

templateUrl: './app.component.html',

styleUrl: './app.component.scss',

providers: [provideNativeDateAdapter()], // 添加 NativeDateAdapter

})

export class AppComponent {}

注:你要放在 app.config.ts 作为全局 provider 也可以。

效果

果然,加了 DateAdapter 就不再报错了🙂

我们再加一个 selected date 给它,看看效果。

export class AppComponent {

readonly selectedDate = signal(new Date('2024-09-14')); // 因为是 NativeDateAdapter,所以这里使用 Date 对象

}

App Template

<mat-calendar [(selected)]="selectedDate" [style.width.px]="256" />

效果

13 号有 border 是因为 13 号是 today (本篇写于 2024-09-13),14 号有 background-color 是因为 14 号是 selected date。

LuxonDateAdapter

我们继续试试另一个 built-in DateAdapter --LuxonDateAdapter。

安装 Luxon

yarn add luxon

yarn add @types/luxon --dev

yarn add @angular/material-luxon-adapter

provider

import { provideLuxonDateAdapter } from '@angular/material-luxon-adapter';

@Component({

providers: [provideLuxonDateAdapter()], // 添加 LuxonDateAdapter

})

export class AppComponent {}

selected date 从 Date 对象改成 DateTime 对象 (Luxon 用的是 DateTime 对象,哎哟,有点像 .NET 哦)

import { DateTime } from 'luxon';

export class AppComponent {

// 因为是 LuxonDateAdapter,所以这里使用 DateTime 对象

readonly selectedDate = signal(DateTime.fromFormat('2024-09-14', 'yyyy-MM-dd'));

}

效果

和NativeDateAdapter 出来的效果一模一样🙂。

Angular Material custom DateAdapter for Temporal

Temporal 是 JavaScript 未来要取代 Date 的日期方案。它目前还在 Stage 3 阶段,没有任何游览器支持,但已经有完整的Polyfill可以使用了。

对他一窍不通的朋友,可以先看这篇JavaScript – Temporal API & Date。

Angular 没有 built-in 的 Temporal DateAdapter,假如我们的项目使用 Temporal,那就需要自己做一个 Temporal DateAdapter。

Let's start 🚀。

Install Temporal

安装 Temporal Polyfill

yarn add temporal-polyfill

Create class TemporalDateAdapter

创建 class TemporalDateAdapter 并继承 abstract classDateAdapter

import { DateAdapter } from '@angular/material/core';

import { Temporal } from 'temporal-polyfill';

export class TemporalDateAdapter extends DateAdapter<Temporal.PlainDate> {}

DateAdapter 需要一个泛型,我们传入Temporal.PlainDate,它仅仅代表日期 (年月日),不包含时间 (时分秒),也不包含时区 (time zone)。

这个 abstract class DateAdapter 里面有很多抽象方法,我们需要一一去实现。

源码在date-adapter.ts

我们可以参考 LuxonDateAdapter (源码luxon-date-adapter.ts) 和 NativeDateAdapter (源码native-date-adapter.ts) 去实现上面这些 abstract methods。

Implement all abstract methods

我们一个一个来实现吧 🚀。

constructor &locale

constructor() {

super();

this.locale = inject(MAT_DATE_LOCALE) as string;

}

locale 负责格式和语言,比如说是要显示 “January" 还是 "Jan" 还是 "一月"。

MAT_DATE_LOCALE 默认会拿 Angular 的LOCALE_ID(这个我没有教过,以后教 i18n 时会补上),AngularLOCALE_ID 的默认值是 "en-US"。

总之,默认是美国英语就对了,想显示中文,我们可以把 LOCALE_ID改成 "zh-Hans-CN" (zh-Hans 代表简体,CN 代表中国)。

app.config.ts

export const appConfig: ApplicationConfig = {

providers: [

provideExperimentalZonelessChangeDetection(),

provideAnimations(),

{

provide: LOCALE_ID, // 设置整个项目的 locale

useValue: 'zh-Hans-CN',

},

{

provide: MAT_DATE_LOCALE, // 只设置 Datepicker 的 locale

useValue: 'zh-Hans-CN',

},

],

};

注:假如MAT_DATE_LOCALE 和LOCALE_ID 一样的话,那只需要设置LOCALE_ID 就可以了。

getYear

override getYear(date: Temporal.PlainDate): number {

return date.year;

}

太简单的,我就不解释了,下一个。

getMonth

override getMonth(date: Temporal.PlainDate): number {

return date.month - 1;

}

Temporal 一月是用 1 来表示,JavaScript Date 一月是用 0 来表示。

Angular Material 选择依据 JS Date 的标准来使用,所以我们需要额外做一个 -1 的动作。

getDate

override getDate(date: Temporal.PlainDate): number {

return date.day;

}

getDayOfWeek

override getDayOfWeek(date: Temporal.PlainDate): number {

return date.dayOfWeek === 7 ? 0 : date.dayOfWeek;

}

Temporal 星期日是用 7 来表示,JavaScript Date 星期日是用 0 来表示。

Angular Material 两个都兼容,返回 7 或者返回 0 都可以用来表示星期日。

getMonthNames

getMonthNames 需要返回一个 string array,从一月到十二月,e.g. ['Jan', 'Feb', ’Mar‘, ...months]。

import { Intl, Temporal } from 'temporal-polyfill';

override getMonthNames(style: 'long' | 'short' | 'narrow'): string[] {

return new Array(12).fill(undefined).map<string>((_, index) => {

const month = index + 1;

const date = Temporal.PlainDate.from({ year: 2017, month, day: 1 });

const formatter = new Intl.DateTimeFormat(this.locale, {

month: style,

});

return formatter.format(date); // long: January, short: Jan, narrow: J

});

}

我们借助Intl.DateTimeFormat 处理 locale 就可以了。

getDateNames

getDateNames 需要返回一个 string array,从 1 到 31,e.g. [1, 2, 3, ...days]。

override getDateNames(): string[] {

return new Array(31).fill(undefined).map<string>((_, index) => {

const day = index + 1;

const date = Temporal.PlainDate.from({ year: 2017, month: 1, day });

const formatter = new Intl.DateTimeFormat(this.locale, {

day: 'numeric',

});

// e.g. zh-Hans-CN: 31日

return formatter.format(date);

});

}

有一个小知识点:Intl.DateTimeFormat 中文会以 '日' 这个字作为结尾。换作是 MomentDateAdapter 的话,它用的是 Moment.format('D'),不会出现 '日' 这个字。

这些微差就看项目的需求咯,要怎样 format 都是我们自定义的。

getDayOfWeekNames

getDayOfWeekNames 需要返回一个 string array,从星期日到星期六,e.g. ['Sun', 'Mon', 'Tue', ...dayOfWeeks]。

override getDayOfWeekNames(style: 'long' | 'short' | 'narrow'): string[] {

return new Array(7).fill(undefined).map<string>((_, index) => {

const day = index + 1;

// 刚巧 2017-01-01 是星期日,dayOfWeek 也从星期日开始

const date = Temporal.PlainDate.from({ year: 2017, month: 1, day });

const formatter = new Intl.DateTimeFormat(this.locale, {

weekday: style,

});

return formatter.format(date); // long: Sunday, short: Sun, narrow: S

});

}

我们借助 Intl.DateTimeFormat 处理 locale 就可以了。

getYearName

override getYearName(date: Temporal.PlainDate): string {

const formatter = new Intl.DateTimeFormat(this.locale, {

year: 'numeric',

});

return formatter.format(date); // e.g. 2017 (注:zh-Hans-CN 也是 2017 而不是 2017年 哦)

}

getFirstDayOfWeek

override getFirstDayOfWeek(): number {

return 0;

}

由于上面getDayOfWeek 是以 0 来表示星期日,所以这里也 follow。

一周的第一天是星期日,所以返回 0。

当然,这个是美国的习惯。马来西亚,中国通常第一天是星期一,那我们可以改成 return 1。

getNumDaysInMonth

返回特定月份里有多少天,比如 28天,29天,30天,或者 31天。

override getNumDaysInMonth(date: Temporal.PlainDate): number {

return date.daysInMonth;

}

clone

克隆一个日期

override clone(date: Temporal.PlainDate): Temporal.PlainDate {

return date.with({});

}

createDate

创建日期对象

override createDate(year: number, month: number, date: number): Temporal.PlainDate {

return Temporal.PlainDate.from({ year, month: month + 1, day: date });

}

today

返回今天

override today(): Temporal.PlainDate {

return Temporal.Now.plainDateISO();

}

addCalendarYears,addCalendarMonths,addCalendarDays

添加年月日

override addCalendarYears(date: Temporal.PlainDate, years: number): Temporal.PlainDate {

return date.add({ years });

}

override addCalendarMonths(date: Temporal.PlainDate, months: number): Temporal.PlainDate {

return date.add({ months });

}

override addCalendarDays(date: Temporal.PlainDate, days: number): Temporal.PlainDate {

return date.add({ days });

}

toIso8601

返回 ISO 8601 日期格式

override toIso8601(date: Temporal.PlainDate): string {

return date.toString(); // 2017-01-01 year-month-day

}

isDateInstance

判断 value 是不是一个日期对象

override isDateInstance(obj: any): boolean {

return obj instanceof Temporal.PlainDate;

}

isValid

判断是不是一个 valid 的日期

override isValid(_date: Temporal.PlainDate): boolean {

return true;

}

JavaScript Date 有一个 Invalid Date 的概念

const date = new Date('whatever');

console.log(date.toString()); // Invalid Date

console.log(date.getTime()); // NaN

意思是 value 虽然是日期对象,但它是 invalid 的。

Temporal 没有这个概念,只要是 Temporal 对象,那它一定就是一个 valid 的日期。

所以isValid 方法直接 return true 就可以了。

invalid

invalid 要返回一个Invalid Date。

override invalid(): Temporal.PlainDate {

throw new Error('Method not implemented.');

}

Temporal 没有Invalid Date 概念,这里我们直接 throw error 就可以了。

format

format 就是把 Temporal convert to date string with specify display format。

override format(date: Temporal.PlainDate, displayFormat: any): string {

const formatter = new Intl.DateTimeFormat(this.locale, {

...displayFormat,

timeZone: 'UTC',

});

return formatter.format(date);

}

参数 displayFormat 也是我们自定义的,透过 provider MAT_DATE_FORMATS,它的默认值是

不同的 DateAdapter 会搭配不同的 MatDateFormats,比如说 Luxon 的 displayFormat 是用 string 来表达的 e.g. yyyy-MM-dd = 2017-01-01 (完成的 format list 看这里)

parse

有 format 自然也有 parse。

override parse(value: any, _parseFormat: any): Temporal.PlainDate | null {

if (typeof value !== 'string') throw new Error('Method not implemented.');

const date = new Date(value);

if (!Number.isNaN(date.getTime())) {

return toTemporalInstant.call(date).toZonedDateTimeISO('UTC').toPlainDate();

}

return null;

}

同样的,参数 parseFormat 也是自定义的,来自 MatDateFormats.parse.dateInput (当 user 使用 input 输入一个 date string 时,date adapter 要把它从 string 转换成日期对象)。

我这里只是写了一个简单的 parse 作为例子,真实项目中 parse 通常会比较复杂。

deserialize

deserialize 和 parse 很类似,但是 deserialize 只用于 parse ISO 8601 string format。

而 parse 则可以处理 whatever string format 甚至是 whatever types.

override deserialize(value: any): Temporal.PlainDate | null {

if (typeof value === 'string') {

if (value === '') return null;

const ISO_8601_REGEX = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|(?:(?:\+|-)\d{2}:\d{2}))?)?$/;

if (ISO_8601_REGEX.test(value)) {

return this.parse(value, undefined);

}

}

return super.deserialize(value);

}

上面这段,我是从 NativeDateAdapter 抄过来的。

总结

以上就是所有需要 implement 的 abstract methods。

parse, format,deserialize 需要依据不同项目需求去做调整,其它的基本上是通用的。

TryTemporalDateAdapter

App 组件

@Component({

selector: 'app-root',

standalone: true,

imports: [MatDatepickerModule, TestAdapterComponent],

templateUrl: './app.component.html',

styleUrl: './app.component.scss',

providers: [

{

provide: MAT_DATE_LOCALE,

useValue: 'zh-Hans-CN',

},

{

provide: DateAdapter,

useClass: TemporalDateAdapter,

},

{

provide: MAT_DATE_FORMATS,

useValue: MAT_NATIVE_DATE_FORMATS,

},

],

})

export class AppComponent {

// 因为是 TemporalDateAdapter,所以这里使用 Temporal.PlainDate 对象

readonly selectedDate = signal(Temporal.PlainDate.from('2024-09-14'));

}

注:provider 也可以放到全局 app.config.ts。

效果

Angular Material Calendar

我们先看看官网给出的第一个 Datepicker 例子

<mat-form-field>

<mat-label>Choose a date</mat-label>

<input matInput [matDatepicker]="picker">

<mat-hint>MM/DD/YYYY</mat-hint>

<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>

<mat-datepicker #picker></mat-datepicker>

</mat-form-field>

这里头涉及了 8 个组件 / 指令:

    <li>

    MatFormField 组件 -- <mat-form-field>

  1. MatLabel 指令 -- <mat-label>
  2. MatInput 指令 --input[matInput]

  3. MatDatepickerInput 指令 --input[matDatepicker]

  4. MatHint 指令 -- <mat-hint>
  5. MatDatepickerToggle 组件 -- <mat-datepicker-toggle>

  6. MatSuffix 指令 -- [matIconSuffix]
  7. MatDatepicker 组件 -- <mat-datepicker>

一上来就这么乱🙁...我们把不相关的通通砍掉。

<input [matDatepicker]="picker">

<mat-datepicker-toggle [for]="picker"></mat-datepicker-toggle>

<mat-datepicker #picker></mat-datepicker>

剩下三个:MatDatepickerInput 指令,MatDatepickerToggle 组件,MatDatepicker 组件。

效果

点击 MatDatepickerToggle 后会 popup MatDatepicker,选择日期后会 update 到MatDatepickerInput。

toggle 和 input 只是配角,主角是 date picker。

而MatDatepicker 的底层便是本篇要讲解的 Calendar 组件。

Calendar 组件

我们在上一 part Custom DateAdapter 里就已经使用 Calendar 组件作为例子了。

<mat-calendar [style.width.px]="300" />

效果

它有一些常见的 @Input / @Output 可以做配置。

selected

readonly selectedDate = signal(Temporal.PlainDate.from('2024-09-14'));

<pre>{{ selectedDate() }}</pre>

<mat-calendar [(selected)]="selectedDate" [style.width.px]="300" />

效果

Calendar 组件没有支持Reactive Forms,它的 selected 就是简单的 @Input @Output 或者说Two-way Binding。

minDate, maxDate,dateFilter

minDate, maxDate 用于限制 Calendar 可选范围。

比如,只能选今天和前后七天的日期。

private readonly today = signal(Temporal.Now.plainDateISO());

readonly minDate = computed(() => this.today().subtract({ days: 7 }));

readonly maxDate = computed(() => this.today().add({ days: 7 }));

<mat-calendar [minDate]="minDate()" [maxDate]="maxDate()" [style.width.px]="300" />

效果

灰色的日期都是 disabled,不可选了。

dateFilter 则是依据日期做判断,是否可选。

readonly dateFilterFn = signal((date: Temporal.PlainDate) => date.dayOfWeek !== 6 && date.dayOfWeek !== 7); // 星期六和星期日以外才可选

<mat-calendar [dateFilter]="dateFilterFn()" [style.width.px]="300" />

效果

startAt,startView

startAt 是说,Calendar 初始画面要显示在哪一天?(默认是今天)

readonly startAt = Temporal.PlainDate.from('2017-01-01'); // 显示 2017年1月1日

<mat-calendar [startAt]="startAt" [style.width.px]="300" />

提醒:startAt 只对初始画面有效哦,后续修改 startAt 日期,Calendar 是不会同步更新的。

效果

startView 则是指初始画面要显示哪一种 view?

Calendar 一共有 3 种 view。

<mat-calendar startView="multi-year" [style.width.px]="300" />

提醒:和 startAt 一样,startView 只对初始画面有效,后续修改 startView 值,Calendar 是不会同步更新的。

效果

dateClass

我们可以给特定的日期添加 class,然后做 styling。

readonly dateClassFn = signal<MatCalendarCellClassFunction<Temporal.PlainDate>>((date, _view) => {

if (date.dayOfWeek === 7 || date.dayOfWeek === 6) return 'weekend';

return '';

});

给周末添加 ’weekend‘ class。

接着 styling

mat-calendar ::ng-deep .weekend {

background-color: pink;

}

需要使用 ::ng-deep 哦,不然渗透不进去。

效果

另外,dateClass 目前有一个 Bug --Github Issue –bug(MatCalendar): updating dateClass does not update calendar view。

当我们修改 dateClass @input 值后,它不会立刻更新 Calendar。对比 dateFilter 则会立刻更新的。

selected DateRange

selected 可以选一个日期,也可以选一个 range。

比如说,从 2024-09-01 到 2024-09-30,选择一整个月份。

注:它只是可以选 range,而不是选 multiple 日期哦。

readonly selectedDateRange = signal(

new DateRange(Temporal.PlainDate.from('2024-09-01'), Temporal.PlainDate.from('2024-09-30')),

);

使用DateRange 对象,参数 1 是 start date,参数 2 是 end date。

<p>start: {{ selectedDate().start }}</p>

<p>end: {{ selectedDate().end }}</p>

<mat-calendar [(selected)]="selectedDateRange" [style.width.px]="300" />

效果

selectedChange

上一个例子,假如我们去交互,它会坏掉。

因为 Calendar 的 two-way binding 不支持 date range。

我们需要动一点手脚。

首先,把 two-way binding 给拆开

<mat-calendar [selected]="selectedDateRange()" (selectedChange)="handleSelectedChange($event)" [style.width.px]="300" />

App 组件

private selectCount = 1;

handleSelectedChange(selectedDate: Temporal.PlainDate | null) {

if (!selectedDate) return;

// 分 2 次操作

// 第一次 select start

// 第二次 select end

// 然后循环

const selectCount = ((this.selectCount - 1) % 2) + 1;

this.selectCount++;

const { selectedDateRange } = this;

if (selectCount === 1) {

// select start

this.selectedDateRange.set(new DateRange(selectedDate, null));

return;

}

if (selectCount === 2) {

const start = selectedDateRange().start!;

// 如果第二次 selectedDate 早于 start 就 re-select

if (Temporal.PlainDate.compare(selectedDate, start) === -1) {

selectedDateRange.set(new DateRange(selectedDate, null));

this.selectCount--;

return;

}

// select end

selectedDateRange.set(new DateRange(start, selectedDate));

return;

}

}

效果

MAT_DATE_RANGE_SELECTION_STRATEGY

MAT_DATE_RANGE_SELECTION_STRATEGY 可以增强交互体验。

App 组件 provider

@Component({

providers: [

{

provide: MAT_DATE_RANGE_SELECTION_STRATEGY,

useClass: DefaultMatCalendarRangeStrategy,

},

],

})

export class AppComponent {}

效果

注意到那个 hover 后出现的 dash border 吗?它叫 preview。

comparisonStart,comparisonEnd

comparison 是对比的意思,通常用于对比 2 个 date range。

比如说,想拿上个星期 2024-09-01 到 2024-09-07 的数据对比这个星期 2024-09-08 到 2024-09-17 的数据。

readonly comparisonStart = signal(Temporal.PlainDate.from('2024-09-08'));

readonly comparisonEnd = signal(Temporal.PlainDate.from('2024-09-14'));

<p>start: {{ selectedDateRange().start }}</p>

<p>end: {{ selectedDateRange().end }}</p>

<p>comparison start: {{ comparisonStart() }}</p>

<p>comparison end: {{ comparisonEnd() }}</p>

<mat-calendar

[selected]="selectedDateRange()"

(selectedChange)="handleSelectedChange($event)"

[comparisonStart]="comparisonStart()"

[comparisonEnd]="comparisonEnd()"

[style.width.px]="300"

/>

注:comparison 分成 2 个 @Input 哦,而且它不是使用 DateRange 对象 (不统一啊🙄)。

效果

2 个 date range 是不同的颜色。

另外,selected date range 的头尾日期会有深色 background-color,而 comparison date range 则没有 (又不统一啊🙄)。

styling

想修改样式就 override 它的 CSS variables。

@use '@angular/material' as mat;

mat-calendar {

@include mat.datepicker-date-range-colors(hotpink, teal, yellow, purple);

/* 等价于 */

--mat-datepicker-calendar-date-in-range-state-background-color: hotpink;

--mat-datepicker-calendar-date-in-comparison-range-state-background-color: teal;

--mat-datepicker-calendar-date-in-overlap-range-state-background-color: yellow;

--mat-datepicker-calendar-date-in-overlap-range-selected-state-background-color: purple;

}

效果

还有很多 variables 可以改,这里我就不一一列出了,用 ChromeDevTools 找到 element 后,看着它的 styles 来改就是了。

selectcomparisonStart and comparisonEnd

从上面两个 "不统一🙄",我们可以猜得出来,comparison 这个功能一定是后来硬加进去 Calendar 的。

而事实也是如此,甚至连 select date range 功能也是后来才硬加进去的,所以 two-way binding 才不 work。

看到 Angular Material 团队有多糟糕了吧...😔

我们看看 Google 自家产品 Google Ads,如何 select comparison date range

接着看看 Angular Material 如何 select comparison date range

没错,它必须分开使用 2 个 Calendar 来做选择,而不像 Google Ads 那样只在一个 Calendar 上做选择。

看到吗?Angular Material 团队的无能对 Google 来说并不会有多大的影响,因为人家有能力自己实现。

但社区就不一定了,只能接受 Angular Material 团队的无能,使用体验较差的实现方式😔。

好,我们勉强来实现这个功能 -- 在同一个 Calendar 里选择 comparison date range。

App 组件

export class AppComponent {

readonly selectedDateRange = signal(new DateRange<Temporal.PlainDate>(null, null));

readonly comparisonDateRange = signal(new DateRange<Temporal.PlainDate>(null, null));

private readonly totalSelectCount = signal(1);

readonly selectCount = computed(() => ((this.totalSelectCount() - 1) % 4) + 1);

handleSelectedChange(selectedDate: Temporal.PlainDate | null) {

if (!selectedDate) return;

const { selectedDateRange, comparisonDateRange, totalSelectCount } = this;

const currSelectCount = this.selectCount();

totalSelectCount.set(totalSelectCount() + 1);

// 分 4 次操作

// select start > end > comparison start > comparison end,然后一直循环

if (currSelectCount === 1) {

// select start

selectedDateRange.set(new DateRange(selectedDate, null));

return;

}

if (currSelectCount === 2) {

const start = selectedDateRange().start!;

const isPrevDate = Temporal.PlainDate.compare(selectedDate, start) === -1;

if (isPrevDate) {

// re-select due to selected date is early than start

selectedDateRange.set(new DateRange(selectedDate, null));

totalSelectCount.set(totalSelectCount() - 1);

return;

}

// select end

selectedDateRange.set(new DateRange(start, selectedDate));

return;

}

if (currSelectCount === 3) {

// 这里做了一个偷龙转风

// 因为 Calendar 只能监听 selected,不能监听 comparison 的选择

comparisonDateRange.set(new DateRange(selectedDateRange().start, selectedDateRange().end));

selectedDateRange.set(new DateRange(selectedDate, null));

return;

}

if (currSelectCount === 4) {

const startDate = selectedDateRange().start!;

const isPrevDate = Temporal.PlainDate.compare(selectedDate, startDate) === -1;

if (isPrevDate) {

// re-select due to selected date is early than start

selectedDateRange.set(new DateRange(selectedDate, null));

totalSelectCount.set(totalSelectCount() - 1);

return;

}

// 最后再偷龙转风多一次,让它回到正确位置

selectedDateRange.set(new DateRange(comparisonDateRange().start, comparisonDateRange().end));

comparisonDateRange.set(new DateRange(startDate, selectedDate));

return;

}

}

}

最特别的地方就是第三次 select 的时候做了一个偷龙转风,不完美,但勉强可以用,真的没招了😔。

App Template

<p>start: {{ selectedDateRange().start }}</p>

<p>end: {{ selectedDateRange().end }}</p>

<p>comparison start: {{ comparisonDateRange().start }}</p>

<p>comparison end: {{ comparisonDateRange().end }}</p>

<mat-calendar

[selected]="selectedDateRange()"

(selectedChange)="handleSelectedChange($event)"

[comparisonStart]="comparisonDateRange().start"

[comparisonEnd]="comparisonDateRange().end"

[style.width.px]="300"

[class]="'select-count-' + selectCount()"

/>

最特别的地方是加入了一个 class:select-count-n

App Styles

由于我们上面做了偷龙转风,所以 styling 也需要配合,不然颜色会跑掉。

@use '@angular/material' as mat;

$color-theme: mat.define-theme(

(

color: (

theme-type: light,

primary: mat.$blue-palette,

tertiary: mat.$orange-palette,

),

)

);

mat-calendar {

--primary: #{mat.get-theme-color($color-theme, 'primary')};

--on-primary: #{mat.get-theme-color($color-theme, 'on-primary')};

--primary-light: #{mat.get-theme-color($color-theme, 'primary-container')};

--on-primary-light: #{mat.get-theme-color($color-theme, 'on-primary-container')};

--tertiary: #{mat.get-theme-color($color-theme, 'tertiary')};

--on-tertiary: #{mat.get-theme-color($color-theme, 'on-tertiary')};

--tertiary-light: #{mat.get-theme-color($color-theme, 'tertiary-container')};

--on-tertiary-light: #{mat.get-theme-color($color-theme, 'on-tertiary-container')};

&.select-count-2 ::ng-deep {

/* select start */

--mat-datepicker-calendar-date-selected-state-background-color: var(--primary);

--mat-datepicker-calendar-date-selected-state-text-color: var(--on-primary);

/* select preview */

--mat-datepicker-calendar-date-preview-state-outline-color: var(--primary);

}

&.select-count-3 ::ng-deep {

/* select start & end */

--mat-datepicker-calendar-date-selected-state-background-color: var(--primary);

--mat-datepicker-calendar-date-selected-state-text-color: var(--on-primary);

/* select range */

--mat-datepicker-calendar-date-in-range-state-background-color: var(--primary-light);

.mat-calendar-body-in-range {

--mat-datepicker-calendar-date-text-color: var(--on-primary-light);

}

}

&.select-count-4 ::ng-deep {

/* select start & end */

.mat-calendar-body-comparison-start .mat-calendar-body-cell-content,

.mat-calendar-body-comparison-end .mat-calendar-body-cell-content {

background-color: var(--primary) !important;

--mat-datepicker-calendar-date-text-color: var(--on-primary);

}

/* select range */

--mat-datepicker-calendar-date-in-comparison-range-state-background-color: var(--primary-light);

.mat-calendar-body-in-comparison-range {

--mat-datepicker-calendar-date-text-color: var(--on-primary-light);

}

/* comparison start */

--mat-datepicker-calendar-date-selected-state-background-color: var(--tertiary);

--mat-datepicker-calendar-date-selected-state-text-color: var(--on-tertiary);

/* comparison range preview */

--mat-datepicker-calendar-date-preview-state-outline-color: var(--tertiary);

}

&.select-count-1 ::ng-deep {

/* select start & end */

--mat-datepicker-calendar-date-selected-state-background-color: var(--primary);

--mat-datepicker-calendar-date-selected-state-text-color: var(--on-primary);

/* select range */

--mat-datepicker-calendar-date-in-range-state-background-color: var(--primary-light);

.mat-calendar-body-in-range {

--mat-datepicker-calendar-date-text-color: var(--on-primary-light);

}

/* comparison start & end */

.mat-calendar-body-comparison-start .mat-calendar-body-cell-content,

.mat-calendar-body-comparison-end .mat-calendar-body-cell-content {

background-color: var(--tertiary) !important;

--mat-datepicker-calendar-date-text-color: var(--on-tertiary);

}

/* comparison range */

--mat-datepicker-calendar-date-in-comparison-range-state-background-color: var(--tertiary-light);

.mat-calendar-body-in-comparison-range {

--mat-datepicker-calendar-date-text-color: var(--on-tertiary-light);

}

}

}

看上去好像很多代码,但其实都是类似重复的,之所以没有封装是因为一个一个看,copy paste 会更好管理。

最终效果

总结

Calendar 就介绍到这里。顺便说一说上面提到的MatDatepickerInput 指令,MatDatepickerToggle 组件,MatDatepicker 组件。

MatDatepicker 组件主要是封装了 Calendar 组件,并且让它变成一个Popover,底层技术就是CDK Overlay。

MatDatepickerToggle 组件就只是一个简单的 trigger,负责 popupMatDatepicker。

MatDatepickerInput 指令则是链接 Calendar 的 selected 日期,链接的时候会利用 DateAdapter 做 parse 和 format。

还有一个叫MatDateRangeInput 组件 <mat-date-range-input> 负责处理 date range 的情况。

对这些上层组件 / 指令感兴趣的朋友可以去逛一逛源码。我就不奉陪了🤪。

总结

本篇主要介绍了 Angular Material Datepicker 模块当中的 Calendar 组件,还有如何自定义DateAdapter。

希望以后有时间能再写一篇介绍其它相关的组件 / 指令。掰掰👋

目录

上一篇Angular Material 18+ 高级教程 – Material Form Field

下一篇 TODO

想查看目录,请移步Angular 18+ 高级教程 – 目录

喜欢请点推荐👍,若发现教程内容以新版脱节请评论通知我。happy coding😊💻



声明

本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。