このページは機械翻訳のため、誤りを含む場合があります。 英語の原文を読む

要素

Calendar

Calendar は、週ごとに並べた月の日付から、日付を1つ、または range を指定すると日付の範囲を選ばせるコンポーネントです。フォームの期限日、宿泊の日程、レポートの対象日などに使います。1か月、または複数の月を横に並べて表示し、Previous と Next で月をめくります。遠くへ移動するときは、年の月の表示や年のページに切り替えます。DatePicker のポップアップ内にあるカレンダーであり、ページ内で単独でも使えます。

el-date-picker.tsx
el-date-picker.tsxLive
import { type JSX, mount, signal, Text, View, windowSize } from "nsh";
import { type CalendarEntry, DatePicker, type DatePickerValue, type DateRangePreset, type HourCycle, type OpenChangeReason, type TimeFieldSegmentEntry } from "nsh/base";
import { button, desktop, Icon, Log, Panel, Segmented, size, Split, ui, wide } from "./palette";

// Three date pickers in a card: a due date (weekends disabled, presets
// beside the calendar, a clear button), a stay picked as a range over two
// months in a wide window, and a meeting's date and time with the hours
// on the clock the inspector picks. The trigger is the shared button look
// with the value and a chevron that turns while the calendar is open; the
// calendar is a popup at the trigger, a sheet on a touch screen. Every
// item is drawn by `renderItem` (month names, weekday initials, today on a
// quiet fill, the selected days filled, a range's band) and every segment
// of the time by `renderSegment`; the time's label starts its row and the
// field, a well as the number inputs', ends it.

const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];

/** A day's ISO date (`"2026-10-28"`), in local time as the calendar's days are. */
const iso = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const addDays = (d: Date, days: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + days);
/** An ISO date's month and day: `Oct 28`. */
const monthDay = (value: string) => `${MONTHS[Number(value.slice(5, 7)) - 1].slice(0, 3)} ${Number(value.slice(8, 10))}`;

/** A time `"HH:MM"` on the picked clock: `14:30`, or `2:30 PM`. */
function clock(time: string, cycle: HourCycle) {
  if (cycle === "h23") return time;
  const hour = Number(time.slice(0, 2));
  return `${hour % 12 === 0 ? 12 : hour % 12}:${time.slice(3, 5)} ${hour < 12 ? "AM" : "PM"}`;
}

/** The text of a control: 13 with a pointer, the touch label size otherwise. */
const controlText = desktop ? 13 : size.label;
/** A day's cell: seven across the popup with a pointer, across a phone's sheet otherwise. */
const cell = desktop ? 32 : 42;
/** The gap between the months of a calendar of two. */
const monthGap = 16;
/**
 * On a touch screen the calendar spans the sheet (the window less the sheet's padding), its columns sharing it: the
 * header's titles split what Previous and Next leave, so the month and the year meet in the middle, and each month's
 * title of two is over its month.
 */
const toggleWidth = () => Math.floor((windowSize().width - 2 * 10) / 2 - cell);

/** A popup at the trigger with a pointer, a sheet at the bottom on a touch screen. */
const surface = () =>
  desktop
    ? { ...ui().card, borderRadius: 12, padding: 8 }
    : { ...ui().card, borderTopLeftRadius: 20, borderTopRightRadius: 20, paddingHorizontal: 10, paddingTop: 14, paddingBottom: 30 };

const backdrop = { backgroundColor: "rgba(0,0,0,0.45)" };

/** The popup's layout: presets in a column beside the calendar with a pointer, a row of chips above it on a phone. */
const content = desktop ? { flexDirection: "row" as const, gap: 10 } : { gap: 12 };
const presets = () =>
  desktop
    ? { gap: 2, minWidth: 104, paddingRight: 10, borderRightWidth: 1, borderColor: ui().border }
    : { flexDirection: "row" as const, flexWrap: "wrap" as const, gap: 6 };
