Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions packages/gamut/src/DatePicker/Calendar/utils/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,36 @@ export function getWeekdayFullNames(
}
return names;
}

/**
* Format a date for display in the date picker input (e.g. "2/15/2026").
*/
export function formatDateForInput(date: Date, locale?: string): string {
return new Intl.DateTimeFormat(locale ?? 'en-US', {
month: 'numeric',
day: 'numeric',
year: 'numeric',
}).format(date);
}

/**
* Parse a string from the date input into a Date, or null if invalid.
* Only returns a date when the input is a complete valid date (e.g. "2/15/2026").
* Partial input like "1" or "2/15" returns null even though Date("1") would parse.
*/

//this logic needs some work
export function parseDateFromInput(
value: string,
locale?: string
): Date | null {
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = new Date(trimmed);
if (Number.isNaN(parsed.getTime())) return null;
const formatted = formatDateForInput(parsed, locale);
if (formatted === trimmed) return parsed;
const parts = trimmed.split(/[/-]/);
if (parts.length >= 3) return parsed;
return null;
}
1 change: 1 addition & 0 deletions packages/gamut/src/DatePicker/Calendar/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './dateGrid';
export * from './format';
export * from './validation';
23 changes: 23 additions & 0 deletions packages/gamut/src/DatePicker/Calendar/utils/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Validation helpers for DatePicker (single-date).
* Used to mark invalid dates as unselectable and for manual entry validation.
*/

/**
* Check if a date is in the past (before today at start of day).
* Useful for disabling past dates in the calendar.
*/
export function isPastDate(date: Date): boolean {
const today = new Date();
today.setHours(0, 0, 0, 0);
const d = new Date(date);
d.setHours(0, 0, 0, 0);
return d.getTime() < today.getTime();
}

/**
* Check if a date is valid (finite and not NaN).
*/
export function isValidDate(date: Date): boolean {
return date instanceof Date && !Number.isNaN(date.getTime());
}
108 changes: 108 additions & 0 deletions packages/gamut/src/DatePicker/DatePicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react';

import { Box } from '../Box';
import { PopoverContainer } from '../PopoverContainer';
import { DatePickerCalendar } from './DatePickerCalendar';
import { DatePickerProvider } from './DatePickerContext';
import { DatePickerInput } from './DatePickerInput';
import type { DatePickerContextValue, DatePickerProps } from './types';

/**
* Single-date DatePicker. Holds shared state (selectedDate, isCalendarOpen, inputRef)
* and provides it via context. DatePickerInput and DatePickerCalendar own their
* specific state and update this shared state when needed.
* With no children, renders the default layout (input + calendar popover).
* With children, renders only children so you can compose the layout yourself.
*/
export function DatePicker({
selectedDate,
setSelectedDate,
locale = 'en-US',
disabledDates = [],
placeholder,
label,
id,
children,
}: DatePickerProps) {
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null);
const dialogId = useId();
const calendarDialogId = `datepicker-dialog-${dialogId.replace(/:/g, '')}`;

const openCalendar = useCallback(() => setIsCalendarOpen(true), []);
const closeCalendar = useCallback(() => {
setIsCalendarOpen(false);
inputRef.current?.focus();
}, []);

const contextValue = useMemo<DatePickerContextValue>(
() => ({
selectedDate,
setSelectedDate,
isCalendarOpen,
openCalendar,
closeCalendar,
locale,
disabledDates,
calendarDialogId, // do we need this in context? or just pass it as props? does that defeat the purpose of the context?
}),
[
selectedDate,
setSelectedDate,
isCalendarOpen,
openCalendar,
closeCalendar,
locale,
disabledDates,
calendarDialogId,
]
);

useEffect(() => {
if (!isCalendarOpen) return;
const id = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(id);
}, [isCalendarOpen]);

const content =
children !== undefined ? (
children
) : (
<>
<Box width="fit-content">
<DatePickerInput
placeholder={placeholder}
label={label}
id={id}
ref={inputRef}
/>
</Box>
<PopoverContainer
alignment="bottom-left"
invertAxis="x"
offset={10}
allowPageInteraction
isOpen={isCalendarOpen}
onRequestClose={closeCalendar}
targetRef={inputRef}
// look into if we can kill this and mess with where we are focusing instead
focusOnProps={{ autoFocus: false, focusLock: false }} // without this we cant type in the input but there has to be a better way
>
<div aria-label="Choose date" id={calendarDialogId} role="dialog">
<DatePickerCalendar dialogId={calendarDialogId} />
</div>
</PopoverContainer>
</>
);

