You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在Angular Material中实现类似react-multi-date的多日期范围选择器

Angular 实现多日期范围选择器(对标react-multi-date-picker核心功能)

核心功能拆解

先明确要实现的核心能力,和目标组件对齐:

  • 支持多选单个日期
  • 支持选择连续日期范围(点击起始+结束)
  • 标记已选日期、范围首尾及中间日期
  • 自定义日期格式
  • 禁用指定日期
  • 切换年月的日历导航

实现步骤与关键代码

1. 组件基础结构

创建MultiDateRangePickerComponent,维护日历状态、已选日期集合,处理日期生成与交互逻辑。

组件逻辑(.ts)

import { Component, Input, Output, EventEmitter } from '@angular/core';
import { DatePipe } from '@angular/common';

// 定义已选日期类型,区分单个日期和范围首尾
interface SelectedDate {
  date: Date;
  isRangeStart?: boolean;
  isRangeEnd?: boolean;
}

@Component({
  selector: 'app-multi-date-range-picker',
  templateUrl: './multi-date-range-picker.component.html',
  styleUrls: ['./multi-date-range-picker.component.css'],
  providers: [DatePipe]
})
export class MultiDateRangePickerComponent {
  @Input() disabledDates: Date[] = [];
  @Input() dateFormat: string = 'yyyy-MM-dd';
  @Output() datesSelected = new EventEmitter<SelectedDate[]>();

  selectedDates: SelectedDate[] = [];
  currentMonth: Date = new Date();
  calendarDates: Date[] = [];
  rangeStart: Date | null = null;

  constructor(private datePipe: DatePipe) {
    this.generateCalendar();
  }

  // 生成当前月的日历日期(包含前后月的补位日期)
  generateCalendar() {
    const year = this.currentMonth.getFullYear();
    const month = this.currentMonth.getMonth();
    const firstDay = new Date(year, month, 1);
    const lastDay = new Date(year, month + 1, 0);
    const startDate = new Date(firstDay);
    startDate.setDate(startDate.getDate() - firstDay.getDay());

    this.calendarDates = [];
    let currentDate = new Date(startDate);

    while (currentDate <= lastDay || currentDate.getDay() !== 0) {
      this.calendarDates.push(new Date(currentDate));
      currentDate.setDate(currentDate.getDate() + 1);
    }
  }

  // 日期点击事件处理
  onDateClick(date: Date, event: MouseEvent) {
    const isCtrlPressed = event.ctrlKey || event.metaKey;
    const formattedDate = this.datePipe.transform(date, this.dateFormat);
    const existingIndex = this.selectedDates.findIndex(d => 
      this.datePipe.transform(d.date, this.dateFormat) === formattedDate
    );

    if (existingIndex !== -1) {
      // 取消已选日期
      this.selectedDates.splice(existingIndex, 1);
      if (this.rangeStart?.getTime() === date.getTime()) this.rangeStart = null;
    } else {
      if (isCtrlPressed) {
        // 按住Ctrl/Command添加单个日期
        this.selectedDates.push({ date: new Date(date) });
      } else {
        if (!this.rangeStart) {
          // 设置范围起始
          this.rangeStart = new Date(date);
          this.selectedDates.push({ date: new Date(date), isRangeStart: true });
        } else {
          // 完成范围选择,补全中间日期
          const start = new Date(Math.min(this.rangeStart.getTime(), date.getTime()));
          const end = new Date(Math.max(this.rangeStart.getTime(), date.getTime()));
          
          // 移除临时的范围起始标记
          this.selectedDates = this.selectedDates.filter(d => !d.isRangeStart);
          // 添加首尾标记
          this.selectedDates.push({ date: start, isRangeStart: true });
          this.selectedDates.push({ date: end, isRangeEnd: true });
          // 补全中间日期(可选,根据UI需求决定是否标记)
          let current = new Date(start);
          while (current < end) {
            current.setDate(current.getDate() + 1);
            if (current < end) {
              this.selectedDates.push({ date: new Date(current) });
            }
          }
          this.rangeStart = null;
        }
      }
    }

    this.datesSelected.emit([...this.selectedDates]);
  }

  // 辅助判断方法
  isDateSelected(date: Date): boolean {
    const formattedDate = this.datePipe.transform(date, this.dateFormat);
    return this.selectedDates.some(d => 
      this.datePipe.transform(d.date, this.dateFormat) === formattedDate
    );
  }

  isRangeStart(date: Date): boolean {
    const formattedDate = this.datePipe.transform(date, this.dateFormat);
    return this.selectedDates.some(d => 
      d.isRangeStart && this.datePipe.transform(d.date, this.dateFormat) === formattedDate
    );
  }