const preset = () =>
  desktop
    ? { height: 28, paddingHorizontal: 8, borderRadius: 6, justifyContent: "center" as const, fontSize: 13, color: ui().text }
    : { height: 34, paddingHorizontal: 12, borderRadius: 17, borderWidth: 1, borderColor: ui().border, justifyContent: "center" as const, fontSize: 14, color: ui().text };
/** The calendar: in the popup with a pointer; across the sheet on a touch screen, as the time's row under it. */
const calendar = desktop ? { padding: 2 } : {};
/** The time's row under a hairline, its label at the start and the field at the end, on the calendar's edges. */
const time = () => ({
  gap: 10,
  marginTop: 8,
  paddingTop: 8,
  borderTopWidth: 1,
  borderColor: ui().border,
  ...(desktop ? { paddingHorizontal: 2 } : {}),
});
/** The time's field: a recessed well as the number inputs', with their hover and the keyboard's ring while it edits. */
const timeField = [
  desktop ? "h-8 rounded-lg px-2" : "h-11 rounded-xl px-3",
  "items-center border border-input bg-field",
  "enabled:hover:border-faint focus-within:outline-2 focus-within:outline-info",
].join(" ");

/** The trigger: the shared button's look, the value at the start and a chevron in a lane as wide as it. */
function Trigger(props: { title?: string; placeholder: string; open: boolean; clearable?: boolean }) {
  const look = button("secondary", {
    style: () => ({
      flexDirection: "row",
      justifyContent: "space-between",
      gap: 8,
      ...(desktop ? { width: 260 } : {}),
      ...(props.open ? { borderColor: ui().faint } : {}),
    }),
  });
  return (
    <View {...look}>
      <Text style={{ numberOfLines: 1, flex: 1, fontSize: controlText, color: props.title ? ui().text : ui().muted }}>
        {props.title ?? props.placeholder}
      </Text>
      {/* With a clear button over the end (`cleanable`), an empty lane as wide as the button in place of the chevron. */}
      <View
        class="transition-transform duration-150 motion-reduce:transition-none"
        style={{
          width: props.clearable ? 20 : 10,
          height: 20,
          alignItems: "center",
          justifyContent: "center",
          opacity: props.clearable ? 0 : 1,
          transform: [{ rotate: props.open ? "270deg" : "90deg" }],
        }}
      >
        <Text style={{ fontSize: 18, color: ui().muted }}>›</Text>
      </View>
    </View>
  );
}

/**
 * The calendar's items (`itemClass`): pressed at 70%; `group`, so the
 * drawing inside follows the item's states.
 */
const itemClass = "group active:opacity-70";

/**
 * An item of the calendar, drawn once from what it is, its states the
 * item's classes: Previous and Next as arrows (faint while they take no
 * press), the month and year toggles (or each month's title, with `months`
 * over one) by name, the open view's toggle in the accent, the weekdays'
 * initials, and the days: today on the theme's quiet `accent` fill
 * (gpui-component's), the selected date and a range's ends filled, the days
 * of a complete range on a band from end to end (`data-range-*`), the other
 * months' days and the disabled ones faint, the keyboard's day ringed.
 */
