Cette page a été traduite automatiquement et peut contenir des erreurs. Lire l'original en anglais

Éléments

Calendar

Un calendrier choisit une date, ou avec range une plage de dates, parmi des mois de jours disposés en semaines : une date d’échéance dans un formulaire, les nuits d’un séjour, les jours d’un rapport. Il affiche un mois ou plusieurs côte à côte, fait défiler les mois avec Précédent et Suivant, et bascule vers une vue des mois de l’année ou une page d’années pour aller plus loin. C’est le calendrier de la fenêtre contextuelle de DatePicker, utilisé seul dans la page.

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);

Le calendrier gère le comportement : la valeur (celle de l’app, ou la sienne), le mois affiché, les vues, les jours qui ne peuvent pas être choisis, le clavier et ce qu’entendent les lecteurs d’écran. L’apparence revient à l’app : elle style l’en-tête, le corps, chaque mois, chaque ligne et chaque item, et dessine le contenu des items (les noms des mois et des jours de la semaine, la marque du jour courant, la bande de la plage). La grille remplit la largeur qui lui est donnée, comme les colonnes d’un tableau : les mois se la partagent et les items d’une ligne se partagent la ligne à parts égales. Ainsi, un calendrier dans la feuille d’un téléphone occupe tout l’écran, et un calendrier dans une fenêtre contextuelle dimensionnée selon son contenu garde la largeur de ses items (flex-none dans itemClass ou monthClass garde un item ou un mois à sa propre largeur).

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 provient de nsh/base, le crate nsh-base en Rust. En Rust, calendar() en construit un avec un setter par prop (.value(..), .range(..), .number_of_months(..)) et un listener par événement (.on_change(..)). Sa valeur est un Date (Date::Single, Date::Range, à partir d’un chrono::NaiveDate ou d’une paire de ceux-ci), et les jours qu’il désactive sont un Matcher.

#Valeur

La valeur est une date ISO, "2026-10-28", comme celle d’un <input type="date"> HTML ; avec range c’est [start, end], et null signifie aucune date. Avec value, l’app la possède : le calendrier l’affiche comme sélectionnée et signale un choix avec onChange({ value }) ; l’app définit value pour la suivre. Si elle est omise, le calendrier garde sa propre valeur, à partir de defaultValue au départ.

onChange ne signale que des valeurs complètes, comme le Selected de gpui-base : un jour pressé, ou la fin d’une plage.

  • Sans range, une pression sur un jour le choisit.
  • Avec range, la première pression commence la plage et la seconde la termine, les jours entre les deux faisant partie de la plage (chacun selected, extrémités comprises, voir Items). Une seconde pression sur un jour antérieur recommence la plage à partir de celui-ci, tout comme une pression une fois la plage complète. Une demi-plage s’affiche jusqu’à ce qu’elle soit complète, et n’est signalée qu’à ce moment-là.

Le calendrier s’ouvre sur le mois de la valeur (le début d’une plage), sinon sur celui d’aujourd’hui.

#Mois et vues

numberOfMonths affiche autant de mois côte à côte, à partir du mois affiché ; Précédent et Suivant les font défiler d’un mois à la fois. firstDayOfWeek est le jour par lequel commencent les semaines, de 0 dimanche (par défaut) à 6 samedi.

L’en-tête contient Précédent, deux bascules et Suivant :

  • La vue des jours affiche les jours des mois, chaque mois formant une colonne avec sa ligne de jours de la semaine et ses semaines ; les jours des mois précédent et suivant complètent les semaines (outside, data-outside).
  • La bascule du mois (monthToggle) ouvre la vue des mois : les douze mois, en lignes de trois. En choisir un l’affiche dans la vue des jours.
  • La bascule de l’année (yearToggle) ouvre la vue des années : une page de 20 années, en lignes de cinq, Précédent et Suivant faisant défiler la page. En choisir une affiche cette année dans la vue des jours. yearRange désigne les années que parcourent les pages, [start, end) (par défaut : de 50 ans avant l’année en cours à 50 ans après).

Avec plusieurs mois, l’en-tête affiche le titre de chaque mois au lieu des bascules (un item monthToggle qui ne prend aucune pression), comme celui de gpui-base.

#Dates désactivées

disabledMatcher désigne les jours que l’utilisateur ne peut pas choisir, comme le Matcher de gpui-base, sous l’une de ces quatre formes :

