Calendar
Date picker calendar surface.
Installation
$
npx @309-thingspire/ui@latest add calendarUsage
import { Calendar } from "@/components/calendar/calendar"<Calendar />Examples
Live preview rendered from Calendar.preview.tsx. Switch to the Code tab to view the underlying component source.
Loading preview…
import React, { useEffect, useMemo, useState } from 'react';
import { border, colors, radius, shadows, spacing, typography } from '../../style-tokens';
import type {
CalendarDayState,
CalendarMonthChangePayload,
CalendarProps,
CalendarRangeDay,
CalendarRangeSelection,
CalendarType,
} from './Calendar.types';
type TypographyToken = {
fontFamily: string;
fontSize: number;
fontWeight: number;
lineHeight: number;
letterSpacing: number;
};
type CalendarDayCell = {
key: string;
date: Date;
inCurrentMonth: boolean;
};
const WEEKDAY_LABELS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'] as const;
const CALENDAR_WIDTH = spacing.scale['288'] - spacing.scale['4'];
const DATE_CELL_SIZE = spacing.scale['36'];
const DATE_CELL_INSET = spacing.scale['2'];
const RANGE_FILL_HEIGHT = spacing.scale['32'];
const RANGE_FILL_EDGE = spacing.scale['2'];
const HEADER_ICON_SIZE = spacing.scale['20'];
const ARROW_LEFT_PATH = 'M2.35667 5.30333L6.48167 9.42833L5.30333 10.6067L0 5.30333L5.30333 0L6.48167 1.17833L2.35667 5.30333Z';
const ARROW_RIGHT_PATH = 'M4.125 5.30333L0 1.17833L1.17833 0L6.48167 5.30333L1.17833 10.6067L0 9.42833L4.125 5.30333Z';
const palette = colors.primitive.palette;
const textBase = colors.semantic.theme.text.base;
const textAccent = colors.semantic.theme.text.accent;
function toTypographyStyle(token: TypographyToken) {
return {
fontFamily: token.fontFamily,
fontSize: token.fontSize,
fontWeight: token.fontWeight,
lineHeight: `${token.lineHeight}px`,
letterSpacing: `${token.letterSpacing}px`,
};
}
function atStartOfDay(value: Date): Date {
return new Date(value.getFullYear(), value.getMonth(), value.getDate());
}
function areSameDate(a: Date | null | undefined, b: Date | null | undefined): boolean {
if (!a || !b) {
return false;
}
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
function compareDate(a: Date, b: Date): number {
const aTime = atStartOfDay(a).getTime();
const bTime = atStartOfDay(b).getTime();
if (aTime === bTime) {
return 0;
}
return aTime < bTime ? -1 : 1;
}
function normalizeRange(range: CalendarRangeSelection): CalendarRangeSelection {
if (!range.start || !range.end) {
return range;
}
return compareDate(range.start, range.end) <= 0
? range
: {
start: range.end,
end: range.start,
};
}
function isWithinRange(date: Date, range: CalendarRangeSelection): boolean {
if (!range.start || !range.end) {
return false;
}
const target = atStartOfDay(date).getTime();
const start = atStartOfDay(range.start).getTime();
const end = atStartOfDay(range.end).getTime();
return target >= start && target <= end;
}
function getRangeDayPosition(date: Date, range: CalendarRangeSelection): CalendarRangeDay {
if (!range.start || !range.end) {
return 'middle';
}
if (areSameDate(range.start, range.end)) {
return 'first';
}
if (areSameDate(date, range.start)) {
return 'first';
}
if (areSameDate(date, range.end)) {
return 'last';
}
return 'middle';
}
function formatMonthLabel(date: Date, locale: string): string {
return new Intl.DateTimeFormat(locale, {
month: 'long',
year: 'numeric',
}).format(date);
}
function formatDateInput(date: Date): string {
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const year = String(date.getFullYear());
return `${month}/${day}/${year}`;
}
function buildMonthGrid(year: number, month: number): CalendarDayCell[][] {
const firstDayOfMonth = new Date(year, month, 1);
const firstWeekday = firstDayOfMonth.getDay();
const monthLength = new Date(year, month + 1, 0).getDate();
const previousMonthLength = new Date(year, month, 0).getDate();
const entries: CalendarDayCell[] = [];
for (let index = 0; index < firstWeekday; index += 1) {
const day = previousMonthLength - firstWeekday + index + 1;
const date = new Date(year, month - 1, day);
entries.push({
key: `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`,
date,
inCurrentMonth: false,
});
}
for (let day = 1; day <= monthLength; day += 1) {
const date = new Date(year, month, day);
entries.push({
key: `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`,
date,
inCurrentMonth: true,
});
}
while (entries.length % WEEKDAY_LABELS.length !== 0) {
const offset = entries.length - (firstWeekday + monthLength) + 1;
const date = new Date(year, month + 1, offset);
entries.push({
key: `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`,
date,
inCurrentMonth: false,
});
}
const weeks: CalendarDayCell[][] = [];
for (let start = 0; start < entries.length; start += WEEKDAY_LABELS.length) {
weeks.push(entries.slice(start, start + WEEKDAY_LABELS.length));
}
return weeks;
}
function ArrowIcon({ direction }: { direction: 'left' | 'right' }) {
return (
<svg
aria-hidden="true"
viewBox="0 0 6.48167 10.6067"
style={{
width: HEADER_ICON_SIZE,
height: HEADER_ICON_SIZE,
display: 'block',
color: textBase.staticDarkSecondary,
}}
>
<path d={direction === 'left' ? ARROW_LEFT_PATH : ARROW_RIGHT_PATH} fill="currentColor" />
</svg>
);
}
function FooterField({ label, value, showLabel }: { label: string; value: string; showLabel: boolean }) {
return (
<div
style={{
display: 'flex',
flex: '1 0 0',
minWidth: spacing.scale['144'],
minHeight: spacing.scale['0'],
flexDirection: 'column',
alignItems: 'flex-start',
gap: spacing.scale['8'],
}}
>
{showLabel ? (
<div
style={{
width: '100%',
display: 'flex',
alignItems: 'flex-start',
gap: spacing.scale['0'],
padding: spacing.scale['0'],
}}
>
<div
style={{
display: 'flex',
alignItems: 'flex-start',
gap: spacing.scale['4'],
paddingInline: spacing.scale['0'],
paddingBlock: spacing.scale['2'],
}}
>
<span
style={{
color: textBase.staticDark,
...toTypographyStyle(typography.scale.captionL.medium),
whiteSpace: 'nowrap',
}}
>
{label}
</span>
</div>
</div>
) : null}
<div
style={{
width: '100%',
display: 'flex',
alignItems: 'flex-start',
gap: spacing.scale['0'],
boxShadow: shadows.elevation.xs.css,
}}
>
<div
style={{
display: 'flex',
flex: '1 0 0',
alignItems: 'center',
gap: spacing.scale['0'],
minWidth: spacing.scale['0'],
minHeight: spacing.scale['0'],
paddingInline: spacing.scale['8'],
paddingBlock: spacing.scale['6'],
borderStyle: 'solid',
borderWidth: border.width['1'],
borderColor: palette.gray['3'],
borderRadius: radius.scale.lg,
backgroundColor: palette.base.white,
overflow: 'hidden',
boxSizing: 'border-box',
}}
>
<div
style={{
display: 'flex',
flex: '1 0 0',
alignItems: 'center',
gap: spacing.scale['4'],
minWidth: spacing.scale['0'],
minHeight: spacing.scale['0'],
}}
>
<div
style={{
display: 'flex',
flex: '1 0 0',
alignItems: 'center',
gap: spacing.scale['2'],
minWidth: spacing.scale['0'],
minHeight: spacing.scale['0'],
}}
>
<div
style={{
display: 'flex',
flex: '1 0 0',
alignItems: 'center',
gap: spacing.scale['0'],
minWidth: spacing.scale['0'],
minHeight: spacing.scale['0'],
paddingInline: spacing.scale['4'],
paddingBlock: spacing.scale['0'],
}}
>
<span
style={{
color: textBase.staticDark,
...toTypographyStyle(typography.scale.captionL.regular),
whiteSpace: 'nowrap',
}}
>
{value}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
function ActionButton({
label,
appearance,
onClick,
}: {
label: string;
appearance: 'primary' | 'secondary';
onClick?: () => void;
}) {
const isPrimary = appearance === 'primary';
return (
<button
type="button"
onClick={onClick}
style={{
display: 'flex',
flex: '1 0 0',
height: spacing.scale['32'],
alignItems: 'center',
justifyContent: 'center',
gap: spacing.scale['2'],
minWidth: spacing.scale['0'],
minHeight: spacing.scale['0'],
paddingInline: spacing.scale['10'],
paddingBlock: spacing.scale['6'],
borderStyle: 'solid',
borderWidth: border.width['1'],
borderColor: isPrimary ? palette.gray['13'] : palette.gray['3'],
borderRadius: radius.scale.lg,
backgroundColor: isPrimary ? palette.gray['13'] : palette.base.white,
color: isPrimary ? palette.base.white : textBase.staticDark,
boxShadow: shadows.elevation.xs.css,
cursor: 'pointer',
}}
>
<span
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
paddingInline: spacing.scale['4'],
paddingBlock: spacing.scale['0'],
...toTypographyStyle(typography.scale.captionL.medium),
whiteSpace: 'nowrap',
}}
>
{label}
</span>
</button>
);
}
function CalendarFooter({
type,
showTimeSelection,
selectedDate,
selectedRange,
dateTime,
rangeStartTime,
rangeEndTime,
onCancel,
onApply,
}: {
type: CalendarType;
showTimeSelection: boolean;
selectedDate: Date | null;
selectedRange: CalendarRangeSelection;
dateTime: string;
rangeStartTime: string;
rangeEndTime: string;
onCancel?: () => void;
onApply?: () => void;
}) {
const dateValue = selectedDate ? formatDateInput(selectedDate) : '--/--/----';
const rangeStartValue = selectedRange.start ? formatDateInput(selectedRange.start) : '--/--/----';
const rangeEndValue = selectedRange.end ? formatDateInput(selectedRange.end) : '--/--/----';
if (type === 'date') {
return (
<div
style={{
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: spacing.scale['16'],
}}
>
<div
style={{
width: '100%',
display: 'flex',
alignItems: 'flex-end',
gap: spacing.scale['8'],
}}
>
<FooterField label="Date" value={dateValue} showLabel />
{showTimeSelection ? <FooterField label="Time" value={dateTime} showLabel /> : null}
</div>
<div
style={{
width: '100%',
display: 'flex',
alignItems: 'flex-start',
gap: spacing.scale['8'],
}}
>
<ActionButton label="Cancel" appearance="secondary" onClick={onCancel} />
<ActionButton label="Apply" appearance="primary" onClick={onApply} />
</div>
</div>
);
}
return (
<div
style={{
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: spacing.scale['16'],
}}
>
<div
style={{
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: spacing.scale['8'],
}}
>
<div
style={{
width: '100%',
display: 'flex',
alignItems: 'flex-end',
gap: spacing.scale['8'],
}}
>
<FooterField label="Start" value={rangeStartValue} showLabel />
{showTimeSelection ? <FooterField label="Time" value={rangeStartTime} showLabel={false} /> : null}
</div>
<div
style={{
width: '100%',
display: 'flex',
alignItems: 'flex-end',
gap: spacing.scale['8'],
}}
>
<FooterField label="End" value={rangeEndValue} showLabel />
{showTimeSelection ? <FooterField label="Time" value={rangeEndTime} showLabel={false} /> : null}
</div>
</div>
<div
style={{
width: '100%',
display: 'flex',
alignItems: 'flex-start',
gap: spacing.scale['8'],
}}
>
<ActionButton label="Cancel" appearance="secondary" onClick={onCancel} />
<ActionButton label="Apply" appearance="primary" onClick={onApply} />
</div>
</div>
);
}
export function Calendar({
type = 'date',
month,
year,
selectedDate,
selectedRange,
currentDate = new Date(),
showTimeSelection = true,
dateTime = '10:39 PM',
rangeStartTime = '10:39 PM',
rangeEndTime = '09:12 AM',
locale = 'en-US',
disabledDate,
onSelectDate,
onSelectRange,
onMonthChange,
onApply,
onCancel,
className,
style,
...props
}: CalendarProps) {
const fallbackViewDate = useMemo(() => atStartOfDay(currentDate), [currentDate]);
const [viewMonth, setViewMonth] = useState(month ?? fallbackViewDate.getMonth());
const [viewYear, setViewYear] = useState(year ?? fallbackViewDate.getFullYear());
const [internalSelectedDate, setInternalSelectedDate] = useState<Date | null>(selectedDate ?? null);
const [internalSelectedRange, setInternalSelectedRange] = useState<CalendarRangeSelection>(
selectedRange ?? {
start: null,
end: null,
},
);
const [hoverDate, setHoverDate] = useState<Date | null>(null);
const [focusedKey, setFocusedKey] = useState<string | null>(null);
useEffect(() => {
if (typeof month === 'number') {
setViewMonth(month);
}
}, [month]);
useEffect(() => {
if (typeof year === 'number') {
setViewYear(year);
}
}, [year]);
useEffect(() => {
if (selectedDate !== undefined) {
setInternalSelectedDate(selectedDate);
}
}, [selectedDate]);
useEffect(() => {
if (selectedRange !== undefined) {
setInternalSelectedRange(selectedRange);
}
}, [selectedRange]);
const resolvedDate = selectedDate !== undefined ? selectedDate : internalSelectedDate;
const resolvedRange = normalizeRange(selectedRange !== undefined ? selectedRange : internalSelectedRange);
const rangePreview = useMemo(() => {
if (type !== 'dateRange') {
return null;
}
if (!resolvedRange.start || resolvedRange.end || !hoverDate) {
return null;
}
return normalizeRange({
start: resolvedRange.start,
end: hoverDate,
});
}, [hoverDate, resolvedRange, type]);
const displayedRange = useMemo(() => {
if (rangePreview) {
return rangePreview;
}
if (type === 'dateRange' && resolvedRange.start && !resolvedRange.end) {
return {
start: resolvedRange.start,
end: resolvedRange.start,
};
}
return resolvedRange;
}, [rangePreview, resolvedRange, type]);
const weeks = useMemo(() => buildMonthGrid(viewYear, viewMonth), [viewMonth, viewYear]);
const monthLabel = useMemo(() => formatMonthLabel(new Date(viewYear, viewMonth, 1), locale), [locale, viewMonth, viewYear]);
const handleMonthShift = (direction: 'prev' | 'next') => {
const offset = direction === 'prev' ? -1 : 1;
const shifted = new Date(viewYear, viewMonth + offset, 1);
const payload: CalendarMonthChangePayload = {
month: shifted.getMonth(),
year: shifted.getFullYear(),
};
setViewMonth(payload.month);
setViewYear(payload.year);
onMonthChange?.(payload);
};
const commitDateSelection = (value: Date) => {
if (selectedDate === undefined) {
setInternalSelectedDate(value);
}
onSelectDate?.(value);
};
const commitRangeSelection = (value: CalendarRangeSelection) => {
const normalized = normalizeRange(value);
if (selectedRange === undefined) {
setInternalSelectedRange(normalized);
}
onSelectRange?.(normalized);
};
const handleDateClick = (date: Date, isDisabled: boolean) => {
if (isDisabled) {
return;
}
const nextDate = atStartOfDay(date);
if (type === 'date') {
commitDateSelection(nextDate);
return;
}
const range = normalizeRange(resolvedRange);
if (!range.start || range.end) {
commitRangeSelection({
start: nextDate,
end: null,
});
return;
}
if (compareDate(nextDate, range.start) < 0) {
commitRangeSelection({
start: nextDate,
end: range.start,
});
return;
}
commitRangeSelection({
start: range.start,
end: nextDate,
});
};
return (
<section
className={className}
style={{
width: CALENDAR_WIDTH,
maxWidth: spacing.scale['480'],
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: spacing.scale['12'],
padding: spacing.scale['12'],
borderStyle: 'solid',
borderWidth: border.width['1'],
borderColor: palette.gray['3'],
borderRadius: radius.scale.xl,
backgroundColor: palette.base.white,
boxShadow: shadows.elevation.lg.css,
boxSizing: 'border-box',
...style,
}}
{...props}
>
<header
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: spacing.scale['12'],
}}
>
<div
style={{
display: 'flex',
flex: '1 0 0',
minWidth: spacing.scale['0'],
minHeight: spacing.scale['0'],
alignItems: 'flex-start',
gap: spacing.scale['0'],
padding: spacing.scale['0'],
}}
>
<div
style={{
display: 'flex',
alignItems: 'flex-start',
gap: spacing.scale['4'],
paddingInline: spacing.scale['0'],
paddingBlock: spacing.scale['2'],
}}
>
<span
style={{
color: textBase.staticDark,
...toTypographyStyle(typography.scale.captionL.medium),
whiteSpace: 'nowrap',
}}
>
{monthLabel}
</span>
</div>
</div>
<div
style={{
display: 'flex',
alignItems: 'flex-start',
gap: spacing.scale['8'],
}}
>
<button
type="button"
aria-label="Previous month"
onClick={() => handleMonthShift('prev')}
style={{
width: HEADER_ICON_SIZE,
height: HEADER_ICON_SIZE,
display: 'grid',
placeItems: 'center',
backgroundColor: palette.base.transparent,
borderStyle: 'solid',
borderWidth: border.width['0'],
borderColor: palette.base.transparent,
padding: spacing.scale['0'],
margin: spacing.scale['0'],
cursor: 'pointer',
}}
>
<ArrowIcon direction="left" />
</button>
<button
type="button"
aria-label="Next month"
onClick={() => handleMonthShift('next')}
style={{
width: HEADER_ICON_SIZE,
height: HEADER_ICON_SIZE,
display: 'grid',
placeItems: 'center',
backgroundColor: palette.base.transparent,
borderStyle: 'solid',
borderWidth: border.width['0'],
borderColor: palette.base.transparent,
padding: spacing.scale['0'],
margin: spacing.scale['0'],
cursor: 'pointer',
}}
>
<ArrowIcon direction="right" />
</button>
</div>
</header>
<div
style={{
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: spacing.scale['4'],
}}
onMouseLeave={() => {
if (type === 'dateRange') {
setHoverDate(null);
}
}}
>
<div
style={{
width: '100%',
display: 'grid',
gridTemplateColumns: `repeat(${WEEKDAY_LABELS.length}, minmax(0, 1fr))`,
}}
>
{WEEKDAY_LABELS.map((label, index) => (
<div
key={`${label}-${index}`}
style={{
display: 'flex',
flex: '1 0 0',
minWidth: spacing.scale['0'],
minHeight: spacing.scale['0'],
height: spacing.scale['32'],
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.scale.md,
backgroundColor: palette.base.transparent,
}}
>
<span
style={{
color: textBase.staticDarkSecondary,
...toTypographyStyle(typography.scale.captionM.regular),
textAlign: 'center',
whiteSpace: 'nowrap',
}}
>
{label}
</span>
</div>
))}
</div>
<div
style={{
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
}}
>
{weeks.map((week) => (
<div
key={week.map((day) => day.key).join('|')}
style={{
width: '100%',
display: 'grid',
gridTemplateColumns: `repeat(${WEEKDAY_LABELS.length}, minmax(0, 1fr))`,
}}
>
{week.map((day) => {
const outOfMonth = !day.inCurrentMonth;
const disabledByRule = disabledDate?.(day.date) ?? false;
const isDisabled = outOfMonth || disabledByRule;
const isCurrentDate = areSameDate(day.date, currentDate);
const dateKey = day.key;
let state: CalendarDayState = 'default';
let rangeDay: CalendarRangeDay = 'middle';
if (type === 'date') {
if (areSameDate(day.date, resolvedDate)) {
state = 'active';
}
} else {
const inRange = isWithinRange(day.date, displayedRange);
if (inRange) {
state = rangePreview ? 'hover' : 'active';
rangeDay = getRangeDayPosition(day.date, displayedRange);
}
}
if (isDisabled) {
state = 'disabled';
}
const isFocused = focusedKey === dateKey;
const shouldRenderRangeFill = type === 'dateRange' && (state === 'hover' || state === 'active');
const isRangeEdge = type === 'dateRange' && (rangeDay === 'first' || rangeDay === 'last');
const isRangeMiddle = type === 'dateRange' && rangeDay === 'middle';
const textColor = (() => {
if (state === 'disabled') {
return textBase.staticDarkQuaternary;
}
if (isCurrentDate) {
return textAccent.purpleAccent;
}
if (state === 'active' && type === 'date') {
return textBase.staticDark;
}
if (isRangeEdge && (state === 'active' || state === 'hover')) {
return textBase.staticDark;
}
return textBase.staticDarkSecondary;
})();
const innerBackgroundColor = (() => {
if (type === 'date' && state === 'active') {
return palette.base.white;
}
if (isRangeEdge && (state === 'active' || state === 'hover')) {
return palette.base.white;
}
return palette.base.transparent;
})();
const innerBorderWidth = (() => {
if (type === 'date' && state === 'active') {
return border.width['1'];
}
if (isRangeEdge && (state === 'active' || state === 'hover')) {
return border.width['1'];
}
return border.width['0'];
})();
const innerBorderColor = palette.purple['8'];
const innerRadius = (() => {
if (type === 'date') {
return {
borderTopLeftRadius: radius.scale.md,
borderTopRightRadius: radius.scale.md,
borderBottomRightRadius: radius.scale.md,
borderBottomLeftRadius: radius.scale.md,
};
}
if (isRangeMiddle && (state === 'active' || state === 'hover')) {
return {
borderTopLeftRadius: radius.scale['0'],
borderTopRightRadius: radius.scale['0'],
borderBottomRightRadius: radius.scale['0'],
borderBottomLeftRadius: radius.scale['0'],
};
}
if (rangeDay === 'first' && (state === 'active' || state === 'hover')) {
return {
borderTopLeftRadius: radius.scale.md,
borderTopRightRadius: radius.scale['0'],
borderBottomRightRadius: radius.scale['0'],
borderBottomLeftRadius: radius.scale.md,
};
}
if (rangeDay === 'last' && (state === 'active' || state === 'hover')) {
return {
borderTopLeftRadius: radius.scale['0'],
borderTopRightRadius: radius.scale.md,
borderBottomRightRadius: radius.scale.md,
borderBottomLeftRadius: radius.scale['0'],
};
}
return {
borderTopLeftRadius: radius.scale.md,
borderTopRightRadius: radius.scale.md,
borderBottomRightRadius: radius.scale.md,
borderBottomLeftRadius: radius.scale.md,
};
})();
const fillStyle = (() => {
if (!shouldRenderRangeFill) {
return null;
}
if (rangeDay === 'first') {
return {
left: RANGE_FILL_EDGE,
right: spacing.scale['0'],
borderTopLeftRadius: radius.scale.md,
borderBottomLeftRadius: radius.scale.md,
borderTopRightRadius: radius.scale['0'],
borderBottomRightRadius: radius.scale['0'],
};
}
if (rangeDay === 'last') {
return {
left: spacing.scale['0'],
right: RANGE_FILL_EDGE,
borderTopLeftRadius: radius.scale['0'],
borderBottomLeftRadius: radius.scale['0'],
borderTopRightRadius: radius.scale.md,
borderBottomRightRadius: radius.scale.md,
};
}
return {
left: spacing.scale['0'],
right: spacing.scale['0'],
borderTopLeftRadius: radius.scale['0'],
borderBottomLeftRadius: radius.scale['0'],
borderTopRightRadius: radius.scale['0'],
borderBottomRightRadius: radius.scale['0'],
};
})();
return (
<button
key={dateKey}
type="button"
disabled={isDisabled}
onClick={() => handleDateClick(day.date, isDisabled)}
onMouseEnter={() => {
if (type === 'dateRange' && resolvedRange.start && !resolvedRange.end && !isDisabled) {
setHoverDate(day.date);
}
}}
onFocus={() => setFocusedKey(dateKey)}
onBlur={() => setFocusedKey((previous) => (previous === dateKey ? null : previous))}
style={{
position: 'relative',
width: DATE_CELL_SIZE,
height: DATE_CELL_SIZE,
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'flex-start',
padding: DATE_CELL_INSET,
margin: spacing.scale['0'],
backgroundColor: palette.base.transparent,
borderStyle: 'solid',
borderWidth: border.width['0'],
borderColor: palette.base.transparent,
cursor: isDisabled ? 'default' : 'pointer',
boxShadow: isFocused ? shadows.focusRing.light.css : 'none',
boxSizing: 'border-box',
}}
>
{fillStyle ? (
<span
aria-hidden="true"
style={{
position: 'absolute',
top: `calc(50% - ${RANGE_FILL_HEIGHT / 2}px)`,
height: RANGE_FILL_HEIGHT,
backgroundColor: palette.gray['1a'],
...fillStyle,
}}
/>
) : null}
<span
style={{
position: 'relative',
display: 'flex',
flex: '1 0 0',
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
minWidth: spacing.scale['0'],
minHeight: spacing.scale['0'],
borderStyle: 'solid',
borderWidth: innerBorderWidth,
borderColor: innerBorderColor,
backgroundColor: innerBackgroundColor,
...innerRadius,
boxSizing: 'border-box',
}}
>
<span
style={{
width: '100%',
color: textColor,
...toTypographyStyle(typography.scale.captionL.regular),
textAlign: 'center',
}}
>
{day.date.getDate()}
</span>
</span>
</button>
);
})}
</div>
))}
</div>
</div>
<CalendarFooter
type={type}
showTimeSelection={showTimeSelection}
selectedDate={resolvedDate}
selectedRange={resolvedRange}
dateTime={dateTime}
rangeStartTime={rangeStartTime}
rangeEndTime={rangeEndTime}
onCancel={onCancel}
onApply={onApply}
/>
</section>
);
}
API Reference
Props 문서 준비 중입니다. (component-spec.json 추가 시 표시됩니다.)