function Item(props: { item: CalendarEntry; months?: number }) {
  const kind = props.item.kind;
  if (kind === "previous" || kind === "next") {
    return (
      <View class="group-disabled:opacity-30" style={{ width: cell, height: cell, alignItems: "center", justifyContent: "center" }}>
        <Text style={{ fontSize: 20, color: ui().muted }}>{kind === "previous" ? "‹" : "›"}</Text>
      </View>
    );
  }
  if (kind === "monthToggle" || kind === "yearToggle") {
    // A month's title in a calendar of several takes no press: the month and its year, over the month.
    const title = kind === "monthToggle" && (props.months ?? 1) > 1;
    return (
      <Text
        class="text-foreground group-selected:text-brand"
        style={{
          width: !desktop ? toggleWidth() : title ? 6 * cell + monthGap / 2 : kind === "monthToggle" ? 3 * cell : 2 * cell,
          paddingHorizontal: 3,
          textAlign: title ? "center" : kind === "monthToggle" ? "right" : "left",
          fontSize: size.label,
          fontWeight: "600",
        }}
      >
        {title ? `${MONTHS[props.item.value - 1]} ${props.item.year}` : kind === "monthToggle" ? MONTHS[props.item.value - 1] : String(props.item.value)}
      </Text>
    );
  }
  if (kind === "weekday") {
    return <Text style={{ minWidth: cell, paddingVertical: 4, textAlign: "center", fontSize: 11, color: ui().faint }}>{WEEKDAYS[props.item.value]}</Text>;
  }
  if (kind === "month" || kind === "year") {
    // The month view's months (rows of 3) and the year view's years (rows of 5), as wide as a month; the shown one
    // filled.
    return (
      <View
        class="rounded-lg group-selected:bg-brand-fill"
        style={{ minWidth: (7 * cell) / (kind === "month" ? 3 : 5), height: cell + 6, alignItems: "center", justifyContent: "center" }}
      >
        <Text class="text-foreground group-selected:text-on-brand" style={{ fontSize: controlText }}>
          {kind === "month" ? MONTHS[props.item.value - 1].slice(0, 3) : String(props.item.value)}
        </Text>
      </View>
    );
  }
  // A day, as wide as its column (the calendar's columns share its width). The band spans a range's middle days and
  // reaches from its ends' middles; a range of one day has none. The circle fills for the selected date and a
  // range's ends (not its middle days), else for today.
  const own = props.item.outside ? "text-faint" : "text-foreground";
  return (
    <View style={{ minWidth: cell, height: cell, alignItems: "center", justifyContent: "center" }}>
      <View
        class={
          "absolute inset-y-0.5 left-0 right-0 hidden bg-brand-tint group-data-range-middle:flex group-data-range-start:flex " +
          "group-data-range-start:left-1/2 group-data-range-end:flex group-data-range-end:right-1/2 group-data-range-start:group-data-range-end:hidden"
        }
      />
      <View
        class={
          `${props.item.today ? "bg-accent" : ""} border-[1.5px] border-transparent group-focus:border-info ` +
          "group-selected:bg-brand-fill group-data-range-middle:bg-transparent"
        }
        style={{ width: cell - 4, height: cell - 4, borderRadius: (cell - 4) / 2, alignItems: "center", justifyContent: "center" }}
      >
        <Text
          class={
            `${own} ${props.item.today ? "font-semibold" : "font-normal"} group-selected:font-semibold group-selected:text-on-brand ` +
            `group-data-range-middle:${own} group-disabled:text-faint group-disabled:opacity-50`
          }
          style={{ fontSize: controlText, fontVariant: ["tabular-nums"] }}
        >
          {String(props.item.value)}
        </Text>
      </View>
    </View>
  );
}

/**
 * A segment of the time: filled while it has the keys (`focus:` on the
 * segment, `segmentClass`), its digits or AM / PM in the color on the fill
 * (`group-focus:`), so moving between the segments draws nothing.
 */
const segmentClass = "group rounded active:opacity-70 focus:bg-brand-fill";
function Segment(props: { segment: TimeFieldSegmentEntry }) {
  return (
    <Text class="text-foreground group-focus:text-on-brand" style={{ paddingHorizontal: 3, fontSize: controlText, fontVariant: ["tabular-nums"] }}>
      {props.segment.label}
    </Text>
  );
}

/** A label over its control, a quieter line under the label. */
function Field(props: { label: string; detail: string; children: JSX.Element }) {
  return (
    <View style={{ gap: 8 }}>
      <View style={{ gap: 2 }}>
        <Text style={{ fontSize: size.label, fontWeight: "500", color: ui().text }}>{props.label}</Text>
        <Text style={{ fontSize: size.detail, color: ui().muted }}>{props.detail}</Text>
      </View>
      {props.children}
    </View>
  );
}

