Calendar

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>

      

使い方


サンプル

Default

日付を表示・選択できます。

Min・Max

選択可能な範囲を指定できます。

Date

指定の日付を渡すことができます。

Range

日付を範囲選択することができます。

Month

表示する月数を指定することができます。

OnChange

コールバック関数で値を受け取ることができます。

With Input

InputDateとも組み合わせることができます。