组件详解#41:日期选择器,自己写一个比引入datepicker库更可控 | Component Deep Dive #41: Date Picker — Build Your Own, It's More Controllable Than Any Library

组件详解#41:日期选择器,自己写一个比引入datepicker库更可控

产品介绍:Web Component Dictionary v2.0 收录83个手写UI组件,无需任何框架。完整源码+实时预览,已在 Payhip 上架。文末有获取方式。

为什么需要手写日期选择器?

引入第三方日期选择器通常会带来三个问题:体积大(动辄几十KB)、样式难覆盖(shadow DOM 嵌套)、国际化定制受限。

而手写一个基础版日期选择器其实只需要约200行代码,包含月历网格生成、月份切换、日期选中、最小/最大日期限制。

效果预览

点击输入框弹出月历面板。核心功能:月份前后切换、当前日期高亮、选中日期标记、不可选日期灰色显示。支持 min / max 属性限制范围。

代码拆解

日历网格生成是最关键的部分:

function generateCalendar(year, month) {
  const firstDay = new Date(year, month, 1).getDay(); // 当月1号是周几
  const daysInMonth = new Date(year, month + 1, 0).getDate(); // 当月总天数
  const daysInPrevMonth = new Date(year, month, 0).getDate(); // 上月总天数
  const cells = [];

  // 填充上月尾部日期(灰色不可选)
  for (let i = firstDay - 1; i >= 0; i--) {
    cells.push({ day: daysInPrevMonth - i, month: 'prev', disabled: true });
  }
  // 填充当月日期
  const today = new Date();
  for (let d = 1; d <= daysInMonth; d++) {
    const date = new Date(year, month, d);
    cells.push({ day: d, month: 'current', date, disabled: isOutOfRange(date), isToday: isSameDay(date, today) });
  }
  // 填充下月头部日期(补满6行42格)
  const remaining = 42 - cells.length;
  for (let d = 1; d <= remaining; d++) {
    cells.push({ day: d, month: 'next', disabled: true });
  }
  return cells;
}

CSS 核心:月历用 CSS Grid 7列布局,周日到周六表头固定,.today 用浅蓝背景,.selected 用深蓝背景白字。

关键技术点

  1. inputmode="none":阻止移动端弹出原生键盘覆盖日历面板
  2. 点击外部关闭document.addEventListener('click', handler, true) 捕获阶段处理
  3. 日期比较:都用 new Date(y,m,d) 构造,避免时区偏差
  4. min / max 属性:HTML原生属性直接读取 input.min / input.max
  5. 键盘支持:Escape关闭、方向键+Enter选择日期

常见坑点

  • getDay() 返回 0=周日:构建日历时必须偏移处理,否则每月1号放错位置
  • new Date(2026, 7, 0) 返回7月最后一天:月份是0-based,7,0 = 6月30日
  • 忘记 stopPropagation:点击日历内部时事件冒泡到document触发关闭

完整可复制代码