function App() {
  const due = signal<string | null>(null);
  const stay = signal<DatePickerValue>(null);
  const meeting = signal<string | null>(null);
  const cycle = signal<HourCycle>("h12");
  const dueOpen = signal(false);
  const stayOpen = signal(false);
  const meetingOpen = signal(false);
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 8));
  const opened = (open: (next: boolean) => void) => ({ open: next }: { open: boolean; reason: OpenChangeReason }) => open(next);
  // The presets from today.
  const today = new Date();
  const monday = addDays(today, ((8 - today.getDay()) % 7) || 7);
  const dues: DateRangePreset[] = [
    { label: "Today", value: iso(today) },
    { label: "Tomorrow", value: iso(addDays(today, 1)) },
    { label: "Next Monday", value: iso(monday) },
  ];
  const stays: DateRangePreset[] = [
    { label: "This week", value: [iso(today), iso(addDays(today, (7 - today.getDay()) % 7))] },
    { label: "Next 7 nights", value: [iso(today), iso(addDays(today, 7))] },
  ];
  const stayTitle = () => {
    const r = stay();
    return Array.isArray(r) && r[0] && r[1] ? `${monthDay(r[0])} – ${monthDay(r[1])}, ${r[1].slice(0, 4)}` : undefined;
  };
  const meetingTitle = () => {
    const v = meeting();
    return v ? `${monthDay(v)}, ${v.slice(0, 4)} · ${clock(v.slice(11, 16), cycle())}` : undefined;
  };
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>date-picker</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Arrows move between days, Return picks; the trigger takes Delete to clear" : "Tap a day; the meeting's time takes digits"}
      </Text>

      <Split
        gap={14}
        inspector={
          <>
            <Panel title="value">
              <Text style={{ fontSize: 13, color: ui().text }}>{`due ${JSON.stringify(due())}`}</Text>
              <Text style={{ fontSize: 13, color: ui().text }}>{`stay ${JSON.stringify(stay())}`}</Text>
              <Text style={{ fontSize: 13, color: ui().text }}>{`meeting ${JSON.stringify(meeting())}`}</Text>
            </Panel>
            <Segmented label="hourCycle" options={["h12", "h23"] as const} value={cycle()} onChange={(v) => cycle.set(v)} />
            <Log title="onChange" lines={events()} minHeight={120} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, padding: 16, gap: 20 }}>
          <Field label="Due" detail="Weekdays only, presets beside the calendar, a clear button">
            <DatePicker
              value={due()}
              onChange={({ value }) => {
                due.set(typeof value === "string" ? value : null);
                log(`Due → ${JSON.stringify(value)}`);
              }}
              onOpenChange={opened((next) => dueOpen.set(next))}
              accessibilityLabel="Due"
              disabledMatcher={[0, 6]}
              presets={dues}
              offset={4}
              margin={8}
              popupStyle={surface()}
              backdropStyle={backdrop}
              contentStyle={content}
              presetsStyle={presets()}
              presetStyle={preset()}
              presetClass="active:opacity-70"
              calendarStyle={calendar}
              headerStyle={{ marginBottom: 4 }}
              itemClass={itemClass}
              renderItem={(item) => <Item item={item} />}
              style={desktop ? { width: 260 } : undefined}
              cleanable
              clearButtonAccessibilityLabel="Clear the due date"
              clearButtonClass="absolute items-center justify-center rounded-full enabled:hover:opacity-70"
              clearButtonStyle={{ top: (size.control - 20) / 2, right: 10, width: 20, height: 20 }}
              clearButton={<Icon name="close" size={14} tint={ui().muted} />}
              trigger={<Trigger title={due() ? `${monthDay(due()!)}, ${due()!.slice(0, 4)}` : undefined} placeholder="No due date" open={dueOpen()} clearable={due() !== null} />}
            />
          </Field>
          <Field label="Stay" detail={wide() ? "A range over two months: the first press starts it" : "A range: the first press starts it, the second ends it"}>
            <DatePicker
              range
              value={stay()}
              onChange={({ value }) => {
                stay.set(value);
                log(`Stay → ${JSON.stringify(value)}`);
              }}
              onOpenChange={opened((next) => stayOpen.set(next))}
              accessibilityLabel="Stay"
              numberOfMonths={wide() ? 2 : 1}
              firstDayOfWeek={1}
              presets={stays}
              offset={4}
              margin={8}
              popupStyle={surface()}
              backdropStyle={backdrop}
              contentStyle={content}
              presetsStyle={presets()}
              presetStyle={preset()}
              presetClass="active:opacity-70"
              calendarStyle={calendar}
              headerStyle={{ marginBottom: 4 }}
              bodyStyle={{ gap: monthGap }}
              itemClass={itemClass}
              renderItem={(item) => <Item item={item} months={wide() ? 2 : 1} />}
              trigger={<Trigger title={stayTitle()} placeholder="Check-in – check-out" open={stayOpen()} />}
            />
          </Field>
          <Field label="Meeting" detail={desktop ? "A day, then its time; the day again closes the calendar" : "A tap picks the day; open it again for the time"}>
            <DatePicker
              value={meeting()}
              onChange={({ value }) => {
                meeting.set(typeof value === "string" ? value : null);
                log(`Meeting → ${JSON.stringify(value)}`);
              }}
              onOpenChange={opened((next) => meetingOpen.set(next))}
              accessibilityLabel="Meeting"
              timePrecision="minute"
              hourCycle={cycle()}
              defaultTime="09:00"
              offset={4}
              margin={8}
              popupStyle={surface()}
              backdropStyle={backdrop}
              calendarStyle={calendar}
              headerStyle={{ marginBottom: 4 }}
              itemClass={itemClass}
              renderItem={(item) => <Item item={item} />}
              timeStyle={time()}
              timeLabel={<Text style={{ fontSize: controlText, color: ui().muted }}>Time</Text>}
              timeAccessibilityLabel="Meeting time"
              timeFieldClass={timeField}
              segmentClass={segmentClass}
              separatorStyle={{ fontSize: controlText, color: ui().faint }}
              renderSegment={(segment) => <Segment segment={segment} />}
              trigger={<Trigger title={meetingTitle()} placeholder="Not scheduled" open={meetingOpen()} />}
            />
          </Field>
        </View>
      </Split>
    </View>
  );
}