FormeDésactive
[0, 6]Ces jours de la semaine, 0 dimanche à 6 samedi.
{ before, after }Les jours avant before et après after (l’un ou l’autre peut être omis).
{ from, to }Les jours de from à to, inclus (une borne omise reste ouverte).
(date) => booleanLes jours pour lesquels la fonction renvoie true, à partir de la date ISO de chaque jour affiché.

La fonction est appelée une fois par jour affiché, au prochain tour JS, et pas de nouveau pour un jour auquel elle a déjà répondu : faire défiler les mois ne l’interroge que sur les jours qu’ils apportent, sa réponse doit donc être une fonction pure de la date. Passer une autre fonction relance l’interrogation. Un jour désactivé est disabled: : il ne prend aucune pression, et Retour et Espace ne le choisissent pas.

#Items

Chaque cible de pression et chaque libellé du calendrier est un item : Précédent, les bascules, Suivant, les jours de la semaine, les jours, les mois et les années. Chacun est dessiné dans une partie stylée par itemClass et itemStyle, dont les variantes suivent son état :

VarianteL’item
selected:Est sélectionné : la date, chaque jour d’une plage complète (extrémités comprises, comme le range calendar de React Aria), le début d’une demi-plage, le mois ou l’année affichés, la bascule de la vue ouverte. Les lecteurs d’écran l’annoncent comme sélectionné.
disabled:Ne prend aucune pression : une date désactivée, un jour de la semaine, le titre d’un mois, Précédent et Suivant dans la vue des mois ou aux limites des années.
focus:A le clavier.
data-range-start:, data-range-end:Est le premier ou le dernier jour d’une plage complète (une plage d’un seul jour est les deux).
data-range-middle:Est un jour à l’intérieur d’une plage complète, hors extrémités.
data-today:Est aujourd’hui.
data-outside:Est un jour du mois précédent ou suivant, complétant ses semaines.

Les variantes data-* sont celles de Tailwind (data-range-start: correspond à [data-range-start]), comme le calendrier de shadcn style ses jours ; avec group dans itemClass, le contenu les suit avec group-selected:, group-data-range-middle:, et ainsi de suite. selected: désigne chaque jour d’une plage : les extrémités d’une plage prennent donc leur apparence par-dessus (les dernières variantes l’emportent : data-* vient après selected:), et ses jours intermédiaires la leur :

<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 dessine le contenu d’un item d’après ce qu’il est, un CalendarEntry, jamais d’après son état : le dessin est fait une fois et conservé tant que l’item est sélectionné, focalisé ou placé dans une plage ; ainsi les touches, un choix et le focus ne redessinent rien, et un défilement des mois dessine une fois chacun des jours qu’il apporte (un jour garde son dessin selon son mois et sa date : avec plusieurs mois, les mois toujours affichés gardent le leur).

ChampSignification
kind"previous", "monthToggle", "yearToggle", "next", "weekday", "day", "month" ou "year".
valueSon numéro : le jour du mois, le mois (1 à 12), l’année, le jour de la semaine (0 dimanche à 6) ; 0 pour Précédent et Suivant.
dateLa date ISO d’un jour.
year, monthL’année et le mois auxquels il appartient : ceux d’un jour (le mois dont les semaines l’affichent), ceux de la bascule ou du titre d’un mois, ceux d’un mois de la vue des mois ; l’année propre à la bascule de l’année et à une année. 0 pour Précédent, Suivant et les jours de la semaine (et month pour la bascule de l’année et les années).
todayLe jour est aujourd’hui.
outsideLe jour appartient au mois précédent ou suivant.

Sans renderItem, un item affiche son numéro, et Précédent et Suivant affichent ‹ et ›, comme gpui-base ; un jour de la semaine affiche aussi son numéro, de 0 à 6, puisque les noms des mois et des jours de la semaine sont les mots de l’app, dans la langue de l’app.

#Props

