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
7 changes: 6 additions & 1 deletion packages/gamut/src/DatePicker/DatePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const DatePicker: React.FC<DatePickerProps> = (props) => {
const [gridFocusRequested, setGridFocusRequested] = useState(false);
const [activeRangePart, setActiveRangePart] =
useState<DatePickerRangeContextValue['activeRangePart']>(null);
const [hasError, setHasError] = useState(false);
const inputRef = useRef<HTMLDivElement | null>(null);
const dialogId = useId();
const calendarDialogId = `datepicker-dialog-${dialogId.replace(/:/g, '')}`;
Expand Down Expand Up @@ -106,6 +107,8 @@ export const DatePicker: React.FC<DatePickerProps> = (props) => {
disableDate,
translations,
quickActions: quickActions === null ? [] : resolvedQuickActions,
hasError,
setHasError,
};
return mode === 'range'
? {
Expand Down Expand Up @@ -141,6 +144,8 @@ export const DatePicker: React.FC<DatePickerProps> = (props) => {
disableDate,
props,
activeRangePart,
hasError,
setHasError,
]);

const content =
Expand Down Expand Up @@ -182,7 +187,7 @@ export const DatePicker: React.FC<DatePickerProps> = (props) => {
isOpen={isCalendarOpen}
targetRef={inputRef}
x={-20}
y={-16}
y={isCalendarOpen && hasError ? 0 : -16}
onRequestClose={closeCalendar}
>
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,12 @@ export const CalendarBody: React.FC<CalendarBodyProps> = ({
<thead>
<tr>
{weekdayLabels.map((label, i) => (
<TableHeader abbr={weekdayFullNames[i]} key={label} scope="col">
<TableHeader
abbr={weekdayFullNames[i]}
aria-label={weekdayFullNames[i]}
key={label}
scope="col"
>
{label}
</TableHeader>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { createRef } from 'react';
import { getIsoFirstDayFromLocale } from '../../../utils/locale';
import { CalendarBody } from '../CalendarBody';
import { getMonthGrid } from '../utils/dateGrid';
import { formatDateForAriaLabel } from '../utils/format';
import { formatDateForAriaLabel, getWeekdayNames } from '../utils/format';

const displayDate = new Date(2024, 2, 1);
const focusedDate = new Date(2024, 2, 15);
Expand Down Expand Up @@ -203,14 +203,21 @@ describe('CalendarBody', () => {
await waitFor(() => expect(march15).toHaveFocus());
});

it('renders seven weekday column headers with scope and abbreviations', () => {
it('renders seven weekday column headers with full accessible names', () => {
const { view } = renderView();
const locale = new Intl.Locale('en-US');
const firstWeekday = getIsoFirstDayFromLocale(locale);
const fullNames = getWeekdayNames({
format: 'long',
locale,
firstWeekday,
});

const headers = view.getAllByRole('columnheader');
expect(headers).toHaveLength(7);
headers.forEach((th) => {
headers.forEach((th, i) => {
expect(th).toHaveAttribute('scope', 'col');
expect(th).toHaveAttribute('abbr');
expect(th).toHaveAccessibleName(fullNames[i]);
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export function createMockSingleContext(
closeCalendar: jest.fn(),
translations: { ...DEFAULT_DATE_PICKER_TRANSLATIONS },
quickActions: [],
hasError: false,
setHasError: jest.fn(),
selectedDate: new Date(2024, 2, 15),
onSelection: jest.fn(),
...overrides,
Expand All @@ -40,6 +42,8 @@ export function createMockRangeContext(
closeCalendar: jest.fn(),
translations: { ...DEFAULT_DATE_PICKER_TRANSLATIONS },
quickActions: [],
hasError: false,
setHasError: jest.fn(),
startDate: null,
endDate: null,
onRangeSelection: jest.fn(),
Expand Down
9 changes: 9 additions & 0 deletions packages/gamut/src/DatePicker/DatePickerContext/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ interface DatePickerBaseContextValue<Mode extends 'single' | 'range'>
* Discriminator: same meaning as the `mode` prop on `DatePicker` (`"single"` or `"range"`).
*/
mode: Mode;
/**
* Whether there is an error in any input. Used by DatePicker to adjust popover position
* when calendar is open. Only tracks IF error exists, not the message.
*/
hasError: boolean;
/**
* Callback to set whether error exists. Called by DatePickerInput when validation errors occur.
*/
setHasError: (hasError: boolean) => void;
/**
* Resolved `Intl.Locale` for the `locale` prop (or the runtime default). The same object is
* passed to formatters and to APIs such as `getWeekInfo` where available.
Expand Down
128 changes: 99 additions & 29 deletions packages/gamut/src/DatePicker/DatePickerInput/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { MiniCalendarIcon } from '@codecademy/gamut-icons';
import { css } from '@codecademy/gamut-styles';
import styled from '@emotion/styled';
import {
type FocusEvent,
forwardRef,
Expand All @@ -11,6 +13,8 @@
} from 'react';

import { FlexBox } from '../../Box';
import { IconButton } from '../../Button';
import { FormError } from '../../Form/elements/FormError';
import { FormGroup } from '../../Form/elements/FormGroup';
import type { InputWrapperProps } from '../../Form/inputs/Input';
import { isSameDay } from '../DatePickerCalendar/Calendar/utils/dateGrid';
Expand All @@ -31,18 +35,35 @@
getDateFieldOrder,
getDateFormatLayout,
} from './utils';
import { validateSegments } from './utils/validation';

/* FormError's `absolute` variant takes the message out of layout flow (anchored
to FormGroup's position: relative container), so the input never changes height:
the range-mode arrow stays aligned and no space is reserved when there's no error. */
const AbsoluteFormError = styled(FormError)(
css({
// max-content keeps the message on one line rather than wrapping into the
// narrow input width.
width: 'max-content',
// The `absolute` variant anchors at top: calc(100% - 8px), which overlaps the
// bottom of the input. Nudge it down below the input with a small gap.
mt: 12,
})
);

export type DatePickerInputProps = Omit<
InputWrapperProps,
'className' | 'type' | 'icon' | 'value' | 'onChange' | 'color'
'className' | 'type' | 'icon' | 'value' | 'onChange' | 'color' | 'error'
> & {
/** In range mode: which part of the range this input edits. Omit for single-date or combined display. */
rangePart?: 'start' | 'end';
/** Error message to display. String for specific error, undefined for no error. */
error?: string;
};

export const DatePickerInput = forwardRef<HTMLDivElement, DatePickerInputProps>(
(
{ disabled, error, form, label, name, rangePart, size = 'base', ...rest },

Check warning on line 66 in packages/gamut/src/DatePicker/DatePickerInput/index.tsx

View workflow job for this annotation

GitHub Actions / lint (lint)

'error' is defined but never used
ref
) => {
const context = useDatePicker();
Expand All @@ -61,6 +82,7 @@
isCalendarOpen,
translations,
disableDate,
setHasError,
} = context;

const isRange = mode === 'range';
Expand All @@ -87,6 +109,7 @@
[boundDate]
);
const [segments, setSegments] = useState<SegmentValues>(segmentsFromBound);
const [inputError, setInputError] = useState<string>('');

const parsedForHidden = parseSegmentsToDate(segments);
const hiddenValue = parsedForHidden
Expand All @@ -95,6 +118,7 @@

const isInputFocusedRef = useRef(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const segmentElRefs = useRef<
Partial<Record<DatePartKind, HTMLSpanElement | null>>
>({});
Expand Down Expand Up @@ -159,45 +183,86 @@

const onSegmentChange = useCallback(
(next: SegmentValues) => {
const parsed = parseSegmentsToDate(next);
if (parsed) commitParsedDate(parsed);
else if (!next.month && !next.day && !next.year) clearSelection();
const validationResult = validateSegments(
next,
translations,
disableDate
);
if (validationResult.isValid) {
setInputError('');
setHasError(false);
commitParsedDate(validationResult.date);
} else if (!next.month && !next.day && !next.year) {
setInputError('');
setHasError(false);
clearSelection();
} else if (validationResult.errorMessage) {
setInputError(validationResult.errorMessage);
setHasError(true);
}
},
[clearSelection, commitParsedDate]
[clearSelection, commitParsedDate, translations, disableDate, setHasError]
);

const onContainerBlur = useCallback(
(e: FocusEvent<HTMLDivElement>) => {
if (containerRef.current?.contains(e.relatedTarget as Node)) return;
isInputFocusedRef.current = false;
setSegments((prev) => {
const normalized = normalizeSegmentValues(prev);
const parsed = parseSegmentsToDate(normalized);
if (parsed) {
const sameAsBound = isSameDay(parsed, boundDate);
// Check if all segments are empty
if (!prev.month && !prev.day && !prev.year) {
setInputError('');
setHasError(false);
queueMicrotask(() => {
clearSelection();
});
return getDateSegmentsFromDate(null);
}

// Validate raw input (without normalizing/clamping)
const validationResult = validateSegments(
prev,
translations,
disableDate
);

if (validationResult.isValid) {
// Valid complete date - normalize and commit
setInputError('');
setHasError(false);
const normalized = normalizeSegmentValues(prev);
const sameAsBound = isSameDay(validationResult.date, boundDate);
if (isCalendarOpen && !sameAsBound) {
queueMicrotask(() => {
commitParsedDate(parsed);
commitParsedDate(validationResult.date);
});
}
return normalized;
}
if (!normalized.month && !normalized.day && !normalized.year) {
queueMicrotask(() => {
clearSelection();
});
return getDateSegmentsFromDate(null);

// Incomplete entry on blur - show the incomplete-date error
if (!validationResult.errorMessage) {
setInputError(translations.invalidDateIncomplete);
setHasError(true);
return prev; // Keep incomplete input visible so user can correct it
}
return segmentsFromBound;

// Invalid complete entry - show error and keep invalid input visible
setInputError(validationResult.errorMessage);
setHasError(true);
return prev;
});
},
[

Check failure on line 256 in packages/gamut/src/DatePicker/DatePickerInput/index.tsx

View workflow job for this annotation

GitHub Actions / lint (lint)

React Hook useCallback has an unnecessary dependency: 'segmentsFromBound'. Either exclude it or remove the dependency array
containerRef,
boundDate,
segmentsFromBound,
clearSelection,
commitParsedDate,
isCalendarOpen,
translations,
disableDate,
setHasError,
]
);

Expand Down Expand Up @@ -227,7 +292,7 @@

return (
<FormGroup
htmlFor={inputId}
id={inputId}
isSoloField
label={label ?? defaultLabel}
mb={0}
Expand All @@ -235,12 +300,13 @@
spacing="tight"
width="fit-content"
>
<SegmentedShell

Check failure on line 303 in packages/gamut/src/DatePicker/DatePickerInput/index.tsx

View workflow job for this annotation

GitHub Actions / lint (lint)

The attribute aria-invalid is not supported by the role group
id={inputId}
aria-invalid={!!inputError}
aria-labelledby={inputId}
inputSize={size}
ref={shellRef}
role="group"
variant={error ? 'error' : 'default'}
variant={inputError ? 'error' : 'default'}
width="113px"
onBlur={onContainerBlur}
onClick={onShellClick}
Expand Down Expand Up @@ -270,7 +336,7 @@
applySegments={onSegmentChange}
assignSegmentRef={assignSegmentRef}
disabled={!!disabled}
error={!!error}
error={!!inputError}
field={item.field}
key={item.field}
nextField={nextField}
Expand All @@ -292,16 +358,20 @@
type="hidden"
value={hiddenValue}
/>
<FlexBox
alignItems="center"
justifyContent="center"
pl={16}
pr={8}
role="presentation"
>
<MiniCalendarIcon aria-hidden size={16} />
</FlexBox>
<IconButton
mx={4}
icon={MiniCalendarIcon}

Check warning on line 363 in packages/gamut/src/DatePicker/DatePickerInput/index.tsx

View workflow job for this annotation

GitHub Actions / lint (lint)

Props should be sorted alphabetically
size="small"
tip={translations.openCalendarLabel}
ref={buttonRef}

Check warning on line 366 in packages/gamut/src/DatePicker/DatePickerInput/index.tsx

View workflow job for this annotation

GitHub Actions / lint (lint)

Props should be sorted alphabetically
onClick={() => buttonRef.current?.blur()}
/>
</SegmentedShell>
{inputError && (
<AbsoluteFormError aria-live="polite" role="alert" variant="absolute">
{inputError}
</AbsoluteFormError>
)}
</FormGroup>
);
}
Expand Down
Loading
Loading