mount(App);

Calendar は動作を担います。値(アプリのもの、または Calendar 自身のもの)、表示中の月、ビュー、選択できない日、キーボード、スクリーンリーダーが読み上げる内容です。見た目はアプリ側の担当です。ヘッダー、ボディ、各月、各行、各アイテムにスタイルを付け、アイテムの内容(月名や曜日名、今日の印、範囲の帯)を描画します。グリッドは、テーブルの列と同様に、与えられた幅いっぱいに広がります。月は幅を分け合い、行のアイテムは行を均等に分け合うため、スマートフォンのシートではカレンダーが画面幅いっぱいになり、内容に合わせたサイズのポップアップではアイテムの幅が保たれます(flex-none を itemClass または monthClass に指定すると、アイテムや月がそれ自身の幅のままになります)。

use nsh_base::{CalendarEntry, CalendarItemKind, Date, Matcher, calendar};
use nsh_core::prelude::*;

const MONTHS: [&str; 12] = [
    "January", "February", "March", "April", "May", "June",
    "July", "August", "September", "October", "November", "December",
];
const WEEKDAYS: [&str; 7] = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];

fn due_date() -> impl IntoElement {
    let due = signal(Date::default());
    calendar()
        .value(move || due.get())
        .on_change(move |next: &Date| due.set(*next))
        .disabled_matcher(Matcher::DayOfWeek(vec![0, 6]))
        .previous_accessibility_label("Previous month")
        .next_accessibility_label("Next month")
        .item_class("rounded-md selected:bg-primary selected:text-primary-foreground disabled:opacity-50")
        .render_item(|item: &CalendarEntry| {
            let label = match item.kind {
                CalendarItemKind::Previous => "‹".to_string(),
                CalendarItemKind::Next => "›".to_string(),
                CalendarItemKind::Weekday => WEEKDAYS[item.value as usize].to_string(),
                CalendarItemKind::MonthToggle | CalendarItemKind::Month => {
                    MONTHS[item.value as usize - 1].to_string()
                }
                _ => item.value.to_string(),
            };
            view()
                .w_8()
                .h_8()
                .items_center()
                .justify_center()
                .child(text(label).text_sm())
        })
}

