Calendar
Calendarは、日付の表示や選択ができるコンポーネントです。
<script lang="ts">
import Calendar from '$lib/components/ui/modules/Calendar.svelte';
</script>
<Calendar />
プロパティ
Calendarは、以下のプロパティをサポートしています。
| 名前 | 型 | デフォルト値 | 説明 |
|---|---|---|---|
value |
string |
'' |
日付(YYYY-MM-DD)を渡すことができます。 |
min |
string |
0001-01-01 |
選択できる最小値を設定できます。 |
max |
string |
9999-12-31 |
選択できる最大値を設定できます。 |
start |
string |
'' |
範囲選択の開始日を指定できます。 |
end |
string |
'' |
範囲選択の終了日を指定できます。 |
months |
number |
1 |
表示する月数を指定できます。 |
enableRange |
boolean |
false |
範囲選択が可能になります。 |
onChange |
(value: string) => void |
日付が選択されたときのコールバック関数です。 | |
onRangeChange |
(value: CalendarRange) => void |
日付が範囲選択されたときのコールバック関数です。 | |
autoFocusDate |
boolean |
false |
日付に自動でフォーカスします。 |
CalendarDate
CalendarDateは、1日分の情報を表すオブジェクトです。
| 名前 | 型 | デフォルト値 | 説明 |
|---|---|---|---|
value |
number |
日付(数値)です。 | |
outOfMonth |
boolean |
表示範囲外の月かどうか。 | |
disabled |
boolean |
選択できるかどうか。 | |
formatDate |
string |
フォーマット済みの日付文字列です。 |
CalendarRange
CalendarRangeは、範囲選択された日付の情報を表すオブジェクトです。
| 名前 | 型 | デフォルト値 | 説明 |
|---|---|---|---|
start |
string |
'' |
開始日です。 |
end |
string |
'' |
終了日です。 |
インストールの手順
以下のコンポーネントのコードを、使いたいプロジェクトにコピー&ペーストします。
パスは実際のプロジェクトの構成にあわせて更新します。
modules/Calendar.svelte
<!--
@component
## 概要
- Calendar は、日付の表示や選択ができるコンポーネントです
## 機能
- 使用側から値(YYYY-MM-DD)を渡すと該当する日付が選択される
- 選択した日付を渡せる
## Props
- value: 指定の日付を渡すことができます
- min: 最小値を指定すると、最小の範囲を設定できます
- max: 最大値を指定すると、最大の範囲を設定できます
- start: 範囲選択の最小値を指定できます
- end: 範囲選択の最大値を指定できます
- enableRange: 範囲選択ができます
- onChange: 日付が選択されたときのコールバック関数
- onRangeChange: 日付が範囲選択されたときのコールバック関数
- autoFocusDate: 日付に自動でフォーカスします
## Usage
```svelte
<Calendar />
```
-->
<script module lang="ts">
import type { ClassValue } from 'svelte/elements';
import { cva, type VariantProps } from 'class-variance-authority';
const MIN_DATE = '0001-01-01';
const MAX_DATE = '9999-12-31';
const DISPLAY_MONTH_NUM = 12;
const DISPLAY_YEAR_NUM = 12;
const MONTHS_PER_ROW = 3;
const YEARS_PER_ROW = 3;
const WEEK_DAYS = ['日', '月', '火', '水', '木', '金', '土'];
export const calendarVariants = cva('grid min-w-69.5 gap-3 p-3 bg-base-container-default border border-base-stroke-default rounded-md shadow-md origin-top', {
variants: {
/** 操作できるかどうか */
disabled: {
true: ['opacity-50 pointer-events-none'],
false: [],
},
},
});
export const calendarArrowButtonVariants = cva('p-1.5 bg-base-container-default rounded-md text-base-foreground-muted outline-primary transition cursor-pointer hover:bg-base-container-accent/90 focus-visible:rounded-xs hover:text-base-foreground-accent focus-visible:outline-[0.125rem] focus-visible:outline-offset-[0.125rem]', {
variants: {
/** 操作できるかどうか */
disabled: {
true: ['opacity-50 text-base-foreground-muted pointer-events-none'],
false: [],
},
},
});
export const calendarMonthYearButtonVariants = cva('px-0.5 py-1.25 rounded-md font-medium leading-none outline-primary transition cursor-pointer hover:bg-base-container-accent/90', {
variants: {
/** 操作できるかどうか */
disabled: {
true: ['opacity-50 text-base-foreground-muted pointer-events-none'],
false: [],
},
},
});
export const calendarDayOfWeekVariants = cva('place-content-center px-3 py-0.75 font-normal leading-tight text-base-foreground-muted text-xs');
export const calendarDateVariants = cva('relative z-0 place-content-center w-9 h-10 text-base-foreground-accent text-sm overflow-visible mx-auto cursor-pointer focus-visible:before:absolute hover:before:absolute focus-visible:before:inset-x-0 hover:before:inset-x-0 focus-visible:before:inset-y-0.5 hover:before:inset-y-0.5 focus-visible:z-10 focus-visible:before:content-[""] hover:before:content-[""] focus-visible:before:size-9 hover:before:size-9 hover:before:bg-base-container-accent/90 focus-visible:before:rounded-md focus-visible:before:outline-[0.125rem] focus-visible:outline-none focus-visible:before:outline-offset-[0.125rem] focus-visible:before:outline-primary hover:before:outline-primary hover:before:transition-colors hover:before:!-z-10 focus-visible:before:outline', {
variants: {
/** 選択されているかどうか */
select: {
true: [
'text-base-foreground-on-fill-bright after:absolute after:inset-x-0 after:inset-y-0.5 after:content-[""] after:size-9 after:bg-primary hover:after:bg-primary/90 after:rounded-md after:transition-colors hover:after:-z-10 after:-z-20',
],
false: ['hover:before:rounded-md'],
},
/** 現在の日付かどうか */
current: {
true: [
'after:absolute after:inset-x-0 after:inset-y-0.5 after:content-[""] after:size-9 after:border-2 after:border-base-stroke-default after:!rounded-md after:-z-10',
],
false: [],
},
/** 選択されている月じゃないかどうか */
outOfMonth: {
true: ['opacity-50 text-base-foreground-muted'],
false: [],
},
/** 操作できるかどうか */
disabled: {
true: ['opacity-50 text-base-foreground-muted pointer-events-none'],
false: [],
},
/** 指定された日付の範囲かどうか */
range: {
true: [
'relative z-0 before:absolute before:inset-x-0 before:inset-y-0.5 before:content-[""] before:size-9 before:bg-primary/10 hover:before:rounded-none before:-z-30',
],
false: [],
},
},
compoundVariants: [
{
select: true,
current: true,
class: 'after:border-none after:-z-10 before:-z-20',
},
],
});
export const calendarMonthYearVariants = cva('relative z-0 place-content-center min-w-13 w-full px-2 py-1 rounded-full text-center leading-none text-base-foreground-accent transition-colors cursor-pointer', {
variants: {
/** 現在の年月かどうか */
current: {
true: [
'text-base-foreground-on-fill-bright before:absolute before:inset-0 before:content-[""] before:bg-primary group-hover:before:bg-primary/90 hover:before:bg-primary/90 before:rounded-full group-focus-visible:ring-2 group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-base-container-default group-focus-visible:ring-primary before:-z-10',
],
false: [
'before:absolute before:inset-0 before:content-[""] group-hover:before:bg-base-container-accent/90 hover:before:bg-base-container-accent/90 group-focus-visible:before:bg-base-container-default before:rounded-full group-focus-visible:ring-2 group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-base-container-default group-focus-visible:ring-primary before:-z-10',
],
},
/** 操作できるかどうか */
disabled: {
true: ['opacity-50 text-base-foreground-muted pointer-events-none'],
false: [],
},
},
});
export type CalendarVariants = VariantProps<typeof calendarVariants>;
export interface CalendarProps extends CalendarVariants {
/** 日付 */
value?: string;
/** 範囲選択できるどうか */
enableRange?: boolean;
/** 範囲選択の最小値 */
start?: string;
/** 範囲選択の最大値 */
end?: string;
/** 選択できる範囲の最小値 */
min?: string;
/** 選択できる範囲の最大値 */
max?: string;
/** 表示する月の数 */
months?: number;
/** 選択されたときのコールバック関数 */
onChange?: (value: string) => void;
/** 選択されたときのコールバック関数 */
onRangeChange?: (value: CalendarRange) => void;
/** 初期表示時に現在選択中の日付に自動でフォーカスするか */
autoFocusDate?: boolean;
/** クラス */
class?: ClassValue;
}
export interface CalendarDate {
/** 日付 */
value: number;
/** 選択されている月じゃないかどうか */
outOfMonth?: boolean;
/** 操作できるかどうか */
disabled?: boolean;
/** 選択されている日付 */
date: Date;
/** 選択されている日付のフォーマット */
formattedDate: string;
}
export interface CalendarRange {
/** 範囲の開始日 */
start: string;
/** 範囲の最終日 */
end: string;
}
export type CalendarType = 'date' | 'month' | 'year';
</script>
<script lang="ts">
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
import { tick } from 'svelte';
import { SvelteDate } from 'svelte/reactivity';
import { scale } from 'svelte/transition';
let { value = $bindable(''), enableRange = false, start = $bindable(''), end = $bindable(''), min = MIN_DATE, max = MAX_DATE, autoFocusDate = false, class: className, months = 1, onChange, onRangeChange }: CalendarProps = $props();
let calendarEl: HTMLDivElement;
let beforeSelectDate = $state<Date | null>(null);
let type = $state<CalendarType>('date');
let rangeStart = $derived(start ? parseLocalDate(start) : null);
let rangeEnd = $derived(end ? parseLocalDate(end) : null);
const minDate = $derived(parseLocalDate(min));
const maxDate = $derived(parseLocalDate(max));
let selectedDate = $derived(value ? new SvelteDate(parseLocalDate(value)) : new SvelteDate());
const selectedValue = $derived(toDateString(selectedDate));
let displayDate = $state(new SvelteDate());
let calendars = $state<ReturnType<typeof createCalendar>[]>([]);
$effect(() => {
// すでに表示中の月範囲にdisplayDateが含まれる場合は再生成しない
if (isInDisplayMonths()) return;
updateCalendars();
});
let calendarVariantsClass = $derived(calendarVariants({ class: className }));
const yearRange = $derived.by(() => {
const start = Math.floor(displayDate.getFullYear() / DISPLAY_YEAR_NUM) * DISPLAY_YEAR_NUM;
return Array.from({ length: DISPLAY_YEAR_NUM }, (_, i) => start + i);
});
const yearLabel = $derived(`${yearRange[0]}-${yearRange[yearRange.length - 1]}年`);
const calendarStartDate = $derived.by(() => {
if (!calendars.length) {
return displayDate;
}
const start = calendars[0];
return new Date(start.year, start.month, 1);
});
const prevDisplayDate = $derived.by(() => {
if (type === 'year') {
return new Date(calendarStartDate.getFullYear() - DISPLAY_YEAR_NUM, 11, 31);
}
else if (type === 'month') {
return new Date(calendarStartDate.getFullYear() - 1, 11, 31);
}
else {
return new Date(calendarStartDate.getFullYear(), calendarStartDate.getMonth(), 0);
}
});
const nextDisplayDate = $derived.by(() => {
if (type === 'year') {
return new Date(calendarStartDate.getFullYear() + DISPLAY_YEAR_NUM, 0, 1);
}
else if (type === 'month') {
return new Date(calendarStartDate.getFullYear() + 1, 0, 1);
}
else {
return new Date(calendarStartDate.getFullYear(), calendarStartDate.getMonth() + 1, 1);
}
});
const prevDisabled = $derived(!isDateInRange(prevDisplayDate));
const nextDisabled = $derived(!isDateInRange(nextDisplayDate));
$effect(() => {
if (!isValidDateString(min)) min = MIN_DATE;
if (!isValidDateString(max)) max = MAX_DATE;
if (new Date(min) > new Date(max)) {
console.warn(`minRange (${min}) は maxRange (${max}) より前の日付である必要があります。`);
}
});
$effect(() => {
if (enableRange) {
if (!value) {
value = end || start;
}
}
});
$effect(() => {
if (!autoFocusDate) return;
if (calendarEl.contains(document.activeElement)) return;
const date_to_focus = value ? value : toDateString(new Date());
focusByDate(date_to_focus);
});
$effect(() => {
if (value && !isValidDateString(value)) {
console.warn(`value (${value}) は有効な日付ではありません。`);
value = '';
}
setDisplayDate(value ? parseLocalDate(value) : min !== MIN_DATE ? parseLocalDate(min) : new Date());
});
/** 指定した日付の年月を先頭に、months 分のカレンダーを生成 */
function updateCalendars() {
calendars = Array.from({ length: months }, (_, i) => {
const date = new Date(displayDate.getFullYear(), displayDate.getMonth() + i);
return createCalendar(date.getFullYear(), date.getMonth());
});
}
function createCalendar(year: number, month: number) {
const first = new Date(year, month, 1);
const start = new Date(first);
start.setDate(start.getDate() - first.getDay());
const weeks: CalendarDate[][] = [];
for (let w = 0; w < 6; w++) {
const week: CalendarDate[] = [];
for (let d = 0; d < 7; d++) {
const date = new Date(start);
date.setDate(start.getDate() + w * 7 + d);
const formatted = toDateString(date);
const is_out_of_range = date < minDate || date > maxDate;
week.push({
value: date.getDate(),
date,
formattedDate: formatted,
outOfMonth: date.getMonth() !== month,
disabled: is_out_of_range,
});
}
weeks.push(week);
}
return { year, month, weeks };
}
function isValidDateString(str: string): boolean {
const date = new Date(str);
return !Number.isNaN(date.getTime());
}
function toDateString(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function isInDisplayMonths(): boolean {
if (!calendars.length) return false;
const start = calendars[0];
const end = calendars[calendars.length - 1];
const target = {
year: displayDate.getFullYear(),
month: displayDate.getMonth(),
};
function format_month({ year, month }): number {
return year * 12 + month;
}
return format_month(start) <= format_month(target) && format_month(target) <= format_month(end);
}
function isSelected(day: CalendarDate) {
if (!value) return false;
const target = day.formattedDate;
if (enableRange) {
const is_start = rangeStart ? target === toDateString(rangeStart) : false;
const is_end = rangeEnd ? target === toDateString(rangeEnd) : false;
return is_start || is_end;
}
return day.formattedDate === selectedValue;
}
function yearDisabled(year: number) {
const start = new Date(year, 0, 1);
const end = new Date(year, 11, 31);
return end < minDate || start > maxDate;
}
function monthDisabled(month: number) {
const first_day = new Date(displayDate.getFullYear(), month, 1);
const last_day = new Date(displayDate.getFullYear(), month + 1, 0);
return last_day < minDate || first_day > maxDate;
}
function setDisplayDate(date: Date) {
displayDate = new SvelteDate(date.getFullYear(), date.getMonth(), date.getDate());
}
/** 表示タイプと日付を更新 */
function updateDisplay(nextType: CalendarType, date: Date) {
type = nextType;
setDisplayDate(date);
}
async function focusByDate(date: Date | string) {
type = 'date';
await tick();
if (typeof date !== 'string') {
date = toDateString(date);
}
const el = calendarEl?.querySelector(`[data-date="${date}"][data-outofmonth="false"]`);
if (el instanceof HTMLElement) {
el.focus({ preventScroll: true });
}
}
/** 1つ前の表示期間へ移動し、日付表示ではカレンダーを即時再生成 */
function prevCalendar() {
if (prevDisabled) return;
setDisplayDate(prevDisplayDate);
updateCalendars();
}
/** 1つ次の表示期間へ移動し、日付表示ではカレンダーを即時再生成 */
function nextCalendar() {
if (nextDisabled) return;
setDisplayDate(nextDisplayDate);
updateCalendars();
}
function isDateInRange(date: Date): boolean {
return date >= minDate && date <= maxDate;
}
function parseLocalDate(str: string): Date {
const [y, m, d] = str.split('-').map(Number);
return new Date(y, m - 1, d);
}
function setValue(date: Date | string) {
if (typeof date === 'string') {
value = date;
}
else {
value = toDateString(date);
}
onChange?.(value);
}
function setRangeValue(day: CalendarDate) {
const selected_range_value = day.date;
value = day.formattedDate;
if (rangeStart && !rangeEnd) {
rangeEnd = new Date(selected_range_value);
if (rangeStart > rangeEnd) [rangeStart, rangeEnd] = [rangeEnd, rangeStart];
}
else {
rangeStart = new Date(selected_range_value);
rangeEnd = null;
}
start = toDateString(rangeStart);
end = rangeEnd === null ? '' : toDateString(rangeEnd);
onChange?.(value);
onRangeChange?.({ start, end });
}
function isBeforeSelectDate(day: CalendarDate) {
if (!rangeStart || rangeEnd || !beforeSelectDate) return false;
return day.date.getTime() === beforeSelectDate.getTime();
}
function isInRangeArea(day: CalendarDate): boolean {
const target = day.date.getTime();
let range_start = 0;
let range_end = 0;
if (rangeStart && rangeEnd) {
range_start = rangeStart.getTime();
range_end = rangeEnd.getTime();
}
if (rangeStart && beforeSelectDate && !rangeEnd) {
range_start = rangeStart.getTime();
range_end = beforeSelectDate.getTime();
}
if (range_start > range_end) [range_start, range_end] = [range_end, range_start];
return target > range_start && target < range_end;
}
function getRoundedClass(day: CalendarDate): string {
if (day.outOfMonth) return '!bg-transparent';
if (!enableRange && isSelected(day)) return 'hover:before:!rounded-md';
const date = day.date;
const date_time = date.getTime();
if (!enableRange) return isSelected(day) ? 'rounded-md' : '';
const start_date = rangeStart ?? null;
const end_like = rangeEnd ?? beforeSelectDate ?? rangeStart ?? null;
if (!start_date || !end_like) return '';
let start_ms = start_date.getTime();
let end_ms = end_like.getTime();
if (start_ms > end_ms) [start_ms, end_ms] = [end_ms, start_ms];
const in_inc = (ms: number) => ms >= start_ms && ms <= end_ms;
if (!in_inc(date_time)) return '';
// 基本背景(before)
const base =
'before:content-[""] before:absolute before:inset-y-0.5 before:inset-x-0 before:size-9 before:!bg-primary/10 before:-z-50';
// 隣接日の情報
const prev = new Date(date);
prev.setDate(prev.getDate() - 1);
const next = new Date(date);
next.setDate(next.getDate() + 1);
const prev_in = in_inc(prev.getTime());
const next_in = in_inc(next.getTime());
// 週端 or 月またぎ(行が切れる箇所)は角丸
const prev_breaks_week = date.getDay() === 0; // 日曜: 左端
const next_breaks_week = date.getDay() === 6; // 土曜: 右端
const prev_breaks_month = prev.getMonth() !== date.getMonth() || prev.getFullYear() !== date.getFullYear();
const next_breaks_month = next.getMonth() !== date.getMonth() || next.getFullYear() !== date.getFullYear();
const round_left = !prev_in || prev_breaks_week || prev_breaks_month;
const round_right = !next_in || next_breaks_week || next_breaks_month;
// 角丸の決定
if (round_left && round_right) return `${base} before:rounded-md`;
if (round_left) return `${base} before:!rounded-l-md`;
if (round_right) return `${base} before:!rounded-r-md`;
return '';
}
async function selectDate(day: CalendarDate) {
if (day.disabled) return;
if (enableRange) {
setRangeValue(day);
focusByDate(day.formattedDate);
}
else {
setValue(day.formattedDate);
focusByDate(day.formattedDate);
}
}
function isToday(day: CalendarDate) {
return day.formattedDate === toDateString(new Date());
}
/** 表示してる年を変更 */
function updateDisplayYear(year: number) {
if (yearDisabled(year)) return;
displayDate.setFullYear(year);
updateCalendars();
}
function changeDisplayYear(year: number) {
updateDisplayYear(year);
type = 'date';
}
/** 表示してる月を変更 */
function updateDisplayMonth(month: number) {
if (monthDisabled(month)) return;
displayDate.setMonth(month);
updateCalendars();
}
function changeDisplayMonth(month: number) {
updateDisplayMonth(month);
type = 'date';
}
async function onKeyDownDay(e: KeyboardEvent, day: CalendarDate) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectDate(day);
return;
}
const direction_map = {
ArrowLeft: -1,
ArrowRight: 1,
ArrowUp: -7,
ArrowDown: 7,
};
const direction = direction_map[e.key] ?? 0;
if (direction === 0) return;
const date = new Date(day.date);
date.setDate(date.getDate() + direction);
if (!isDateInRange(date)) return;
e.preventDefault();
beforeSelectDate = date;
setDisplayDate(date);
focusByDate(date);
}
async function onKeyDownMonth(e: KeyboardEvent, m: number) {
if (monthDisabled(m)) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
changeDisplayMonth(m);
return;
}
const directionMap = {
ArrowLeft: -1,
ArrowRight: 1,
ArrowUp: -MONTHS_PER_ROW,
ArrowDown: MONTHS_PER_ROW,
};
const direction = directionMap[e.key] ?? 0;
if (direction === 0) return;
const new_month = m + direction;
if (monthDisabled(new_month)) return;
e.preventDefault();
updateDisplayMonth(new_month);
await tick();
const el = calendarEl.querySelector(`[data-month="${displayDate.getMonth()}"]`);
if (el instanceof HTMLElement) el.focus({ preventScroll: true });
}
async function onKeyDownYear(e: KeyboardEvent, y: number) {
if (yearDisabled(y)) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
changeDisplayYear(y);
return;
}
const direction_map = {
ArrowLeft: -1,
ArrowRight: 1,
ArrowUp: -YEARS_PER_ROW,
ArrowDown: YEARS_PER_ROW,
};
const direction = direction_map[e.key] ?? 0;
if (direction === 0) return;
const new_year = y + direction;
if (yearDisabled(new_year)) return;
e.preventDefault();
updateDisplayYear(new_year);
await tick();
const el = calendarEl.querySelector(`[data-year="${displayDate.getFullYear()}"]`);
if (el instanceof HTMLElement) el.focus({ preventScroll: true });
}
</script>
<div class={calendarVariantsClass} bind:this={calendarEl} transition:scale={{ start: 0.98, opacity: 0, duration: 100 }}>
<div class="flex items-center justify-between">
<button class={calendarArrowButtonVariants({ disabled: prevDisabled })} onclick={prevCalendar} disabled={prevDisabled} type="button">
<ChevronLeft size="1rem" />
</button>
<div class="flex items-center justify-around w-full gap-9">
{#if type === 'date'}
{#each calendars as calendar}
{@const year = calendar.year}
{@const month = calendar.month}
{@const year_disabled = yearDisabled(year)}
{@const month_disabled = monthDisabled(month)}
<div class="flex items-center gap-0.5 text-sm">
<button class={calendarMonthYearButtonVariants({ disabled: year_disabled })} onclick={() => updateDisplay('year', new Date(year, month, 1))} disabled={year_disabled}>
{year}年
</button>
<button class={calendarMonthYearButtonVariants({ disabled: month_disabled })} onclick={() => updateDisplay('month', new Date(year, month, 1))} disabled={month_disabled}>
{month + 1}月
</button>
</div>
{/each}
{:else if type === 'month'}
{@const year = displayDate.getFullYear()}
{@const disabled = yearDisabled(year)}
<button class={calendarMonthYearButtonVariants({ disabled })} onclick={() => { type = 'year'; }} {disabled}>
{year}年
</button>
{:else}
<p>{yearLabel}</p>
{/if}
</div>
<button class={calendarArrowButtonVariants({ disabled: nextDisabled })} onclick={nextCalendar} disabled={nextDisabled} type="button">
<ChevronRight size="1rem" />
</button>
</div>
<div class="min-h-68.25">
<div class="flex gap-2" class:hidden={type !== 'date'}>
{#each Array(months) as _}
<div class="flex">
{#each WEEK_DAYS as week}
<div class={calendarDayOfWeekVariants()}>{week}</div>
{/each}
</div>
{/each}
</div>
<div>
{#if type === 'date'}
<div class="flex gap-2">
{#each calendars as calendar}
<div>
{#each calendar.weeks as week}
<div class="flex text-center">
{#each week as day}
<div class="py-0.25">
<div
class={[
calendarDateVariants({
select: (!day.outOfMonth && isSelected(day)) || isBeforeSelectDate(day),
current: isToday(day),
outOfMonth: day.outOfMonth,
disabled: day.disabled,
range: !day.outOfMonth && enableRange && isInRangeArea(day),
}),
getRoundedClass(day),
]}
tabindex={day.disabled ? -1 : 0}
role="button"
data-date={day.formattedDate}
data-outofmonth={day.outOfMonth}
onclick={() => selectDate(day)}
onkeydown={(e) => onKeyDownDay(e, day)}
onfocus={() => {
beforeSelectDate = new Date(day.date);
}}
onblur={() => {
beforeSelectDate = null;
}}
onmouseenter={() => {
beforeSelectDate = new Date(day.date);
}}
onmouseleave={() => {
beforeSelectDate = null;
}}
>
{day.value}
</div>
</div>
{/each}
</div>
{/each}
</div>
{/each}
</div>
{:else}
<div class="grid grid-cols-3 col-span-4 row-span-3 place-items-center min-h-68.25">
{#if type === 'month'}
{#each Array(DISPLAY_MONTH_NUM) as _, m}
{@const isDisabled = monthDisabled(m)}
<div class={['px-1.5 py-2', !isDisabled && 'group cursor-pointer focus-visible:!outline-none']} tabindex={isDisabled ? -1 : 0} role="button" data-month={m} onclick={() => changeDisplayMonth(m)} onkeydown={(e) => onKeyDownMonth(e, m)}>
<p class={calendarMonthYearVariants({ current: selectedDate.getFullYear() === displayDate.getFullYear() && selectedDate.getMonth() === m, disabled: isDisabled })}>
{m + 1}月
</p>
</div>
{/each}
{:else if type === 'year'}
{#each yearRange as y}
{@const year_disabled = yearDisabled(y)}
<div class={['px-1.5 py-2', !year_disabled && 'group cursor-pointer focus-visible:!outline-none']} tabindex={year_disabled ? -1 : 0} role="button" data-year={y} onclick={() => changeDisplayYear(y)} onkeydown={(e) => { onKeyDownYear(e, y); }}>
<p class={calendarMonthYearVariants({ current: selectedDate.getFullYear() === y, disabled: year_disabled })}>
{y}
</p>
</div>
{/each}
{/if}
</div>
{/if}
</div>
</div>
</div>
使い方
<script lang="ts">
import Calendar from '$lib/components/ui/modules/Calendar.svelte';
</script>
<Calendar />
サンプル
Default
日付を表示・選択できます。
<script lang="ts">
import Calendar from '$lib/components/ui/modules/Calendar.svelte';
</script>
<Calendar />
Min・Max
選択可能な範囲を指定できます。
<script lang="ts">
import Calendar from '$lib/components/ui/modules/Calendar.svelte';
let min = $state('2025-07-07');
let max = $state('2025-07-29');
</script>
<Calendar {min} {max} />
Date
指定の日付を渡すことができます。
<script lang="ts">
import DebugConsole from '$lib/components/ui/atoms/DebugConsole.svelte';
import Calendar from '$lib/components/ui/modules/Calendar.svelte';
let value = $state('2025-07-07');
</script>
<div class="grid gap-4 w-full">
<Calendar class="w-[18rem] mx-auto" bind:value />
<DebugConsole data={value} />
</div>
Range
日付を範囲選択することができます。
<script lang="ts">
import DebugConsole from '$lib/components/ui/atoms/DebugConsole.svelte';
import Calendar from '$lib/components/ui/modules/Calendar.svelte';
let value = $state('');
let start = $state('');
let end = $state('');
let date = $state({
start: '',
end: '',
});
let singleDate = $state('');
function onChange(value: string) {
singleDate = value;
}
function onRangeChange(value: { start: string; end: string }) {
date = value;
}
</script>
<div class="grid w-full gap-4">
<Calendar class="mx-auto" enableRange bind:value bind:start bind:end {onRangeChange} {onChange} />
<DebugConsole data={{ start, end, value, onRangeChange: date, onChange: singleDate }} />
</div>
Month
表示する月数を指定することができます。
<script lang="ts">
import Calendar from '$lib/components/ui/modules/Calendar.svelte';
</script>
<Calendar months={2} enableRange />
OnChange
コールバック関数で値を受け取ることができます。
<script lang="ts">
import DebugConsole from '$lib/components/ui/atoms/DebugConsole.svelte';
import Calendar from '$lib/components/ui/modules/Calendar.svelte';
let selectedDate = $state('');
function onChange(date: string) {
selectedDate = date;
}
</script>
<div class="grid w-full gap-4">
<Calendar class="w-[18rem] mx-auto" {onChange} />
<DebugConsole data={selectedDate} />
</div>
With Input
InputDateとも組み合わせることができます。
<script lang="ts">
import Input from '$lib/components/ui/atoms/Input.svelte';
import Calendar from '$lib/components/ui/modules/Calendar.svelte';
import { Calendar as CalendarIcon } from '@lucide/svelte';
// カレンダーの開閉状態
let isCalendarOpen = $state(false);
// カレンダーコンポーネントの値
let calendarValue = $state('');
// Input に表示する値(yyyy/mm/dd形式)
let inputValue = $state('');
// カレンダー全体のルート要素(クリック判定用)
let calendarRoot = $state<HTMLDivElement>();
// カレンダーを開く
function openCalendar() {
isCalendarOpen = true;
}
// カレンダーをトグルする(開く/閉じる)
function toggleCalendar() {
isCalendarOpen = !isCalendarOpen;
}
// カレンダーで日付を選択したときの処理
function onChange(val: string) {
inputValue = val.replace(/-/g, '/');
if (inputValue) {
isCalendarOpen = false;
}
}
// body クリック時の処理(カレンダーの外をクリックしたら閉じる)
function onClickBody(e: MouseEvent) {
if (!calendarRoot || !(e.target instanceof HTMLElement)) return;
if (!calendarRoot.contains(e.target)) {
isCalendarOpen = false;
}
}
// フォーカスアウト時の処理(カレンダー外にフォーカスが移ったら閉じる)
function onFocusout(e: FocusEvent) {
if (!calendarRoot || !(e.relatedTarget instanceof HTMLElement)) return;
if (!calendarRoot.contains(e.relatedTarget)) {
isCalendarOpen = false;
}
}
// Escapeキーでカレンダーを閉じる処理
function onEscape(e: KeyboardEvent) {
if (isCalendarOpen && e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
isCalendarOpen = false;
}
}
</script>
<svelte:body onclickcapture={onClickBody} onkeydown={onEscape} />
<div
class="relative flex flex-col w-full"
bind:this={calendarRoot}
onfocusout={onFocusout}
>
<Input
inputClass="cursor-pointer !opacity-100"
type="text"
placeholder="yyyy/mm/dd"
readonly
bind:value={inputValue}
onfocus={openCalendar}
>
{#snippet endContent()}
<button class="cursor-pointer" onclick={toggleCalendar}>
<CalendarIcon class="text-base-foreground-muted" size="1rem" />
</button>
{/snippet}
</Input>
{#if isCalendarOpen}
<Calendar
class="absolute top-full mt-1"
{onChange}
bind:value={calendarValue}
autoFocusDate
/>
{/if}
</div>