  isRangeEnd(date: Date): boolean {
    const formattedDate = this.datePipe.transform(date, this.dateFormat);
    return this.selectedDates.some(d => 
      d.isRangeEnd && this.datePipe.transform(d.date, this.dateFormat) === formattedDate
    );
  }

  isDateDisabled(date: Date): boolean {
    const formattedDate = this.datePipe.transform(date, this.dateFormat);
    return this.disabledDates.some(d => 
      this.datePipe.transform(d, this.dateFormat) === formattedDate
    );
  }

  // 切换年月
  prevMonth() {
    this.currentMonth.setMonth(this.currentMonth.getMonth() - 1);
    this.generateCalendar();
  }

  nextMonth() {
    this.currentMonth.setMonth(this.currentMonth.getMonth() + 1);
    this.generateCalendar();
  }
}

模板(.html)

<div class="multi-date-picker">
  <div class="calendar-header">
    <button (click)="prevMonth()">&lt;</button>
    <h3>{{ currentMonth | date:'yyyy年MM月' }}</h3>
    <button (click)="nextMonth()">&gt;</button>
  </div>
  <div class="calendar-weekdays">
    <div class="weekday">日</div>
    <div class="weekday">一</div>
    <div class="weekday">二</div>
    <div class="weekday">三</div>
    <div class="weekday">四</div>
    <div class="weekday">五</div>
    <div class="weekday">六</div>
  </div>
  <div class="calendar-dates">
    <div 
      *ngFor="let date of calendarDates"
      class="calendar-date"
      [class.disabled]="isDateDisabled(date)"
      [class.selected]="isDateSelected(date)"
      [class.range-start]="isRangeStart(date)"
      [class.range-end]="isRangeEnd(date)"
      (click)="onDateClick(date, $event)"
    >
      {{ date | date:'d' }}
    </div>
  </div>
  <div class="selected-dates">
    <span *ngFor="let date of selectedDates" class="date-tag">
      {{ date.date | date:dateFormat }}
      <button (click)="selectedDates.splice(selectedDates.indexOf(date), 1)">×</button>
    </span>
  </div>
</div>

样式(.css)

.multi-date-picker {
  font-family: system-ui, sans-serif;
  border: 1px solid #e0e0e0;
  border-radius: 6px;
  padding: 12px;
  max-width: 320px;
}

.calendar-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 12px;
}

.calendar-header button {
  background: transparent;
  border: none;
  cursor: pointer;
  font-size: 18px;
  padding: 4px 8px;
  border-radius: 4px;
  transition: background 0.2s;
}

.calendar-header button:hover {
  background: #f5f5f5;
}

.calendar-weekdays {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  gap: 6px;
  margin-bottom: 8px;
}

.weekday {
  text-align: center;
  font-weight: 500;
  font-size: 14px;
  color: #666;
}

.calendar-dates {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  gap: 6px;
}

.calendar-date {
  text-align: center;
  padding: 8px 0;
  border-radius: 6px;
  cursor: pointer;
  font-size: 14px;
  transition: background 0.2s;
}

.calendar-date.disabled {
  color: #ccc;
  cursor: not-allowed;
}

.calendar-date.selected {
  background-color: #e3f2fd;
}

.calendar-date.range-start {
  background-color: #2196f3;
  color: #fff;
  border-top-left-radius: 50%;
  border-bottom-left-radius: 50%;
}

.calendar-date.range-end {
  background-color: #2196f3;
  color: #fff;
  border-top-right-radius: 50%;
  border-bottom-right-radius: 50%;
}

.selected-dates {
  margin-top: 12px;
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

.date-tag {
  background-color: #f5f5f5;
  padding: 4px 8px;
  border-radius: 4px;
  font-size: 13px;
  display: flex;
  align-items: center;
  gap: 4px;
}

.date-tag button {
  background: transparent;
  border: none;
  cursor: pointer;
  font-size: 14px;
  color: #666;
  padding: 0;
}

优化与扩展建议

  • 拖拽选择范围:添加mousedown、mousemove、mouseup事件监听,实现拖拽选择连续日期的交互。
  • 性能优化:当日历数据量大时,使用cdk-virtual-scroll-viewport实现虚拟滚动,减少DOM节点数量。
  • 时区处理:统一使用UTC日期处理,避免本地时区导致的日期显示偏差。
  • 配置化扩展:增加输入属性支持最小/最大日期限制、自定义选中样式、日历语言切换等。
  • 表单集成:实现ControlValueAccessor,将组件集成到Angular表单体系中,支持双向绑定。

内容的提问来源于stack exchange,提问作者Alf Moh

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.23 06:37:07