Calendar は Rust の nsh-base クレートである nsh/base から提供されます。Rust では、calendar() でプロパティごとのセッター(.value(..)、.range(..)、.number_of_months(..))とイベントごとのリスナー(.on_change(..))を持つ Calendar を作成します。その値は Date(Date::Single、Date::Range、または chrono::NaiveDate もしくはその組から作成)で、無効にする日は Matcher です。

#値

値は ISO 形式の日付 "2026-10-28" で、HTML の <input type="date"> の値と同じです。range を指定すると [start, end] となり、null は日付なしを表します。value を指定した場合は、アプリが値を所有します。Calendar はその値を選択済みとして表示し、選択を onChange({ value }) で通知します。アプリは value を設定してそれに追従します。省略した場合、Calendar は自身の値を保持し、最初は defaultValue から始まります。

onChange は、gpui-base の Selected と同様に、完成した値のみを通知します。押された日、または範囲の終端です。

  • range を指定しない場合、日を押すとその日が選択されます。
  • range を指定すると、最初の押下で範囲が始まり、2回目の押下で終わります。その間の日が範囲に入ります(それぞれ selected。両端を含みます。アイテムを参照)。より前の日を2回目に押すと、その日から範囲をやり直します。範囲が完成した後に押した場合も同様です。未完成の範囲は完成するまで表示され、完成して初めて通知されます。

Calendar は、値(範囲の場合は開始日)の月を開き、なければ今日の月を開きます。

#月とビュー

numberOfMonths は、表示中の月から数えてその数の月を横に並べて表示します。Previous と Next は1か月ずつめくります。firstDayOfWeek は週の始まりの曜日で、0 の日曜日(デフォルト)から 6 の土曜日までです。

ヘッダーには、Previous、2つのトグル、Next があります。

  • 日ビューは月の日を表示します。各月は、曜日の行とその週を縦に並べた1列です。前後の月の日が週を埋めます(outside、data-outside)。
  • 月トグル(monthToggle)は月ビューを開きます。12か月を3つずつの行で表示します。1つ選ぶと、その月が日ビューに表示されます。
  • 年トグル(yearToggle)は年ビューを開きます。20年分のページを5つずつの行で表示し、Previous と Next でページをめくります。1つ選ぶと、その年が日ビューに表示されます。yearRange はページが及ぶ年の範囲で、[start, end)(デフォルト:今年の50年前から50年後まで)です。

複数の月を表示する場合、ヘッダーにはトグルの代わりに各月のタイトルが表示されます(押下を受け付けない monthToggle アイテム)。gpui-base と同様です。

#無効な日付

disabledMatcher はユーザーが選べない日で、gpui-base の Matcher と同じく、次の4つの形式のいずれかで指定します。

形式無効にする日
[0, 6]指定した曜日。0 の日曜日から 6 の土曜日まで。
{ before, after }before より前と after より後の日(どちらか一方は省略可)。
{ from, to }from から to までの日(両端を含む。どちらか一方を省略すると、その側は開いた範囲)。
(date) => boolean関数が true を返す日。表示中の各日の ISO 日付が渡されます。

