@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