@codecademy/gamut
68.2.268.2.3-alpha.93a7da.0
dist/DatePicker/DatePickerInput/index.js+
dist/DatePicker/DatePickerInput/index.jsNew file+210
Index: package/dist/DatePicker/DatePickerInput/index.js
===================================================================
--- package/dist/DatePicker/DatePickerInput/index.js
+++ package/dist/DatePicker/DatePickerInput/index.js
@@ -0,0 +1,210 @@
+import { MiniCalendarIcon } from '@codecademy/gamut-icons';
+import { forwardRef, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
+import { FlexBox } from '../../Box';
+import { FormGroup } from '../../Form/elements/FormGroup';
+import { useDatePicker } from '../DatePickerContext';
+import { SegmentedShell } from './elements';
+import { DatePickerInputSegment } from './Segment';
+import { SegmentLiteral } from './Segment/elements';
+import { getDateSegmentsFromDate, normalizeSegmentValues, parseSegmentsToDate } from './Segment/utils';
+import { formatDateISO8601DateOnly, getDateFieldOrder, getDateFormatLayout } from './utils';
+
+/**
+ * Props for DatePickerInput. When used inside DatePicker, only overrides (e.g. placeholder, label).
+ * In range mode, use rangePart to bind to start or end date. When outside DatePicker, pass value, onChange, etc.
+ */
+import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
+export const DatePickerInput = /*#__PURE__*/forwardRef(({
+ disabled,
+ error,
+ form,
+ label,
+ name,
+ rangePart,
+ size = 'base',
+ ...rest
+}, ref) => {
+ const context = useDatePicker();
+ if (context === null) {
+ throw new Error('DatePickerInput must be used inside a DatePicker (it reads shared state from context).');
+ }
+ const {
+ mode,
+ openCalendar,
+ focusCalendar,
+ locale,
+ isCalendarOpen,
+ translations
+ } = context;
+ const isRange = mode === 'range';
+ const endDate = isRange ? context.endDate : null;
+ const date = isRange ? context.startDate : context.selectedDate;
+ const inputID = useId();
+ const inputId = `datepicker-input-${inputID.replace(/:/g, '')}`;
+ const {
+ layout,
+ fieldOrder
+ } = useMemo(() => {
+ const layout = getDateFormatLayout(locale);
+ return {
+ layout,
+ fieldOrder: getDateFieldOrder(layout)
+ };
+ }, [locale]);
+ const firstField = fieldOrder[0];
+ const firstFieldId = `${inputId}-${firstField}`;
+ const defaultLabel = !isRange ? translations.dateLabel : rangePart === 'end' ? translations.endDateLabel : translations.startDateLabel;
+ const boundDate = isRange && rangePart === 'end' ? endDate : date;
+ const segmentsFromBound = useMemo(() => getDateSegmentsFromDate(boundDate), [boundDate]);
+ const [segments, setSegments] = useState(segmentsFromBound);
+ const parsedForHidden = parseSegmentsToDate(segments);
+ const hiddenValue = parsedForHidden ? formatDateISO8601DateOnly(parsedForHidden) : '';
+
+ /** True only while a segment spinbutton is focused — avoids clobbering partial typing. Icon/shell-only focus must not set this or calendar picks won't sync to segments. */
+ const isInputFocusedRef = useRef(false);
+ const containerRef = useRef(null);
+ const segmentElRefs = useRef({});
+ const assignSegmentRef = useCallback((field, el) => {
+ segmentElRefs.current[field] = el;
+ }, [segmentElRefs]);
+ const onSiblingSegmentFocus = useCallback(field => {
+ segmentElRefs.current[field]?.focus();
+ }, [segmentElRefs]);
+ const shellRef = useCallback(el => {
+ containerRef.current = el;
+ if (typeof ref === 'function') ref(el);else if (ref != null) ref.current = el;
+ }, [ref]);
+ useEffect(() => {
+ if (!isInputFocusedRef.current) {
+ setSegments(segmentsFromBound);
+ }
+ }, [segmentsFromBound]);
+ const commitParsedDate = useCallback(parsed => {
+ if (!isRange) {
+ context.onSelection(parsed);
+ }
+ if (isRange && rangePart) {
+ if (rangePart === 'start') context.onRangeSelection(parsed, endDate);else context.onRangeSelection(date, parsed);
+ }
+ }, [isRange, rangePart, context, endDate, date]);
+ const clearSelection = useCallback(() => {
+ if (!isRange) {
+ context.onSelection(null);
+ }
+ if (isRange && rangePart) {
+ if (rangePart === 'start') context.onRangeSelection(null, endDate);else context.onRangeSelection(date, null);
+ }
+ }, [isRange, rangePart, context, endDate, date]);
+ const onSegmentChange = useCallback(next => {
+ const parsed = parseSegmentsToDate(next);
+ if (parsed) commitParsedDate(parsed);else if (!next.month && !next.day && !next.year) clearSelection();
+ }, [clearSelection, commitParsedDate]);
+ const onContainerBlur = useCallback(e => {
+ if (containerRef.current?.contains(e.relatedTarget)) return;
+ isInputFocusedRef.current = false;
+ setSegments(prev => {
+ const normalized = normalizeSegmentValues(prev);
+ const parsed = parseSegmentsToDate(normalized);
+ if (parsed) {
+ commitParsedDate(parsed);
+ return normalized;
+ }
+ if (!normalized.month && !normalized.day && !normalized.year) {
+ clearSelection();
+ return getDateSegmentsFromDate(null);
+ }
+ return segmentsFromBound;
+ });
+ }, [containerRef, segmentsFromBound, clearSelection, commitParsedDate]);
+ const setActiveRangePartForField = useCallback(() => {
+ if (isRange && rangePart) context.setActiveRangePart(rangePart);
+ }, [isRange, rangePart, context]);
+ const onSegmentFocus = useCallback(() => {
+ isInputFocusedRef.current = true;
+ setActiveRangePartForField();
+ }, [isInputFocusedRef, setActiveRangePartForField]);
+
+ /** Focus entered the shell (segment, icon, etc.). Range targeting only — does not mark segment editing. */
+ const onShellFocus = useCallback(() => {
+ setActiveRangePartForField();
+ }, [setActiveRangePartForField]);
+
+ /** Pointer activation on the shell (bubbles from segments/icon). Ensures range targeting even if focus order differs from click. */
+ const onShellClick = useCallback(() => {
+ if (disabled) return;
+ setActiveRangePartForField();
+ openCalendar();
+ }, [disabled, setActiveRangePartForField, openCalendar]);
+ const onSegmentAltArrowDown = useCallback(() => {
+ if (!isCalendarOpen) openCalendar();
+ focusCalendar();
+ }, [isCalendarOpen, openCalendar, focusCalendar]);
+ return /*#__PURE__*/_jsx(FormGroup, {
+ htmlFor: firstFieldId,
+ isSoloField: true,
+ label: label ?? defaultLabel,
+ mb: 0,
+ pb: 0,
+ spacing: "tight",
+ width: "fit-content",
+ children: /*#__PURE__*/_jsxs(SegmentedShell, {
+ inputSize: size,
+ ref: shellRef,
+ role: "group",
+ variant: error ? 'error' : 'default',
+ width: "113px",
+ onBlur: onContainerBlur,
+ onClick: onShellClick,
+ onFocus: onShellFocus,
+ ...rest,
+ children: [/*#__PURE__*/_jsx(FlexBox, {
+ alignItems: "center",
+ justifyContent: "center",
+ children: layout.map((item, index) => {
+ if (item.kind === 'literal') {
+ return /*#__PURE__*/_jsx(SegmentLiteral, {
+ "aria-hidden": true
+ // eslint-disable-next-line react/no-array-index-key
+ ,
+ children: `${item.text}`
+ }, `literal-${item.text}-${index}`);
+ }
+ const idx = fieldOrder.indexOf(item.field);
+ const prevField = idx > 0 ? fieldOrder[idx - 1] : null;
+ const nextField = idx < fieldOrder.length - 1 ? fieldOrder[idx + 1] : null;
+ return /*#__PURE__*/_jsx(DatePickerInputSegment, {
+ applySegments: onSegmentChange,
+ assignSegmentRef: assignSegmentRef,
+ disabled: !!disabled,
+ error: !!error,
+ field: item.field,
+ nextField: nextField,
+ prevField: prevField,
+ segments: segments,
+ setSegments: setSegments,
+ onAltArrowDown: onSegmentAltArrowDown,
+ onFocus: onSegmentFocus,
+ onSiblingFocus: onSiblingSegmentFocus
+ }, item.field);
+ })
+ }), /*#__PURE__*/_jsx("input", {
+ "aria-hidden": true,
+ form: form,
+ name: name,
+ tabIndex: -1,
+ type: "hidden",
+ value: hiddenValue
+ }), /*#__PURE__*/_jsx(FlexBox, {
+ alignItems: "center",
+ justifyContent: "center",
+ pl: 16,
+ pr: 8,
+ role: "presentation",
+ children: /*#__PURE__*/_jsx(MiniCalendarIcon, {
+ "aria-hidden": true,
+ size: 16
+ })
+ })]
+ })
+ });
+});
\ No newline at end of file