関数は、表示中の各日について次の JS ターンで1回だけ呼ばれ、すでに答えた日については再度呼ばれません。月をめくると新しく現れる日についてのみ問い合わせるため、答えは日付のみに依存する純粋関数でなければなりません。別の関数を渡すと、改めて問い合わせます。無効な日は disabled: で、押下を受け付けず、Return と Space でも選択されません。

#アイテム

Calendar の押下対象とラベルはすべてアイテムです。Previous、トグル、Next、曜日、日、月、年が該当します。各アイテムは itemClass と itemStyle でスタイルを付けるパーツに描画され、そのバリアントはアイテムの状態に従います。

バリアントアイテム
selected:選択されている。日付、完成した範囲のすべての日(React Aria の範囲カレンダーと同様に両端を含む)、未完成の範囲の開始日、表示中の月または年、開いているビューのトグル。スクリーンリーダーは選択済みと読み上げます。
disabled:押下を受け付けない。無効な日付、曜日、月のタイトル、月ビューや年の端での Previous と Next。
focus:キーボードフォーカスを持つ。
data-range-start:、data-range-end:完成した範囲の最初または最後の日(1日だけの範囲は両方)。
data-range-middle:完成した範囲の内側の日(両端を除く)。
data-today:今日。
data-outside:前後の月の日で、週を埋めているもの。

data-* バリアントは Tailwind のもので(data-range-start: は [data-range-start] に一致します)、shadcn の Calendar が日にスタイルを付けるのと同じです。itemClass に group を指定すると、内容は group-selected:、group-data-range-middle: などでそれらに追従します。selected: は範囲のすべての日を意味するため、範囲の両端はその上に自身の見た目を適用し(後のバリアントが優先されます。data-* は selected: の後に来ます)、中間の日はそれぞれの見た目になります。

<Calendar
  range
  itemClass="group rounded-md selected:bg-primary selected:text-primary-foreground data-range-middle:rounded-none data-range-middle:bg-accent data-range-middle:text-accent-foreground data-range-start:rounded-r-none data-range-end:rounded-l-none data-range-start:data-range-end:rounded-md"
/>

renderItem は、アイテムの内容を、そのアイテムが何であるか(CalendarEntry)から描画し、状態からは決して描画しません。描画は一度だけ行われ、アイテムが選択、フォーカス、範囲への包含の状態にある間も保持されるため、キー操作、選択、フォーカスによって再描画されることはありません。月をめくると、新しく現れる日が一度ずつ描画されます(日は月と日付をキーとして描画を保持します。複数の月を表示している場合、引き続き表示されている月はその描画を保持します)。

フィールド意味
kind"previous"、"monthToggle"、"yearToggle"、"next"、"weekday"、"day"、"month"、または "year"。
valueその番号。月の日、月(1〜12)、年、曜日(0 の日曜日から 6 まで)。Previous と Next では 0。
date日の ISO 日付。
year、monthそれが属する年と月。日の場合はその日を週に表示している月、月トグルやタイトルの場合はその月、月ビューの月の場合はその月、年トグルと年の場合はその年自身。Previous、Next、曜日では 0(年トグルと年では month)。
todayその日が今日である。
outsideその日が前後の月のものである。

renderItem がない場合、アイテムはその番号を表示し、Previous と Next は gpui-base と同様に ‹ と › を表示します。曜日も番号(0 から 6)を表示します。月名や曜日名は、アプリの言語によるアプリ自身の言葉だからです。

#プロパティ