return (
<DatePickerProvider value={contextValue}>{content}</DatePickerProvider>
);
}
106 changes: 106 additions & 0 deletions packages/gamut/src/DatePicker/DatePickerCalendar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { useEffect, useId, useState } from 'react';

import {
Calendar,
CalendarBody,
CalendarFooter,
CalendarHeader,
} from './Calendar';
import { useDatePicker } from './DatePickerContext';

export type DatePickerCalendarProps = {
/** Optional id for the dialog (for aria-controls from input). */
dialogId?: string;
/** When outside DatePicker, pass weekStartsOn (0 or 1). */
weekStartsOn?: 0 | 1;
};

/**
* Calendar that composes Calendar, CalendarHeader, CalendarBody, CalendarFooter.
* When inside DatePicker: owns local visibleDate and focusedDate; updates shared
* selectedDate via context on select/clear/today. When outside DatePicker: receives
* all props from parent (standalone mode).
*/
export function DatePickerCalendar(props: DatePickerCalendarProps) {
const context = useDatePicker();
const generatedId = useId();
const fallbackDialogId = `datepicker-calendar-${generatedId.replace(
/:/g,
''
)}`;
const headingId =
props.dialogId ?? context?.calendarDialogId ?? fallbackDialogId;

if (context == null) {
throw new Error(
'DatePickerCalendar must be used inside a DatePicker (it reads shared state from context).'
);
}

const { selectedDate, setSelectedDate, disabledDates, locale } = context;

const firstOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1);

const [visibleDate, setVisibleDate] = useState(() =>
firstOfMonth(selectedDate ?? new Date())
);
const [focusedDate, setFocusedDate] = useState<Date | null>(
() => selectedDate ?? new Date()
);

useEffect(() => {
if (selectedDate) {
setVisibleDate(firstOfMonth(selectedDate));
setFocusedDate(selectedDate);
}
}, [selectedDate]);

const handleDateSelect = (date: Date) => {
setSelectedDate(date);
};

const handleClearDate = () => {
setSelectedDate(null);
setFocusedDate(visibleDate);
};

const handleTodayClick = () => {
const today = new Date();
setSelectedDate(today);
setVisibleDate(firstOfMonth(today));
setFocusedDate(today);
};

const weekStartsOn = (props.weekStartsOn ?? 0) as 0 | 1;

return (
<Calendar>
<CalendarHeader
currentMonthYear={visibleDate}
onCurrentMonthYearChange={setVisibleDate}
locale={locale}
headingId={headingId}
/>
<CalendarBody
visibleDate={visibleDate}
selectedDate={selectedDate}
onDateSelect={handleDateSelect}
disabledDates={disabledDates}
focusedDate={focusedDate ?? selectedDate ?? new Date()}
onFocusedDateChange={setFocusedDate}
onVisibleDateChange={setVisibleDate}
labelledById={headingId}
locale={locale}
weekStartsOn={weekStartsOn}
/>
<CalendarFooter
onSelectedDateChange={(date) =>
date === null ? handleClearDate() : handleDateSelect(date)
}
onCurrentMonthYearChange={setVisibleDate}
onClearDate={handleClearDate}
onTodayClick={handleTodayClick}
/>
</Calendar>
);
}
22 changes: 22 additions & 0 deletions packages/gamut/src/DatePicker/DatePickerContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { createContext, useContext } from 'react';

import type { DatePickerContextValue as DatePickerContextValueType } from './types';

export const DatePickerContext =
createContext<DatePickerContextValueType | null>(null);

/** Provider component; DatePicker uses this to set the context value. */
export const DatePickerProvider = DatePickerContext.Provider;

/**
* Returns the DatePicker context value (shared state and callbacks).
* Must be used inside a DatePicker. For composed layouts, use this to get
* openCalendar, closeCalendar, isCalendarOpen, inputRef, calendarDialogId, etc.
*/
export function useDatePicker(): DatePickerContextValueType {
const value = useContext(DatePickerContext);
if (value == null) {
throw new Error('useDatePickerContext must be used within a DatePicker.');
}
return value;
}
Loading
Loading