组件详解#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 用深蓝背景白字。
关键技术点
inputmode="none":阻止移动端弹出原生键盘覆盖日历面板- 点击外部关闭:
document.addEventListener('click', handler, true)捕获阶段处理 - 日期比较:都用
new Date(y,m,d)构造,避免时区偏差 min/max属性:HTML原生属性直接读取input.min/input.max- 键盘支持: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">‹</button>
<span class="dp-month-year" id="dpLabel"></span>
<button id="dpNext">›</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
inputmode="none"prevents mobile native keyboard from covering the calendar- Outside-click close via document click listener in capture phase
- Date comparison always use
new Date(y,m,d)constructor to avoid timezone issues - Native
min/maxattributes read directly frominput.min/input.max - Keyboard support: Escape to close, Arrow keys + Enter to select
Common Gotchas
getDay()returns 0 for Sunday — must offset when building the calendar gridnew Date(2026, 7, 0)returns the last day of June (month is 0-based, day 0 = last day of previous month)- Don’t forget
stopPropagationon 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.