プロパティRust型デフォルト説明
value.value(..)CalendarValueなし値。アプリが所有します。
defaultValue.default_value(..)CalendarValuenull最初の値。value を指定しない場合に使います。
range.range(..)booleanfalse範囲を選択します。最初の押下で開始し、2回目の押下で終了します。
numberOfMonths.number_of_months(..)number1横に並べて表示する月の数。
firstDayOfWeek.first_day_of_week(..)number0週の始まりの曜日。0 の日曜日から 6 の土曜日まで。
disabledMatcher.disabled_matcher(..)DateMatcherなし選択できない日付。
yearRange.year_range(..)[number, number]前後50年年ビューがページ送りする年の範囲([start, end))。
previousAccessibilityLabel.previous_accessibility_label(..)stringなしスクリーンリーダーが Previous に対して読み上げる内容。
nextAccessibilityLabel.next_accessibility_label(..)stringなしスクリーンリーダーが Next に対して読み上げる内容。
headerClass、headerStyle.header_class(..)、.header_style(..)string、StyleProp<ViewStyle>なしヘッダー。Previous、トグル(または月のタイトル)、Next。
bodyClass、bodyStyle.body_class(..)、.body_style(..)string、StyleProp<ViewStyle>なしボディ。横に並んだ月、または月ビューや年ビューの行。
monthClass、monthStyle.month_class(..)、.month_style(..)string、StyleProp<ViewStyle>なし日ビューの各月。
rowClass、rowStyle.row_class(..)、.row_style(..)string、StyleProp<ViewStyle>なし各行。曜日、週、月または年の行。
itemClass、itemStyle.item_class(..)、.item_style(..)string、StyleProp<TextStyle>なし各アイテム。selected:、disabled:、focus:、日の data-range-start:、data-range-end:、data-range-middle:、data-today:、data-outside:(アイテム)。
renderItem.render_item(..)(item: CalendarEntry) => JSX.Elementなしアイテムの内容。アイテムが何であるかに基づいてアプリが描画します。指定しない場合はその番号。
class、styleスタイルメソッドstring、StyleProp<ViewStyle>なしCalendar 本体。ヘッダーがボディの上に来ます。

#イベント

イベントRust説明
onChange.on_change(|value| ..)ユーザーが完成した値({ value }、日または範囲)を選択しました。追従するには value を設定します。アプリが渡した value は通知されません。

#パーツ

パーツ場所スタイル指定
Calendarルートclass、style
ヘッダーCalendar の最初headerClass、headerStyle
ボディCalendar 内、ヘッダーの後bodyClass、bodyStyle
月ボディ(日ビュー)monthClass、monthStyle
行月、またはボディ(月ビューと年ビュー)rowClass、rowStyle
アイテムヘッダーと行itemClass、itemStyle、renderItem(内部に group-selected:、group-data-range-middle: など。itemClass の group の下)

#キーボード

日付全体が Tab の1つの停止位置です。Tab は選択された日に移り、なければ今日、それもなければ表示中の最初の日に移ります。その後は次のとおりです。

キー動作
Left / Right前日または翌日。
Up / Down1週間前または1週間後の同じ曜日。
Home / End週の最初または最後の日。
Page Up / Page Down1か月前または1か月後の同じ日。
Shift+Page Up / Shift+Page Down1年前または1年後の同じ日。
Return、Spaceその日を選択します。

右から左へのレイアウトでは週が右から始まるため、Left が翌日、Right が前日になります。Up と Down は週単位のままです。表示中の月を越えて移動すると、月がめくられます。Previous、トグル、Next、月、年は押下を受け付けます。gpui-base の日は押下のみを受け付けます。キー操作は NSH 独自のもので、WAI-ARIA の date picker dialog に倣っています。

#アクセシビリティ

  • 各日は ISO 日付を名前とするボタンで、その日付または範囲の日である間は選択済みとなり(React Aria の範囲カレンダーと同様に両端を含む)、無効の間は薄く表示されます。
  • Previous と Next は previousAccessibilityLabel と nextAccessibilityLabel を名前とするボタンです。トグル、月、年は、表示している内容を名前とするボタンです。

ラベル、ロール、読み上げ全般については アクセシビリティ を参照してください。