<!DOCTYPE html>
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<style>
.datepicker-wrap { position:relative; display:inline-block; }
.datepicker-wrap input { width:200px; height:38px; padding:0 12px; border:1px solid #d1d5db; border-radius:6px; font-size:14px; }
.dp-panel { display:none; position:absolute; top:44px; left:0; z-index:100; width:280px; background:#fff; border:1px solid #e5e7eb; border-radius:8px; box-shadow:0 4px 16px rgba(0,0,0,.12); padding:12px; }
.dp-panel.open { display:block; }
.dp-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:10px; }
.dp-header button { width:28px; height:28px; border:none; background:#f3f4f6; border-radius:4px; cursor:pointer; font-size:14px; }
.dp-header .dp-month-year { font-weight:600; font-size:14px; }
.dp-weekdays { display:grid; grid-template-columns:repeat(7,1fr); text-align:center; font-size:12px; color:#9ca3af; margin-bottom:6px; }
.dp-grid { display:grid; grid-template-columns:repeat(7,1fr); gap:2px; }
.dp-day { aspect-ratio:1; display:flex; align-items:center; justify-content:center; font-size:13px; border-radius:6px; cursor:pointer; border:none; background:none; color:#374151; }
.dp-day:hover:not(.disabled):not(.empty) { background:#f3f4f6; }
.dp-day.today { background:#dbeafe; color:#1d4ed8; font-weight:600; }
.dp-day.selected { background:#2563eb; color:#fff; }
.dp-day.disabled { color:#d1d5db; cursor:not-allowed; }
.dp-day.empty { cursor:default; }
</style></head><body>
<div class="datepicker-wrap" id="dpWrap">
  <input type="text" id="dpInput" placeholder="选择日期" inputmode="none" readonly>
  <div class="dp-panel" id="dpPanel">
    <div class="dp-header">
      <button id="dpPrev">&lsaquo;</button>
      <span class="dp-month-year" id="dpLabel"></span>
      <button id="dpNext">&rsaquo;</button>
    </div>
    <div class="dp-weekdays"><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
    <div class="dp-grid" id="dpGrid"></div>
  </div>
</div>
<script>
const MONTHS = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
const input = document.getElementById('dpInput');
const panel = document.getElementById('dpPanel');
let viewYear, viewMonth, selectedDate = null;

function isSameDay(a,b){ return a&&b && a.getFullYear()===b.getFullYear() && a.getMonth()===b.getMonth() && a.getDate()===b.getDate(); }
function fmt(d){ return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); }

function render(){
  document.getElementById('dpLabel').textContent = viewYear+''+MONTHS[viewMonth];
  const firstDay = new Date(viewYear, viewMonth, 1).getDay();
  const daysInMonth = new Date(viewYear, viewMonth+1, 0).getDate();
  const daysInPrev = new Date(viewYear, viewMonth, 0).getDate();
  const today = new Date();
  const cells = [];
  for(let i=firstDay-1; i>=0; i--) cells.push({day:daysInPrev-i, cls:'disabled'});
  for(let d=1; d<=daysInMonth; d++){
    const dt = new Date(viewYear, viewMonth, d);
    let cls = '';
    if(isSameDay(dt, today)) cls += ' today';
    if(selectedDate && isSameDay(dt, selectedDate)) cls += ' selected';
    cells.push({day:d, cls, date:dt});
  }
  while(cells.length < 42) cells.push({day:'', cls:'empty'});
  document.getElementById('dpGrid').innerHTML = cells.map(c=>{
    if(c.cls==='empty') return '<div class="dp-day empty"></div>';
    return `<button class="dp-day${c.cls?' '+c.cls:''}" data-date="${fmt(c.date)}">${c.day}</button>`;
  }).join('');
}

input.addEventListener('click', e => {
  e.stopPropagation();
  if(!panel.classList.contains('open')){
    const now = new Date();
    viewYear = now.getFullYear(); viewMonth = now.getMonth();
  }
  panel.classList.toggle('open'); render();
});

document.getElementById('dpPrev').addEventListener('click', e => {
  e.stopPropagation();
  if(viewMonth===0){ viewMonth=11; viewYear--; } else viewMonth--;
  render();
});

document.getElementById('dpNext').addEventListener('click', e => {
  e.stopPropagation();
  if(viewMonth===11){ viewMonth=0; viewYear++; } else viewMonth++;
  render();
});

document.getElementById('dpGrid').addEventListener('click', e => {
  const btn = e.target.closest('button[data-date]');
  if(!btn) return;
  const parts = btn.dataset.date.split('-');
  selectedDate = new Date(+parts[0], +parts[1]-1, +parts[2]);
  input.value = btn.dataset.date;
  panel.classList.remove('open');
});

document.addEventListener('click', () => panel.classList.remove('open'), true);

const today = new Date();
viewYear = today.getFullYear(); viewMonth = today.getMonth();
input.value = fmt(today); selectedDate = today;
</script>
</body></html>

变体拓展

  • 日期范围选择器:两个输入框 + 两个高亮标记,hover 时显示范围预览
  • 时间选择器融合:面板下方加入时/分滚轮
  • 多选日期:用 Set 存储已选日期,支持批量标记(如排班表)

Web Component Dictionary v2.0 收录83个手写UI组件。全部零框架,完整可运行代码。详情见博客首页。


Component Deep Dive #41: Date Picker — Build Your Own, It’s More Controllable Than Any Library

Why Hand-Code a Date Picker?

Third-party date pickers come with three common headaches: large bundle size (dozens of KB), hard-to-override styles (shadow DOM nesting), and limited i18n flexibility. A basic date picker requires only about 200 lines of vanilla JS, covering calendar grid generation, month navigation, date selection, and min/max constraints.

The Calendar Grid Algorithm

function generateCalendar(year, month) {
  const firstDay = new Date(year, month, 1).getDay();
  const daysInMonth = new Date(year, month + 1, 0).getDate();
  const daysInPrevMonth = new Date(year, month, 0).getDate();
  // Fill previous month tail, current month, next month head
  // Always 42 cells (6 rows x 7 columns)
}

Key Technical Points

  1. inputmode="none" prevents mobile native keyboard from covering the calendar
  2. Outside-click close via document click listener in capture phase
  3. Date comparison always use new Date(y,m,d) constructor to avoid timezone issues
  4. Native min/max attributes read directly from input.min / input.max
  5. Keyboard support: Escape to close, Arrow keys + Enter to select

Common Gotchas

  • getDay() returns 0 for Sunday — must offset when building the calendar grid
  • new Date(2026, 7, 0) returns the last day of June (month is 0-based, day 0 = last day of previous month)
  • Don’t forget stopPropagation on panel clicks, or they’ll bubble to document and close immediately

The full working code with complete demo is included above.

Web Component Dictionary v2.0 — 83 hand-coded UI components. Zero frameworks. Available on Payhip.



← 返回首页