@codecademy/gamut

68.2.268.2.3-alpha.794432.0
dist/DatePicker/DatePickerInput.js
+dist/DatePicker/DatePickerInput.jsNew file
+134
Index: package/dist/DatePicker/DatePickerInput.js
===================================================================
--- package/dist/DatePicker/DatePickerInput.js
+++ package/dist/DatePicker/DatePickerInput.js
@@ -0,0 +1,134 @@
+import { MiniCalendarIcon } from '@codecademy/gamut-icons';
+import { forwardRef, useEffect, useId, useRef, useState } from 'react';
+import { FormGroup } from '../Form/elements/FormGroup';
+import { Input } from '../Form/inputs/Input';
+import { formatDateForInput, getDateFormatPattern, parseDateFromInput } from './Calendar/utils/format';
+import { useDatePicker } from './DatePickerContext';
+
+/**
+ * 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 } from "react/jsx-runtime";
+/**
+ * Date input. When inside DatePicker: owns local input value state and syncs to
+ * shared selectedDate via context on blur/parse; opens calendar on click/arrow down.
+ * When outside DatePicker: fully controlled by props.
+ */
+export const DatePickerInput = /*#__PURE__*/forwardRef(({
+  placeholder,
+  label,
+  rangePart,
+  ...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,
+    startOrSelectedDate,
+    setSelection,
+    openCalendar,
+    focusCalendarGrid,
+    locale,
+    isCalendarOpen,
+    calendarDialogId,
+    translations
+  } = context;
+  const isRange = mode === 'range';
+  const inputID = useId();
+  const inputId = `datepicker-input-${inputID.replace(/:/g, '')}`;
+
+  // Range with two inputs: each input binds to one part. Single or range combined: one value.
+  const boundDate = isRange && rangePart === 'end' ? context.endDate : startOrSelectedDate;
+  const formattedValue = boundDate != null ? formatDateForInput(boundDate, locale) : '';
+  const [inputValue, setInputValue] = useState(() => formattedValue);
+  const isInputFocusedRef = useRef(false);
+
+  // Sync input from shared state. Skip when focused so we don't overwrite while typing.
+  useEffect(() => {
+    if (!isInputFocusedRef.current) {
+      setInputValue(formattedValue);
+    }
+  }, [formattedValue]);
+
+  /** Apply raw input string to selection state. Returns formatted string if parsed so caller can sync input (e.g. on blur). */
+  const applyValueToSelection = raw => {
+    const trimmed = raw.trim();
+    if (!trimmed) {
+      if (isRange && rangePart) {
+        if (rangePart === 'start') setSelection(null, context.endDate);else setSelection(startOrSelectedDate, null);
+      } else setSelection(null);
+      return undefined;
+    }
+    const parsed = parseDateFromInput(trimmed, locale);
+    if (!parsed) return undefined;
+    if (isRange && rangePart) {
+      if (rangePart === 'start') setSelection(parsed, context.endDate);else setSelection(startOrSelectedDate, parsed);
+    } else setSelection(parsed);
+    return formatDateForInput(parsed, locale);
+  };
+  const handleChange = e => {
+    const raw = e.target.value;
+    setInputValue(raw);
+    applyValueToSelection(raw);
+  };
+  const handleBlur = () => {
+    isInputFocusedRef.current = false;
+    const formatted = applyValueToSelection(inputValue.trim());
+    if (formatted) setInputValue(formatted);else if (inputValue.trim()) setInputValue(formattedValue);
+  };
+  const handleKeyDown = e => {
+    if (e.key === 'ArrowDown' || e.key === 'Down') {
+      e.preventDefault();
+      if (isCalendarOpen) {
+        focusCalendarGrid();
+      } else {
+        openCalendar({
+          moveFocusIntoCalendar: true
+        });
+      }
+    }
+  };
+  const handleOpenCalendar = () => {
+    openCalendar({
+      moveFocusIntoCalendar: false
+    });
+  };
+  const defaultLabel = !isRange ? translations.dateLabel : rangePart === 'end' ? translations.endDateLabel : translations.startDateLabel;
+  return /*#__PURE__*/_jsx(FormGroup, {
+    htmlFor: inputId,
+    isSoloField: true // should probaly be based on a prop
+    ,
+    label: label ?? defaultLabel,
+    mb: 0,
+    pb: 0,
+    spacing: "tight",
+    width: "170px",
+    children: /*#__PURE__*/_jsx(Input, {
+      ...rest,
+      "aria-autocomplete": "none",
+      "aria-controls": calendarDialogId,
+      "aria-expanded": isCalendarOpen,
+      "aria-haspopup": "dialog",
+      icon: () => /*#__PURE__*/_jsx(MiniCalendarIcon, {
+        size: 16
+      }),
+      id: inputId,
+      placeholder: placeholder ?? getDateFormatPattern(locale),
+      ref: ref,
+      role: "combobox",
+      type: "text",
+      value: inputValue,
+      onBlur: handleBlur,
+      onChange: handleChange,
+      onClick: handleOpenCalendar,
+      onFocus: () => {
+        isInputFocusedRef.current = true;
+        if (isRange && rangePart) context.setActiveRangePart(rangePart);
+      },
+      onKeyDown: handleKeyDown
+    })
+  });
+});
\ No newline at end of file