PropRustTypePar défautDescription
value.value(..)CalendarValueaucuneLa valeur, gérée par l’app.
defaultValue.default_value(..)CalendarValuenullLa valeur initiale, sans value.
range.range(..)booleanfalseChoisir une plage : la première pression la commence, la seconde la termine.
numberOfMonths.number_of_months(..)number1Combien de mois s’affichent côte à côte.
firstDayOfWeek.first_day_of_week(..)number0Le jour par lequel commencent les semaines, de 0 dimanche à 6 samedi.
disabledMatcher.disabled_matcher(..)DateMatcheraucuneLes dates qui ne peuvent pas être choisies.
yearRange.year_range(..)[number, number]50 ans de chaque côtéLes années que parcourt la vue des années, [start, end).
previousAccessibilityLabel.previous_accessibility_label(..)stringaucuneCe que disent les lecteurs d’écran pour Précédent.
nextAccessibilityLabel.next_accessibility_label(..)stringaucuneCe que disent les lecteurs d’écran pour Suivant.
headerClass, headerStyle.header_class(..), .header_style(..)string, StyleProp<ViewStyle>aucuneL’en-tête : Précédent, les bascules (ou les titres des mois), Suivant.
bodyClass, bodyStyle.body_class(..), .body_style(..)string, StyleProp<ViewStyle>aucuneLe corps : les mois côte à côte, ou les lignes de la vue des mois ou des années.
monthClass, monthStyle.month_class(..), .month_style(..)string, StyleProp<ViewStyle>aucuneChaque mois de la vue des jours.
rowClass, rowStyle.row_class(..), .row_style(..)string, StyleProp<ViewStyle>aucuneChaque ligne : les jours de la semaine, une semaine, une ligne de mois ou d’années.
itemClass, itemStyle.item_class(..), .item_style(..)string, StyleProp<TextStyle>aucuneChaque item : selected:, disabled:, focus: ; pour un jour, data-range-start:, data-range-end:, data-range-middle:, data-today:, data-outside: (Items).
renderItem.render_item(..)(item: CalendarEntry) => JSX.ElementaucuneLe contenu d’un item, dessiné par l’app d’après ce qu’est l’item ; son numéro sinon.
class, styleméthodes de stylestring, StyleProp<ViewStyle>aucuneLe calendrier : l’en-tête au-dessus du corps.

#Événements

ÉvénementRustDescription
onChange.on_change(|value| ..)L’utilisateur a choisi une valeur complète : { value }, un jour ou une plage. Définissez value pour la suivre. Un value passé par l’app n’est jamais signalé.

#Parties

PartieEmplacementStylée avec
Le calendrierla racineclass, style
L’en-têtele calendrier, en premierheaderClass, headerStyle
Le corpsle calendrier, après l’en-têtebodyClass, bodyStyle
Un moisle corps (vue des jours)monthClass, monthStyle
Une ligneun mois, ou le corps (vues des mois et des années)rowClass, rowStyle
Un iteml’en-tête et les lignesitemClass, itemStyle, renderItem (group-selected:, group-data-range-middle:, … à l’intérieur, sous group dans itemClass)

#Clavier

Les jours forment un seul arrêt de Tab : il atterrit sur le jour sélectionné, sinon sur aujourd’hui, sinon sur le premier jour affiché. Ensuite :

ToucheAction
Gauche / DroiteLe jour précédent ou suivant.
Haut / BasLe même jour une semaine avant ou après.
Début / FinLe premier ou le dernier jour de la semaine.
Page précédente / Page suivanteLe même jour un mois avant ou après.
Maj+Page précédente / Maj+Page suivanteLe même jour un an avant ou après.
Retour, EspaceChoisit le jour.

Dans une disposition de droite à gauche, les semaines courent depuis la droite : Gauche est donc le jour suivant et Droite le jour précédent ; Haut et Bas conservent les semaines. Dépasser les mois affichés les fait défiler. Précédent, les bascules, Suivant, les mois et les années prennent des pressions. Les jours de gpui-base ne prennent que des pressions ; les touches sont celles de NSH, d’après la boîte de dialogue de sélection de date de WAI-ARIA.

#Accessibilité

  • Chaque jour est un bouton nommé par sa date ISO, sélectionné tant qu’il est la date ou un jour de la plage (extrémités comprises, comme le range calendar de React Aria), et atténué lorsqu’il est désactivé.
  • Précédent et Suivant sont des boutons nommés par previousAccessibilityLabel et nextAccessibilityLabel ; les bascules, les mois et les années sont des boutons nommés par ce qu’ils affichent.

Voir Accessibility pour les libellés, les rôles et les annonces en général.