@codecademy/gamut 68.2.3-alpha.df0cb3.0 → 68.2.3-alpha.e0ecfc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -104,9 +104,13 @@ export const CalendarBody = ({
104
104
  children: weeks.map((week, rowIndex) => /*#__PURE__*/_jsx("tr", {
105
105
  children: week.map((date, colIndex) => {
106
106
  if (date === null) {
107
- return /*#__PURE__*/_jsx("td", {
108
- role: "gridcell"
109
- }, `empty-${rowIndex}-${colIndex}`);
107
+ return (
108
+ /*#__PURE__*/
109
+ // eslint-disable-next-line jsx-a11y/control-has-associated-label
110
+ _jsx("td", {
111
+ role: "gridcell"
112
+ }, `empty-${rowIndex}-${colIndex}`)
113
+ );
110
114
  }
111
115
  const selected = isSameDay(date, selectedDate) || isSameDay(date, endDate);
112
116
  const range = !!selectedDate && !!endDate;
@@ -25,20 +25,4 @@ export declare const getRelativeMonthLabels: (locale: Intl.Locale) => {
25
25
  * Get localized "today" label (e.g. "today").
26
26
  */
27
27
  export declare const getRelativeTodayLabel: (locale: Intl.Locale) => string;
28
- /**
29
- * Get the locale's short date format pattern (e.g. "MM/DD/YYYY" for en-US,
30
- * "DD/MM/YYYY" for en-GB). Uses Intl.DateTimeFormat formatToParts to infer
31
- * order and separators. Useful for parsing or building locale-aware inputs.
32
- */
33
- export declare const getDateFormatPattern: (locale: Intl.Locale) => string;
34
- /**
35
- * Format a date for display in the date picker input (e.g. "2/15/2026").
36
- */
37
- export declare const formatDateForInput: (date: Date, locale: Intl.Locale) => string;
38
- /**
39
- * Parse a string from the date input into a Date, or null if invalid.
40
- * Only returns a date when the input is a complete valid date (e.g. "2/15/2026").
41
- * Partial input like "1" or "2/15" returns null even though Date("1") would parse.
42
- */
43
- export declare const parseDateFromInput: (value: string, locale: Intl.Locale) => Date | null;
44
28
  export declare const formatDateForAriaLabel: (date: Date, locale: Intl.Locale) => string;
@@ -1,5 +1,4 @@
1
1
  import { stringifyLocale } from '../../utils/locale';
2
- import { isValidDate } from './validation';
3
2
 
4
3
  /**
5
4
  * Capitalize the first character of a string using the locale; rest unchanged (e.g. "next month" → "Next month").
@@ -64,61 +63,6 @@ export const getRelativeTodayLabel = locale => {
64
63
  });
65
64
  return capitalizeFirst(rtf.format(0, 'day'), locale);
66
65
  };
67
-
68
- /**
69
- * Get the locale's short date format pattern (e.g. "MM/DD/YYYY" for en-US,
70
- * "DD/MM/YYYY" for en-GB). Uses Intl.DateTimeFormat formatToParts to infer
71
- * order and separators. Useful for parsing or building locale-aware inputs.
72
- */
73
- export const getDateFormatPattern = locale => {
74
- const parts = new Intl.DateTimeFormat(stringifyLocale(locale), {
75
- year: 'numeric',
76
- month: '2-digit',
77
- day: '2-digit'
78
- }).formatToParts(new Date(2025, 0, 15));
79
- return parts.map(part => {
80
- switch (part.type) {
81
- case 'day':
82
- return 'DD';
83
- case 'month':
84
- return 'MM';
85
- case 'year':
86
- return 'YYYY';
87
- default:
88
- return part.value;
89
- }
90
- }).join('');
91
- };
92
-
93
- /**
94
- * Format a date for display in the date picker input (e.g. "2/15/2026").
95
- */
96
- export const formatDateForInput = (date, locale) => {
97
- return new Intl.DateTimeFormat(stringifyLocale(locale), {
98
- month: 'numeric',
99
- day: 'numeric',
100
- year: 'numeric'
101
- }).format(date);
102
- };
103
-
104
- /**
105
- * Parse a string from the date input into a Date, or null if invalid.
106
- * Only returns a date when the input is a complete valid date (e.g. "2/15/2026").
107
- * Partial input like "1" or "2/15" returns null even though Date("1") would parse.
108
- */
109
-
110
- // this logic needs some work
111
- export const parseDateFromInput = (value, locale) => {
112
- const trimmed = value.trim();
113
- if (!trimmed) return null;
114
- const parsed = new Date(trimmed);
115
- if (!isValidDate(parsed)) return null;
116
- const formatted = formatDateForInput(parsed, locale);
117
- if (formatted === trimmed) return parsed;
118
- const parts = trimmed.split(/[/-]/);
119
- if (parts.length >= 3) return parsed;
120
- return null;
121
- };
122
66
  export const formatDateForAriaLabel = (date, locale) => {
123
67
  return new Intl.DateTimeFormat(stringifyLocale(locale), {
124
68
  month: 'long',
@@ -54,7 +54,9 @@ export const DatePicker = props => {
54
54
  setIsCalendarOpen(false);
55
55
  setActiveRangePart(null);
56
56
  setGridFocusRequested(false);
57
- inputRef.current?.focus();
57
+ const shell = inputRef.current;
58
+ const toFocus = shell?.querySelector('[role="spinbutton"]') ?? shell;
59
+ toFocus?.focus();
58
60
  }, []);
59
61
  const startOrSelectedDate = isRangeProps(props) ? props.startDate : props.selectedDate;
60
62
  const endDate = isRangeProps(props) ? props.endDate : null;
@@ -100,13 +102,13 @@ export const DatePicker = props => {
100
102
  const content = children !== undefined ? children : /*#__PURE__*/_jsxs(_Fragment, {
101
103
  children: [/*#__PURE__*/_jsx(FlexBox, {
102
104
  gap: inputSize === 'small' ? 4 : 8,
105
+ ref: inputRef,
103
106
  width: "fit-content",
104
107
  children: mode === 'range' ? /*#__PURE__*/_jsxs(_Fragment, {
105
108
  children: [/*#__PURE__*/_jsx(DatePickerInput, {
106
109
  label: props.startLabel,
107
110
  placeholder: placeholder,
108
111
  rangePart: "start",
109
- ref: inputRef,
110
112
  size: inputSize
111
113
  }), /*#__PURE__*/_jsx(Box, {
112
114
  alignSelf: "center",
@@ -117,12 +119,12 @@ export const DatePicker = props => {
117
119
  placeholder: placeholder,
118
120
  rangePart: "end",
119
121
  size: inputSize
120
- // does this need a ref?
121
122
  })]
122
123
  }) : /*#__PURE__*/_jsx(DatePickerInput, {
123
124
  label: props.label,
124
- placeholder: placeholder,
125
- ref: inputRef,
125
+ placeholder: placeholder
126
+ // ref={inputRef}
127
+ ,
126
128
  size: inputSize
127
129
  })
128
130
  }), /*#__PURE__*/_jsx(PopoverContainer, {
@@ -0,0 +1,20 @@
1
+ import { type Dispatch, type SetStateAction } from 'react';
2
+ import type { DatePartKind } from '../utils';
3
+ import { SegmentValues } from './segmentUtils';
4
+ export type DatePickerInputSegmentProps = {
5
+ field: DatePartKind;
6
+ segments: SegmentValues;
7
+ disabled: boolean;
8
+ error: boolean;
9
+ handleOnClick: () => void;
10
+ handleOnFocus: () => void;
11
+ focusOrOpenCalendarGrid: () => void;
12
+ setSegments: Dispatch<SetStateAction<SegmentValues>>;
13
+ prevField: DatePartKind | null;
14
+ nextField: DatePartKind | null;
15
+ applySegments: (next: SegmentValues) => void;
16
+ };
17
+ /**
18
+ * Editable date unit (`role="spinbutton"`). Focus with Tab; type digits or use Arrow up/down.
19
+ */
20
+ export declare const DatePickerInputSegment: React.FC<DatePickerInputSegmentProps>;
@@ -0,0 +1,138 @@
1
+ import { useCallback, useId, useRef } from 'react';
2
+ import { Segment } from './elements';
3
+ import { appendSegmentDigit, getSegmentSpinBounds, parseSegmentNumericString, segmentMaxLength, segmentPlaceholder, spinSegment } from './segmentUtils';
4
+ import { jsx as _jsx } from "react/jsx-runtime";
5
+ /**
6
+ * Editable date unit (`role="spinbutton"`). Focus with Tab; type digits or use Arrow up/down.
7
+ */
8
+ export const DatePickerInputSegment = ({
9
+ field,
10
+ segments,
11
+ disabled,
12
+ error,
13
+ handleOnClick,
14
+ handleOnFocus,
15
+ focusOrOpenCalendarGrid,
16
+ setSegments,
17
+ prevField,
18
+ nextField,
19
+ applySegments
20
+ }) => {
21
+ const {
22
+ min,
23
+ max
24
+ } = getSegmentSpinBounds(field, segments);
25
+ const n = parseSegmentNumericString(segments[field]);
26
+ const ariaValue = segments[field].length > 0 && n != null ? n : undefined;
27
+ const display = segments[field].length > 0 ? segments[field] : segmentPlaceholder(field);
28
+ const inputID = useId();
29
+ const inputId = `datepicker-input-${inputID.replace(/:/g, '')}`;
30
+ const segmentRefs = useRef({});
31
+ const focusField = useCallback(field => {
32
+ segmentRefs.current[field]?.focus();
33
+ }, []);
34
+ const handleSegmentKeyDown = useCallback(field => e => {
35
+ if (disabled) return;
36
+ if (e.altKey && (e.key === 'ArrowDown' || e.key === 'Down')) {
37
+ e.preventDefault();
38
+ focusOrOpenCalendarGrid();
39
+ return;
40
+ }
41
+ if (e.key === 'ArrowLeft') {
42
+ if (prevField) {
43
+ e.preventDefault();
44
+ focusField(prevField);
45
+ }
46
+ return;
47
+ }
48
+ if (e.key === 'ArrowRight') {
49
+ if (nextField) {
50
+ e.preventDefault();
51
+ focusField(nextField);
52
+ }
53
+ return;
54
+ }
55
+ if (e.key === 'ArrowUp') {
56
+ e.preventDefault();
57
+ setSegments(prev => {
58
+ const next = {
59
+ ...prev,
60
+ [field]: spinSegment(field, prev, 1)
61
+ };
62
+ applySegments(next);
63
+ return next;
64
+ });
65
+ return;
66
+ }
67
+ if (e.key === 'ArrowDown') {
68
+ e.preventDefault();
69
+ setSegments(prev => {
70
+ const next = {
71
+ ...prev,
72
+ [field]: spinSegment(field, prev, -1)
73
+ };
74
+ applySegments(next);
75
+ return next;
76
+ });
77
+ return;
78
+ }
79
+ if (e.key === 'Backspace' || e.key === 'Delete') {
80
+ e.preventDefault();
81
+ setSegments(prev => {
82
+ if (prev[field].length > 0) {
83
+ const next = {
84
+ ...prev,
85
+ [field]: prev[field].slice(0, -1)
86
+ };
87
+ applySegments(next);
88
+ return next;
89
+ }
90
+ if (prevField) {
91
+ queueMicrotask(() => focusField(prevField));
92
+ }
93
+ return prev;
94
+ });
95
+ return;
96
+ }
97
+
98
+ // if the key is a single digit and is a number, append the digit to the segment
99
+ if (e.key.length === 1 && /^\d$/.test(e.key)) {
100
+ e.preventDefault();
101
+ setSegments(prev => {
102
+ const next = {
103
+ ...prev,
104
+ [field]: appendSegmentDigit(field, prev[field], e.key)
105
+ };
106
+ applySegments(next);
107
+ const maxLen = segmentMaxLength(field);
108
+ if (next[field].length >= maxLen && nextField) {
109
+ queueMicrotask(() => focusField(nextField));
110
+ }
111
+ return next;
112
+ });
113
+ }
114
+ }, [disabled, focusOrOpenCalendarGrid, prevField, focusField, nextField, setSegments, applySegments]);
115
+ return /*#__PURE__*/_jsx(Segment, {
116
+ "aria-disabled": disabled,
117
+ "aria-invalid": Boolean(error),
118
+ "aria-label": field,
119
+ "aria-valuemax": max,
120
+ "aria-valuemin": min,
121
+ "aria-valuenow": ariaValue,
122
+ "aria-valuetext": display,
123
+ contentEditable: false,
124
+ "data-segment": field,
125
+ default: segments[field].length === 0,
126
+ field: field,
127
+ id: `${inputId}-${field}`,
128
+ ref: el => {
129
+ segmentRefs.current[field] = el;
130
+ },
131
+ role: "spinbutton",
132
+ tabIndex: disabled ? -1 : 0,
133
+ onClick: handleOnClick,
134
+ onFocus: handleOnFocus,
135
+ onKeyDown: handleSegmentKeyDown(field),
136
+ children: display
137
+ });
138
+ };
@@ -0,0 +1,16 @@
1
+ import { DatePartKind } from '../utils';
2
+ export declare const Segment: import("@emotion/styled").StyledComponent<{
3
+ theme?: import("@emotion/react").Theme;
4
+ as?: React.ElementType;
5
+ } & Partial<Record<"default", boolean>> & {
6
+ theme?: import("@emotion/react").Theme;
7
+ } & {
8
+ field: DatePartKind;
9
+ }, import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, {}>;
10
+ /** Locale separator (`/`, `.`, etc.) between segments. */
11
+ export declare const SegmentLiteral: import("@emotion/styled").StyledComponent<{
12
+ theme?: import("@emotion/react").Theme;
13
+ as?: React.ElementType;
14
+ } & {
15
+ theme?: import("@emotion/react").Theme;
16
+ }, import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, {}>;
@@ -0,0 +1,34 @@
1
+ import _styled from "@emotion/styled/base";
2
+ import { css, states } from '@codecademy/gamut-styles';
3
+ const segmentStyles = states({
4
+ default: {
5
+ color: 'text-secondary'
6
+ }
7
+ });
8
+ export const Segment = /*#__PURE__*/_styled("span", {
9
+ target: "e157t5v81",
10
+ label: "Segment"
11
+ })(({
12
+ field
13
+ }) => css({
14
+ display: 'inline-block',
15
+ textAlign: 'center',
16
+ minWidth: field === 'year' ? '4ch' : '2ch',
17
+ padding: 0,
18
+ margin: 0,
19
+ color: 'text',
20
+ cursor: 'text',
21
+ '&:focus': {
22
+ outline: '1px solid blue'
23
+ }
24
+ }), segmentStyles, process.env.NODE_ENV === "production" ? "" : "/*# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIi4uLy4uLy4uLy4uL3NyYy9EYXRlUGlja2VyL0RhdGVQaWNrZXJJbnB1dC9TZWdtZW50L2VsZW1lbnRzLnRzeCJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFnQnVCIiwiZmlsZSI6Ii4uLy4uLy4uLy4uL3NyYy9EYXRlUGlja2VyL0RhdGVQaWNrZXJJbnB1dC9TZWdtZW50L2VsZW1lbnRzLnRzeCIsInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB7IGNzcywgc3RhdGVzIH0gZnJvbSAnQGNvZGVjYWRlbXkvZ2FtdXQtc3R5bGVzJztcbmltcG9ydCB7IFN0eWxlUHJvcHMgfSBmcm9tICdAY29kZWNhZGVteS92YXJpYW5jZSc7XG5pbXBvcnQgc3R5bGVkIGZyb20gJ0BlbW90aW9uL3N0eWxlZCc7XG5cbmltcG9ydCB7IERhdGVQYXJ0S2luZCB9IGZyb20gJy4uL3V0aWxzJztcblxuY29uc3Qgc2VnbWVudFN0eWxlcyA9IHN0YXRlcyh7XG4gIGRlZmF1bHQ6IHtcbiAgICBjb2xvcjogJ3RleHQtc2Vjb25kYXJ5JyxcbiAgfSxcbn0pO1xuXG50eXBlIFNlZ21lbnRTdHlsZVByb3BzID0gU3R5bGVQcm9wczx0eXBlb2Ygc2VnbWVudFN0eWxlcz4gJiB7XG4gIGZpZWxkOiBEYXRlUGFydEtpbmQ7XG59O1xuXG5leHBvcnQgY29uc3QgU2VnbWVudCA9IHN0eWxlZC5zcGFuPFNlZ21lbnRTdHlsZVByb3BzPihcbiAgKHsgZmllbGQgfSkgPT5cbiAgICBjc3Moe1xuICAgICAgZGlzcGxheTogJ2lubGluZS1ibG9jaycsXG4gICAgICB0ZXh0QWxpZ246ICdjZW50ZXInLFxuICAgICAgbWluV2lkdGg6IGZpZWxkID09PSAneWVhcicgPyAnNGNoJyA6ICcyY2gnLFxuICAgICAgcGFkZGluZzogMCxcbiAgICAgIG1hcmdpbjogMCxcbiAgICAgIGNvbG9yOiAndGV4dCcsXG4gICAgICBjdXJzb3I6ICd0ZXh0JyxcbiAgICAgICcmOmZvY3VzJzoge1xuICAgICAgICBvdXRsaW5lOiAnMXB4IHNvbGlkIGJsdWUnLFxuICAgICAgfSxcbiAgICB9KSxcbiAgc2VnbWVudFN0eWxlc1xuKTtcblxuLyoqIExvY2FsZSBzZXBhcmF0b3IgKGAvYCwgYC5gLCBldGMuKSBiZXR3ZWVuIHNlZ21lbnRzLiAqL1xuZXhwb3J0IGNvbnN0IFNlZ21lbnRMaXRlcmFsID0gc3R5bGVkLnNwYW4oXG4gIGNzcyh7XG4gICAgY29sb3I6ICd0ZXh0LXNlY29uZGFyeScsXG4gICAgdXNlclNlbGVjdDogJ25vbmUnLFxuICAgIHB4OiA0LFxuICB9KVxuKTtcbiJdfQ== */");
25
+
26
+ /** Locale separator (`/`, `.`, etc.) between segments. */
27
+ export const SegmentLiteral = /*#__PURE__*/_styled("span", {
28
+ target: "e157t5v80",
29
+ label: "SegmentLiteral"
30
+ })(css({
31
+ color: 'text-secondary',
32
+ userSelect: 'none',
33
+ px: 4
34
+ }), process.env.NODE_ENV === "production" ? "" : "/*# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIi4uLy4uLy4uLy4uL3NyYy9EYXRlUGlja2VyL0RhdGVQaWNrZXJJbnB1dC9TZWdtZW50L2VsZW1lbnRzLnRzeCJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFrQzhCIiwiZmlsZSI6Ii4uLy4uLy4uLy4uL3NyYy9EYXRlUGlja2VyL0RhdGVQaWNrZXJJbnB1dC9TZWdtZW50L2VsZW1lbnRzLnRzeCIsInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB7IGNzcywgc3RhdGVzIH0gZnJvbSAnQGNvZGVjYWRlbXkvZ2FtdXQtc3R5bGVzJztcbmltcG9ydCB7IFN0eWxlUHJvcHMgfSBmcm9tICdAY29kZWNhZGVteS92YXJpYW5jZSc7XG5pbXBvcnQgc3R5bGVkIGZyb20gJ0BlbW90aW9uL3N0eWxlZCc7XG5cbmltcG9ydCB7IERhdGVQYXJ0S2luZCB9IGZyb20gJy4uL3V0aWxzJztcblxuY29uc3Qgc2VnbWVudFN0eWxlcyA9IHN0YXRlcyh7XG4gIGRlZmF1bHQ6IHtcbiAgICBjb2xvcjogJ3RleHQtc2Vjb25kYXJ5JyxcbiAgfSxcbn0pO1xuXG50eXBlIFNlZ21lbnRTdHlsZVByb3BzID0gU3R5bGVQcm9wczx0eXBlb2Ygc2VnbWVudFN0eWxlcz4gJiB7XG4gIGZpZWxkOiBEYXRlUGFydEtpbmQ7XG59O1xuXG5leHBvcnQgY29uc3QgU2VnbWVudCA9IHN0eWxlZC5zcGFuPFNlZ21lbnRTdHlsZVByb3BzPihcbiAgKHsgZmllbGQgfSkgPT5cbiAgICBjc3Moe1xuICAgICAgZGlzcGxheTogJ2lubGluZS1ibG9jaycsXG4gICAgICB0ZXh0QWxpZ246ICdjZW50ZXInLFxuICAgICAgbWluV2lkdGg6IGZpZWxkID09PSAneWVhcicgPyAnNGNoJyA6ICcyY2gnLFxuICAgICAgcGFkZGluZzogMCxcbiAgICAgIG1hcmdpbjogMCxcbiAgICAgIGNvbG9yOiAndGV4dCcsXG4gICAgICBjdXJzb3I6ICd0ZXh0JyxcbiAgICAgICcmOmZvY3VzJzoge1xuICAgICAgICBvdXRsaW5lOiAnMXB4IHNvbGlkIGJsdWUnLFxuICAgICAgfSxcbiAgICB9KSxcbiAgc2VnbWVudFN0eWxlc1xuKTtcblxuLyoqIExvY2FsZSBzZXBhcmF0b3IgKGAvYCwgYC5gLCBldGMuKSBiZXR3ZWVuIHNlZ21lbnRzLiAqL1xuZXhwb3J0IGNvbnN0IFNlZ21lbnRMaXRlcmFsID0gc3R5bGVkLnNwYW4oXG4gIGNzcyh7XG4gICAgY29sb3I6ICd0ZXh0LXNlY29uZGFyeScsXG4gICAgdXNlclNlbGVjdDogJ25vbmUnLFxuICAgIHB4OiA0LFxuICB9KVxuKTtcbiJdfQ== */");
@@ -0,0 +1,4 @@
1
+ export { Segment, SegmentLiteral } from './elements';
2
+ export { DatePickerInputSegment } from './DatePickerInputSegment';
3
+ export type { DatePickerInputSegmentProps } from './DatePickerInputSegment';
4
+ export * from './segmentUtils';
@@ -0,0 +1,3 @@
1
+ export { Segment, SegmentLiteral } from './elements';
2
+ export { DatePickerInputSegment } from './DatePickerInputSegment';
3
+ export * from './segmentUtils';
@@ -0,0 +1,49 @@
1
+ import type { DateFormatLayoutItem, DatePartKind } from '../utils';
2
+ export type SegmentValues = {
3
+ month: string;
4
+ day: string;
5
+ year: string;
6
+ };
7
+ export declare const getDateSegmentsFromDate: (date: Date | null) => SegmentValues;
8
+ /**
9
+ * Build a calendar date from segment strings. Requires a 4-digit year and non-empty month/day.
10
+ */
11
+ export declare const parseSegmentsToDate: (segments: SegmentValues) => Date | null;
12
+ /** Digits-only slices used when checking for a fully typed date (2 / 2 / 4). */
13
+ export declare const getStrictSegmentDigits: (segments: SegmentValues) => {
14
+ month: string;
15
+ day: string;
16
+ year: string;
17
+ };
18
+ /** User finished all three fields (2-digit month, 2-digit day, 4-digit year). */
19
+ export declare const isStrictlyCompleteDateEntry: (strictSegments: SegmentValues) => boolean;
20
+ /**
21
+ * Normalize segment strings after blur (digits only).
22
+ * When the user has fully typed 2 / 2 / 4 digits, validates the calendar date without
23
+ * clamping invalid days/months — if invalid, returns empty segments (caller clears selection).
24
+ * Otherwise pads/clamps partial input as before.
25
+ */
26
+ export declare const normalizeSegmentValues: (segments: SegmentValues) => SegmentValues;
27
+ export declare const segmentPlaceholder: (field: DatePartKind) => "YYYY" | "MM" | "DD";
28
+ /** Digit capacity per field (typing / spinbutton editing). */
29
+ export declare const segmentMaxLength: (field: DatePartKind) => number;
30
+ /**
31
+ * Min/max for spinbutton `aria-*` and ArrowUp/ArrowDown stepping (month/day/year).
32
+ * Day max uses month/year when available so February etc. behave correctly.
33
+ */
34
+ export declare const getSegmentSpinBounds: (field: DatePartKind, segments: SegmentValues) => {
35
+ min: number;
36
+ max: number;
37
+ };
38
+ /** Numeric value of a segment string (digits only), or null if empty. */
39
+ export declare const parseSegmentNumericString: (str: string) => number | null;
40
+ /** Append one digit to a segment string (max length enforced). */
41
+ export declare const appendSegmentDigit: (field: DatePartKind, prev: string, digit: string) => string;
42
+ /**
43
+ * Step a segment up/down (ArrowUp / ArrowDown). Empty year steps from the current calendar year.
44
+ */
45
+ export declare const spinSegment: (field: DatePartKind, segments: SegmentValues, delta: 1 | -1) => string;
46
+ /** Build the visible date string from segment state in locale layout order (includes literal separators). */
47
+ export declare const buildCombinedFromSegments: (segments: SegmentValues, layout: DateFormatLayoutItem[]) => string;
48
+ /** Map a digit-only string into segment fields following locale field order (2 / 2 / 4). */
49
+ export declare const digitsToSegments: (digits: string, fieldOrder: DatePartKind[]) => SegmentValues;
@@ -0,0 +1,202 @@
1
+ export const getDateSegmentsFromDate = date => {
2
+ if (date == null) return {
3
+ month: '',
4
+ day: '',
5
+ year: ''
6
+ };
7
+ return {
8
+ month: String(date.getMonth() + 1).padStart(2, '0'),
9
+ day: String(date.getDate()).padStart(2, '0'),
10
+ year: String(date.getFullYear())
11
+ };
12
+ };
13
+
14
+ /**
15
+ * Build a calendar date from segment strings. Requires a 4-digit year and non-empty month/day.
16
+ */
17
+ export const parseSegmentsToDate = segments => {
18
+ const {
19
+ month,
20
+ day,
21
+ year
22
+ } = segments;
23
+ if (year.length !== 4) return null;
24
+ if (month.length === 0 || day.length === 0) return null;
25
+ const m = parseInt(month, 10);
26
+ const d = parseInt(day, 10);
27
+ const y = parseInt(year, 10);
28
+ if (!Number.isFinite(m) || !Number.isFinite(d) || !Number.isFinite(y)) return null;
29
+ if (m < 1 || m > 12) return null;
30
+ const parsed = new Date(y, m - 1, d);
31
+ if (parsed.getFullYear() !== y || parsed.getMonth() !== m - 1 || parsed.getDate() !== d) {
32
+ return null;
33
+ }
34
+ return parsed;
35
+ };
36
+
37
+ /** Digits-only slices used when checking for a fully typed date (2 / 2 / 4). */
38
+ export const getStrictSegmentDigits = segments => ({
39
+ month: segments.month.replace(/\D/g, '').slice(0, 2),
40
+ day: segments.day.replace(/\D/g, '').slice(0, 2),
41
+ year: segments.year.replace(/\D/g, '').slice(0, 4)
42
+ });
43
+
44
+ /** User finished all three fields (2-digit month, 2-digit day, 4-digit year). */
45
+ export const isStrictlyCompleteDateEntry = strictSegments => {
46
+ const {
47
+ month,
48
+ day,
49
+ year
50
+ } = strictSegments;
51
+ return year.length === 4 && month.length === 2 && day.length === 2;
52
+ };
53
+
54
+ /**
55
+ * Normalize segment strings after blur (digits only).
56
+ * When the user has fully typed 2 / 2 / 4 digits, validates the calendar date without
57
+ * clamping invalid days/months — if invalid, returns empty segments (caller clears selection).
58
+ * Otherwise pads/clamps partial input as before.
59
+ */
60
+ export const normalizeSegmentValues = segments => {
61
+ const strictSegments = getStrictSegmentDigits(segments);
62
+ if (isStrictlyCompleteDateEntry(strictSegments)) {
63
+ const parsed = parseSegmentsToDate(strictSegments);
64
+ if (parsed) {
65
+ return getDateSegmentsFromDate(parsed);
66
+ }
67
+ return {
68
+ month: '',
69
+ day: '',
70
+ year: ''
71
+ };
72
+ }
73
+ const year = segments.year.replace(/\D/g, '').slice(0, 4);
74
+ let month = segments.month.replace(/\D/g, '').slice(0, 2);
75
+ let day = segments.day.replace(/\D/g, '').slice(0, 2);
76
+ if (month.length > 0) {
77
+ const m = Math.min(12, Math.max(1, parseInt(month, 10)));
78
+ month = Number.isFinite(m) ? String(m).padStart(2, '0') : '';
79
+ }
80
+ if (year.length === 4 && month.length === 2 && day.length > 0) {
81
+ const y = parseInt(year, 10);
82
+ const m = parseInt(month, 10);
83
+ const dmax = new Date(y, m, 0).getDate();
84
+ const d = Math.min(dmax, Math.max(1, parseInt(day, 10)));
85
+ day = Number.isFinite(d) ? String(d).padStart(2, '0') : '';
86
+ } else if (day.length > 0) {
87
+ const d = Math.min(31, Math.max(1, parseInt(day, 10)));
88
+ day = Number.isFinite(d) ? String(d).padStart(2, '0') : '';
89
+ }
90
+ return {
91
+ month,
92
+ day,
93
+ year
94
+ };
95
+ };
96
+ export const segmentPlaceholder = field => field === 'year' ? 'YYYY' : field === 'month' ? 'MM' : 'DD';
97
+
98
+ /** Digit capacity per field (typing / spinbutton editing). */
99
+ export const segmentMaxLength = field => field === 'year' ? 4 : 2;
100
+
101
+ /**
102
+ * Min/max for spinbutton `aria-*` and ArrowUp/ArrowDown stepping (month/day/year).
103
+ * Day max uses month/year when available so February etc. behave correctly.
104
+ */
105
+ export const getSegmentSpinBounds = (field, segments) => {
106
+ switch (field) {
107
+ case 'month':
108
+ return {
109
+ min: 1,
110
+ max: 12
111
+ };
112
+ case 'day':
113
+ {
114
+ const y = segments.year.length === 4 ? parseInt(segments.year, 10) : 2024;
115
+ const m = segments.month.length >= 1 ? Math.min(12, Math.max(1, parseInt(segments.month, 10) || 1)) : 1;
116
+ const maxD = new Date(y, m, 0).getDate();
117
+ return {
118
+ min: 1,
119
+ max: Number.isFinite(maxD) ? maxD : 31
120
+ };
121
+ }
122
+ case 'year':
123
+ return {
124
+ min: 1,
125
+ max: 9999
126
+ };
127
+ default:
128
+ return {
129
+ min: 1,
130
+ max: 9999
131
+ };
132
+ }
133
+ };
134
+
135
+ /** Numeric value of a segment string (digits only), or null if empty. */
136
+ export const parseSegmentNumericString = str => {
137
+ const digits = str.replace(/\D/g, '');
138
+ if (digits.length === 0) return null;
139
+ const numericValue = parseInt(digits, 10);
140
+ return Number.isFinite(numericValue) ? numericValue : null;
141
+ };
142
+ const parseSegmentDigits = (_field, str) => parseSegmentNumericString(str);
143
+ const padSegmentNumber = (field, numericValue) => {
144
+ if (field === 'year') {
145
+ const clamped = Math.min(9999, Math.max(1, numericValue));
146
+ return String(clamped).padStart(4, '0');
147
+ }
148
+ const clamped = Math.min(99, Math.max(0, numericValue));
149
+ return String(clamped).padStart(2, '0').slice(-2);
150
+ };
151
+
152
+ /** Append one digit to a segment string (max length enforced). */
153
+ export const appendSegmentDigit = (field, prev, digit) => {
154
+ // if the digit is not a single digit, return the previous value
155
+ if (!/^\d$/.test(digit)) return prev;
156
+ const maxLen = segmentMaxLength(field);
157
+ const digitsOnly = prev.replace(/\D/g, '');
158
+ // When the segment is already full, another digit would only be appended then
159
+ // truncated back to the same string — so typing could not change the value.
160
+ // Treat the new digit as the start of a replacement (same as clearing then typing).
161
+ if (digitsOnly.length >= maxLen) {
162
+ return digit.slice(0, maxLen);
163
+ }
164
+ return (digitsOnly + digit).slice(0, maxLen);
165
+ };
166
+
167
+ /**
168
+ * Step a segment up/down (ArrowUp / ArrowDown). Empty year steps from the current calendar year.
169
+ */
170
+ export const spinSegment = (field, segments, delta) => {
171
+ const {
172
+ min,
173
+ max
174
+ } = getSegmentSpinBounds(field, segments);
175
+ let cur = parseSegmentDigits(field, segments[field]);
176
+ if (cur == null) {
177
+ cur = field === 'year' ? delta > 0 ? new Date().getFullYear() : max : delta > 0 ? min : max;
178
+ } else {
179
+ cur += delta;
180
+ }
181
+ cur = Math.min(max, Math.max(min, cur));
182
+ return padSegmentNumber(field, cur);
183
+ };
184
+
185
+ /** Build the visible date string from segment state in locale layout order (includes literal separators). */
186
+ export const buildCombinedFromSegments = (segments, layout) => layout.map(item => item.kind === 'literal' ? item.text : segments[item.field]).join('');
187
+
188
+ /** Map a digit-only string into segment fields following locale field order (2 / 2 / 4). */
189
+ export const digitsToSegments = (digits, fieldOrder) => {
190
+ let rest = digits;
191
+ const out = {
192
+ month: '',
193
+ day: '',
194
+ year: ''
195
+ };
196
+ for (const field of fieldOrder) {
197
+ const maxLen = field === 'year' ? 4 : 2;
198
+ out[field] = rest.slice(0, maxLen);
199
+ rest = rest.slice(maxLen);
200
+ }
201
+ return out;
202
+ };
@@ -0,0 +1,15 @@
1
+ import { StyleProps } from '@codecademy/variance';
2
+ import { conditionalStyles, inputSizeStyles } from '../../Form/styles';
3
+ interface SegmentedShellProps extends StyleProps<typeof conditionalStyles>, StyleProps<typeof inputSizeStyles> {
4
+ }
5
+ /**
6
+ * Shell uses the same style stack as `Input`. `formFieldStyles` targets `&:focus`, but the host is a
7
+ * `div` — focus is on inner spinbuttons, so we mirror `Input` focus visuals with `&:focus-within`.
8
+ */
9
+ export declare const SegmentedShell: import("@emotion/styled").StyledComponent<{
10
+ theme?: import("@emotion/react").Theme;
11
+ as?: React.ElementType;
12
+ } & import("../../Box").FlexBoxProps & Pick<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "slot" | "style" | "title" | "dir" | "children" | "className" | "aria-hidden" | "onAnimationStart" | "onDragStart" | "onDragEnd" | "onDrag" | keyof import("react").ClassAttributes<HTMLDivElement> | "defaultChecked" | "defaultValue" | "suppressContentEditableWarning" | "suppressHydrationWarning" | "accessKey" | "autoCapitalize" | "autoFocus" | "contentEditable" | "contextMenu" | "draggable" | "enterKeyHint" | "hidden" | "id" | "lang" | "nonce" | "spellCheck" | "tabIndex" | "translate" | "radioGroup" | "role" | "about" | "content" | "datatype" | "inlist" | "prefix" | "property" | "rel" | "resource" | "rev" | "typeof" | "vocab" | "autoCorrect" | "autoSave" | "itemProp" | "itemScope" | "itemType" | "itemID" | "itemRef" | "results" | "security" | "unselectable" | "inputMode" | "is" | "exportparts" | "part" | "aria-activedescendant" | "aria-atomic" | "aria-autocomplete" | "aria-braillelabel" | "aria-brailleroledescription" | "aria-busy" | "aria-checked" | "aria-colcount" | "aria-colindex" | "aria-colindextext" | "aria-colspan" | "aria-controls" | "aria-current" | "aria-describedby" | "aria-description" | "aria-details" | "aria-disabled" | "aria-dropeffect" | "aria-errormessage" | "aria-expanded" | "aria-flowto" | "aria-grabbed" | "aria-haspopup" | "aria-invalid" | "aria-keyshortcuts" | "aria-label" | "aria-labelledby" | "aria-level" | "aria-live" | "aria-modal" | "aria-multiline" | "aria-multiselectable" | "aria-orientation" | "aria-owns" | "aria-placeholder" | "aria-posinset" | "aria-pressed" | "aria-readonly" | "aria-relevant" | "aria-required" | "aria-roledescription" | "aria-rowcount" | "aria-rowindex" | "aria-rowindextext" | "aria-rowspan" | "aria-selected" | "aria-setsize" | "aria-sort" | "aria-valuemax" | "aria-valuemin" | "aria-valuenow" | "aria-valuetext" | "dangerouslySetInnerHTML" | "onCopy" | "onCopyCapture" | "onCut" | "onCutCapture" | "onPaste" | "onPasteCapture" | "onCompositionEnd" | "onCompositionEndCapture" | "onCompositionStart" | "onCompositionStartCapture" | "onCompositionUpdate" | "onCompositionUpdateCapture" | "onFocus" | "onFocusCapture" | "onBlur" | "onBlurCapture" | "onChange" | "onChangeCapture" | "onBeforeInput" | "onBeforeInputCapture" | "onInput" | "onInputCapture" | "onReset" | "onResetCapture" | "onSubmit" | "onSubmitCapture" | "onInvalid" | "onInvalidCapture" | "onLoad" | "onLoadCapture" | "onError" | "onErrorCapture" | "onKeyDown" | "onKeyDownCapture" | "onKeyPress" | "onKeyPressCapture" | "onKeyUp" | "onKeyUpCapture" | "onAbort" | "onAbortCapture" | "onCanPlay" | "onCanPlayCapture" | "onCanPlayThrough" | "onCanPlayThroughCapture" | "onDurationChange" | "onDurationChangeCapture" | "onEmptied" | "onEmptiedCapture" | "onEncrypted" | "onEncryptedCapture" | "onEnded" | "onEndedCapture" | "onLoadedData" | "onLoadedDataCapture" | "onLoadedMetadata" | "onLoadedMetadataCapture" | "onLoadStart" | "onLoadStartCapture" | "onPause" | "onPauseCapture" | "onPlay" | "onPlayCapture" | "onPlaying" | "onPlayingCapture" | "onProgress" | "onProgressCapture" | "onRateChange" | "onRateChangeCapture" | "onSeeked" | "onSeekedCapture" | "onSeeking" | "onSeekingCapture" | "onStalled" | "onStalledCapture" | "onSuspend" | "onSuspendCapture" | "onTimeUpdate" | "onTimeUpdateCapture" | "onVolumeChange" | "onVolumeChangeCapture" | "onWaiting" | "onWaitingCapture" | "onAuxClick" | "onAuxClickCapture" | "onClick" | "onClickCapture" | "onContextMenu" | "onContextMenuCapture" | "onDoubleClick" | "onDoubleClickCapture" | "onDragCapture" | "onDragEndCapture" | "onDragEnter" | "onDragEnterCapture" | "onDragExit" | "onDragExitCapture" | "onDragLeave" | "onDragLeaveCapture" | "onDragOver" | "onDragOverCapture" | "onDragStartCapture" | "onDrop" | "onDropCapture" | "onMouseDown" | "onMouseDownCapture" | "onMouseEnter" | "onMouseLeave" | "onMouseMove" | "onMouseMoveCapture" | "onMouseOut" | "onMouseOutCapture" | "onMouseOver" | "onMouseOverCapture" | "onMouseUp" | "onMouseUpCapture" | "onSelect" | "onSelectCapture" | "onTouchCancel" | "onTouchCancelCapture" | "onTouchEnd" | "onTouchEndCapture" | "onTouchMove" | "onTouchMoveCapture" | "onTouchStart" | "onTouchStartCapture" | "onPointerDown" | "onPointerDownCapture" | "onPointerMove" | "onPointerMoveCapture" | "onPointerUp" | "onPointerUpCapture" | "onPointerCancel" | "onPointerCancelCapture" | "onPointerEnter" | "onPointerLeave" | "onPointerOver" | "onPointerOverCapture" | "onPointerOut" | "onPointerOutCapture" | "onGotPointerCapture" | "onGotPointerCaptureCapture" | "onLostPointerCapture" | "onLostPointerCaptureCapture" | "onScroll" | "onScrollCapture" | "onWheel" | "onWheelCapture" | "onAnimationStartCapture" | "onAnimationEnd" | "onAnimationEndCapture" | "onAnimationIteration" | "onAnimationIterationCapture" | "onTransitionEnd" | "onTransitionEndCapture"> & {
13
+ theme?: import("@emotion/react").Theme;
14
+ } & SegmentedShellProps, {}, {}>;
15
+ export {};
@@ -0,0 +1,19 @@
1
+ import _styled from "@emotion/styled/base";
2
+ import { css, theme } from '@codecademy/gamut-styles';
3
+ import { FlexBox } from '../../Box';
4
+ import { conditionalStyles, formFieldFocusStyles, formFieldStyles, inputSizeStyles } from '../../Form/styles';
5
+ /**
6
+ * Shell uses the same style stack as `Input`. `formFieldStyles` targets `&:focus`, but the host is a
7
+ * `div` — focus is on inner spinbuttons, so we mirror `Input` focus visuals with `&:focus-within`.
8
+ */
9
+ export const SegmentedShell = /*#__PURE__*/_styled(FlexBox, {
10
+ target: "ex306dz0",
11
+ label: "SegmentedShell"
12
+ })(formFieldStyles, conditionalStyles, inputSizeStyles, ({
13
+ variant
14
+ }) => css({
15
+ '&:focus-within': variant === 'error' ? {
16
+ borderColor: 'feedback-error',
17
+ boxShadow: `inset 0 0 0 1px ${theme.colors['feedback-error']}`
18
+ } : formFieldFocusStyles
19
+ }), process.env.NODE_ENV === "production" ? "" : "/*# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIi4uLy4uLy4uL3NyYy9EYXRlUGlja2VyL0RhdGVQaWNrZXJJbnB1dC9lbGVtZW50cy50c3giXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBb0I4QiIsImZpbGUiOiIuLi8uLi8uLi9zcmMvRGF0ZVBpY2tlci9EYXRlUGlja2VySW5wdXQvZWxlbWVudHMudHN4Iiwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgY3NzLCB0aGVtZSB9IGZyb20gJ0Bjb2RlY2FkZW15L2dhbXV0LXN0eWxlcyc7XG5pbXBvcnQgeyBTdHlsZVByb3BzIH0gZnJvbSAnQGNvZGVjYWRlbXkvdmFyaWFuY2UnO1xuaW1wb3J0IHN0eWxlZCBmcm9tICdAZW1vdGlvbi9zdHlsZWQnO1xuXG5pbXBvcnQgeyBGbGV4Qm94IH0gZnJvbSAnLi4vLi4vQm94JztcbmltcG9ydCB7XG4gIGNvbmRpdGlvbmFsU3R5bGVzLFxuICBmb3JtRmllbGRGb2N1c1N0eWxlcyxcbiAgZm9ybUZpZWxkU3R5bGVzLFxuICBpbnB1dFNpemVTdHlsZXMsXG59IGZyb20gJy4uLy4uL0Zvcm0vc3R5bGVzJztcblxuaW50ZXJmYWNlIFNlZ21lbnRlZFNoZWxsUHJvcHNcbiAgZXh0ZW5kcyBTdHlsZVByb3BzPHR5cGVvZiBjb25kaXRpb25hbFN0eWxlcz4sXG4gICAgU3R5bGVQcm9wczx0eXBlb2YgaW5wdXRTaXplU3R5bGVzPiB7fVxuXG4vKipcbiAqIFNoZWxsIHVzZXMgdGhlIHNhbWUgc3R5bGUgc3RhY2sgYXMgYElucHV0YC4gYGZvcm1GaWVsZFN0eWxlc2AgdGFyZ2V0cyBgJjpmb2N1c2AsIGJ1dCB0aGUgaG9zdCBpcyBhXG4gKiBgZGl2YCDigJQgZm9jdXMgaXMgb24gaW5uZXIgc3BpbmJ1dHRvbnMsIHNvIHdlIG1pcnJvciBgSW5wdXRgIGZvY3VzIHZpc3VhbHMgd2l0aCBgJjpmb2N1cy13aXRoaW5gLlxuICovXG5leHBvcnQgY29uc3QgU2VnbWVudGVkU2hlbGwgPSBzdHlsZWQoRmxleEJveCk8U2VnbWVudGVkU2hlbGxQcm9wcz4oXG4gIGZvcm1GaWVsZFN0eWxlcyxcbiAgY29uZGl0aW9uYWxTdHlsZXMsXG4gIGlucHV0U2l6ZVN0eWxlcyxcbiAgKHsgdmFyaWFudCB9KSA9PlxuICAgIGNzcyh7XG4gICAgICAnJjpmb2N1cy13aXRoaW4nOlxuICAgICAgICB2YXJpYW50ID09PSAnZXJyb3InXG4gICAgICAgICAgPyB7XG4gICAgICAgICAgICAgIGJvcmRlckNvbG9yOiAnZmVlZGJhY2stZXJyb3InLFxuICAgICAgICAgICAgICBib3hTaGFkb3c6IGBpbnNldCAwIDAgMCAxcHggJHt0aGVtZS5jb2xvcnNbJ2ZlZWRiYWNrLWVycm9yJ119YCxcbiAgICAgICAgICAgIH1cbiAgICAgICAgICA6IGZvcm1GaWVsZEZvY3VzU3R5bGVzLFxuICAgIH0pXG4pO1xuIl19 */");
@@ -0,0 +1,13 @@
1
+ import type { InputWrapperProps } from '../../Form/inputs/Input';
2
+ /**
3
+ * Props for DatePickerInput. When used inside DatePicker, only overrides (e.g. placeholder, label).
4
+ * In range mode, use rangePart to bind to start or end date. When outside DatePicker, pass value, onChange, etc.
5
+ */
6
+ export type DatePickerInputProps = Omit<InputWrapperProps, 'className' | 'type' | 'icon' | 'value' | 'onChange' | 'color'> & {
7
+ /** In range mode: which part of the range this input edits. Omit for single-date or combined display. */
8
+ rangePart?: 'start' | 'end';
9
+ };
10
+ export declare const DatePickerInput: import("react").ForwardRefExoticComponent<Omit<InputWrapperProps, "color" | "className" | "onChange" | "type" | "icon" | "value"> & {
11
+ /** In range mode: which part of the range this input edits. Omit for single-date or combined display. */
12
+ rangePart?: "start" | "end";
13
+ } & import("react").RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,187 @@
1
+ import { MiniCalendarIcon } from '@codecademy/gamut-icons';
2
+ import { forwardRef, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
3
+ import { FlexBox } from '../../Box';
4
+ import { FormGroup } from '../../Form/elements/FormGroup';
5
+ import { useDatePicker } from '../DatePickerContext';
6
+ import { SegmentedShell } from './elements';
7
+ import { DatePickerInputSegment, getDateSegmentsFromDate, normalizeSegmentValues, parseSegmentsToDate, SegmentLiteral } from './Segment';
8
+ import { formatDateISO8601DateOnly, getDateFieldOrder, getDateFormatLayout } from './utils';
9
+
10
+ /**
11
+ * Props for DatePickerInput. When used inside DatePicker, only overrides (e.g. placeholder, label).
12
+ * In range mode, use rangePart to bind to start or end date. When outside DatePicker, pass value, onChange, etc.
13
+ */
14
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
15
+ export const DatePickerInput = /*#__PURE__*/forwardRef(({
16
+ disabled,
17
+ error,
18
+ form,
19
+ label,
20
+ name,
21
+ rangePart,
22
+ size = 'base',
23
+ ...rest
24
+ }, ref) => {
25
+ const context = useDatePicker();
26
+ if (context == null) {
27
+ throw new Error('DatePickerInput must be used inside a DatePicker (it reads shared state from context).');
28
+ }
29
+ const {
30
+ mode,
31
+ startOrSelectedDate,
32
+ setSelection,
33
+ openCalendar,
34
+ focusCalendarGrid,
35
+ locale,
36
+ isCalendarOpen,
37
+ translations
38
+ } = context;
39
+ const isRange = mode === 'range';
40
+ const endDate = isRange ? context.endDate : null;
41
+ const inputID = useId();
42
+ const inputId = `datepicker-input-${inputID.replace(/:/g, '')}`;
43
+ const {
44
+ layout,
45
+ fieldOrder
46
+ } = useMemo(() => {
47
+ const layout = getDateFormatLayout(locale);
48
+ return {
49
+ layout,
50
+ fieldOrder: getDateFieldOrder(layout)
51
+ };
52
+ }, [locale]);
53
+ const firstField = fieldOrder[0];
54
+ const firstFieldId = `${inputId}-${firstField}`;
55
+ const defaultLabel = !isRange ? translations.dateLabel : rangePart === 'end' ? translations.endDateLabel : translations.startDateLabel;
56
+ const boundDate = isRange && rangePart === 'end' ? endDate : startOrSelectedDate;
57
+ const segmentsFromBound = useCallback(() => getDateSegmentsFromDate(boundDate), [boundDate]);
58
+ const [segments, setSegments] = useState(segmentsFromBound);
59
+ const parsedForHidden = parseSegmentsToDate(segments);
60
+ const hiddenValue = parsedForHidden ? formatDateISO8601DateOnly(parsedForHidden) : '';
61
+ const isInputFocusedRef = useRef(false);
62
+ const containerRef = useRef(null);
63
+ const setShellRef = useCallback(el => {
64
+ containerRef.current = el;
65
+ if (typeof ref === 'function') ref(el);else if (ref != null) ref.current = el;
66
+ }, [ref]);
67
+ useEffect(() => {
68
+ if (!isInputFocusedRef.current) {
69
+ setSegments(segmentsFromBound());
70
+ }
71
+ }, [segmentsFromBound]);
72
+ const commitParsedDate = useCallback(parsed => {
73
+ if (isRange && rangePart) {
74
+ if (rangePart === 'start') setSelection(parsed, endDate);else setSelection(startOrSelectedDate, parsed);
75
+ } else setSelection(parsed);
76
+ }, [isRange, rangePart, setSelection, endDate, startOrSelectedDate]);
77
+ const clearSelection = useCallback(() => {
78
+ if (isRange && rangePart) {
79
+ if (rangePart === 'start') setSelection(null, endDate);else setSelection(startOrSelectedDate, null);
80
+ } else setSelection(null);
81
+ }, [isRange, rangePart, setSelection, endDate, startOrSelectedDate]);
82
+ const applySegments = useCallback(next => {
83
+ const parsed = parseSegmentsToDate(next);
84
+ if (parsed) commitParsedDate(parsed);else if (!next.month && !next.day && !next.year) clearSelection();
85
+ }, [clearSelection, commitParsedDate]);
86
+ const handleContainerBlur = e => {
87
+ if (containerRef.current?.contains(e.relatedTarget)) return;
88
+ isInputFocusedRef.current = false;
89
+ setSegments(prev => {
90
+ const normalized = normalizeSegmentValues(prev);
91
+ const parsed = parseSegmentsToDate(normalized);
92
+ if (parsed) {
93
+ commitParsedDate(parsed);
94
+ return normalized;
95
+ }
96
+ if (!normalized.month && !normalized.day && !normalized.year) {
97
+ clearSelection();
98
+ return getDateSegmentsFromDate(null);
99
+ }
100
+ return segmentsFromBound();
101
+ });
102
+ };
103
+ const handleContainerFocus = () => {
104
+ isInputFocusedRef.current = true;
105
+ };
106
+ const handleSegmentFocus = () => {
107
+ handleContainerFocus();
108
+ if (isRange && rangePart) context.setActiveRangePart(rangePart);
109
+ };
110
+ const handleOpenCalendar = () => {
111
+ if (!disabled) openCalendar({
112
+ moveFocusIntoCalendar: false
113
+ });
114
+ };
115
+ const focusOrOpenCalendarGrid = () => {
116
+ if (isCalendarOpen) focusCalendarGrid();else openCalendar({
117
+ moveFocusIntoCalendar: true
118
+ });
119
+ };
120
+ return /*#__PURE__*/_jsx(FormGroup, {
121
+ htmlFor: firstFieldId,
122
+ isSoloField: true,
123
+ label: label ?? defaultLabel,
124
+ mb: 0,
125
+ pb: 0,
126
+ spacing: "tight",
127
+ width: "fit-content",
128
+ children: /*#__PURE__*/_jsxs(SegmentedShell, {
129
+ inputSize: size === 'small' ? 'small' : 'base',
130
+ ref: setShellRef,
131
+ role: "group",
132
+ variant: error ? 'error' : undefined,
133
+ width: "113px",
134
+ onBlur: handleContainerBlur,
135
+ onFocus: handleContainerFocus,
136
+ ...rest,
137
+ children: [/*#__PURE__*/_jsx(FlexBox, {
138
+ alignItems: "center",
139
+ justifyContent: "center",
140
+ children: layout.map((item, index) => {
141
+ if (item.kind === 'literal') {
142
+ return /*#__PURE__*/_jsx(SegmentLiteral, {
143
+ "aria-hidden": true
144
+ // eslint-disable-next-line react/no-array-index-key
145
+ ,
146
+ children: `${item.text}`
147
+ }, `literal-${item.text}-${index}`);
148
+ }
149
+ const idx = fieldOrder.indexOf(item.field);
150
+ const prevField = idx > 0 ? fieldOrder[idx - 1] : null;
151
+ const nextField = idx < fieldOrder.length - 1 ? fieldOrder[idx + 1] : null;
152
+ return /*#__PURE__*/_jsx(DatePickerInputSegment, {
153
+ applySegments: applySegments,
154
+ disabled: Boolean(disabled),
155
+ error: Boolean(error),
156
+ field: item.field,
157
+ focusOrOpenCalendarGrid: focusOrOpenCalendarGrid,
158
+ handleOnClick: handleOpenCalendar,
159
+ handleOnFocus: handleSegmentFocus,
160
+ nextField: nextField,
161
+ prevField: prevField,
162
+ segments: segments,
163
+ setSegments: setSegments
164
+ }, item.field);
165
+ })
166
+ }), /*#__PURE__*/_jsx("input", {
167
+ "aria-hidden": true,
168
+ form: form,
169
+ name: name,
170
+ tabIndex: -1,
171
+ type: "hidden",
172
+ value: hiddenValue
173
+ }), /*#__PURE__*/_jsx(FlexBox, {
174
+ alignItems: "center",
175
+ justifyContent: "center",
176
+ pl: 16,
177
+ pr: 8,
178
+ role: "presentation",
179
+ onClick: handleOpenCalendar,
180
+ children: /*#__PURE__*/_jsx(MiniCalendarIcon, {
181
+ "aria-hidden": true,
182
+ size: 16
183
+ })
184
+ })]
185
+ })
186
+ });
187
+ });
@@ -0,0 +1,17 @@
1
+ /** Single date field in locale order (from `Intl.DateTimeFormat#formatToParts`). */
2
+ export type DatePartKind = 'month' | 'day' | 'year';
3
+ export type DateFormatLayoutItem = {
4
+ kind: 'field';
5
+ field: DatePartKind;
6
+ } | {
7
+ kind: 'literal';
8
+ text: string;
9
+ };
10
+ /**
11
+ * Month/day/year order and literal separators for the locale (e.g. MM/DD/YYYY vs DD/MM/YYYY).
12
+ */
13
+ export declare const getDateFormatLayout: (locale: Intl.Locale) => DateFormatLayoutItem[];
14
+ /** Focus / tab order for the three fields (locale order). */
15
+ export declare const getDateFieldOrder: (layout: DateFormatLayoutItem[]) => DatePartKind[];
16
+ /** ISO 8601 date-only string for hidden form fields. */
17
+ export declare const formatDateISO8601DateOnly: (date: Date) => string;
@@ -0,0 +1,50 @@
1
+ import { stringifyLocale } from '../utils/locale';
2
+
3
+ /** Single date field in locale order (from `Intl.DateTimeFormat#formatToParts`). */
4
+
5
+ /**
6
+ * Month/day/year order and literal separators for the locale (e.g. MM/DD/YYYY vs DD/MM/YYYY).
7
+ */
8
+ export const getDateFormatLayout = locale => {
9
+ const parts = new Intl.DateTimeFormat(stringifyLocale(locale), {
10
+ year: 'numeric',
11
+ month: '2-digit',
12
+ day: '2-digit'
13
+ }).formatToParts(new Date(2025, 10, 15));
14
+ const items = [];
15
+ for (const part of parts) {
16
+ if (part.type === 'month') items.push({
17
+ kind: 'field',
18
+ field: 'month'
19
+ });else if (part.type === 'day') items.push({
20
+ kind: 'field',
21
+ field: 'day'
22
+ });else if (part.type === 'year') items.push({
23
+ kind: 'field',
24
+ field: 'year'
25
+ });else if (part.type === 'literal') items.push({
26
+ kind: 'literal',
27
+ text: part.value
28
+ });
29
+ }
30
+ return items;
31
+ };
32
+
33
+ /** Focus / tab order for the three fields (locale order). */
34
+ export const getDateFieldOrder = layout => {
35
+ const order = [];
36
+ for (const item of layout) {
37
+ if (item.kind === 'field' && !order.includes(item.field)) {
38
+ order.push(item.field);
39
+ }
40
+ }
41
+ return order.length === 3 ? order : ['month', 'day', 'year'];
42
+ };
43
+
44
+ /** ISO 8601 date-only string for hidden form fields. */
45
+ export const formatDateISO8601DateOnly = date => {
46
+ const y = date.getFullYear();
47
+ const m = date.getMonth() + 1;
48
+ const d = date.getDate();
49
+ return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
50
+ };
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@codecademy/gamut",
3
3
  "description": "Styleguide & Component library for Codecademy",
4
- "version": "68.2.3-alpha.df0cb3.0",
4
+ "version": "68.2.3-alpha.e0ecfc.0",
5
5
  "author": "Codecademy Engineering <dev@codecademy.com>",
6
6
  "dependencies": {
7
- "@codecademy/gamut-icons": "9.57.3-alpha.df0cb3.0",
8
- "@codecademy/gamut-illustrations": "0.58.10-alpha.df0cb3.0",
9
- "@codecademy/gamut-patterns": "0.10.29-alpha.df0cb3.0",
10
- "@codecademy/gamut-styles": "17.13.2-alpha.df0cb3.0",
11
- "@codecademy/variance": "0.26.2-alpha.df0cb3.0",
7
+ "@codecademy/gamut-icons": "9.57.3-alpha.e0ecfc.0",
8
+ "@codecademy/gamut-illustrations": "0.58.10-alpha.e0ecfc.0",
9
+ "@codecademy/gamut-patterns": "0.10.29-alpha.e0ecfc.0",
10
+ "@codecademy/gamut-styles": "17.13.2-alpha.e0ecfc.0",
11
+ "@codecademy/variance": "0.26.2-alpha.e0ecfc.0",
12
12
  "@formatjs/intl-locale": "^5.3.1",
13
13
  "@react-aria/interactions": "3.25.0",
14
14
  "@types/marked": "^4.0.8",
@@ -1,16 +0,0 @@
1
- import { ComponentProps } from 'react';
2
- import { Input } from '../Form/inputs/Input';
3
- /**
4
- * Props for DatePickerInput. When used inside DatePicker, only overrides (e.g. placeholder, label).
5
- * In range mode, use rangePart to bind to start or end date. When outside DatePicker, pass value, onChange, etc.
6
- */
7
- export type DatePickerInputProps = Omit<ComponentProps<typeof Input>, 'type' | 'icon'> & {
8
- /** In range mode: which part of the range this input edits. Omit for single-date or combined display. */
9
- rangePart?: 'start' | 'end';
10
- };
11
- /**
12
- * Date input. When inside DatePicker: owns local input value state and syncs to
13
- * shared selectedDate via context on blur/parse; opens calendar on click/arrow down.
14
- * When outside DatePicker: fully controlled by props.
15
- */
16
- export declare const DatePickerInput: import("react").ForwardRefExoticComponent<Omit<DatePickerInputProps, "ref"> & import("react").RefAttributes<HTMLInputElement>>;
@@ -1,134 +0,0 @@
1
- import { MiniCalendarIcon } from '@codecademy/gamut-icons';
2
- import { forwardRef, useEffect, useId, useRef, useState } from 'react';
3
- import { FormGroup } from '../Form/elements/FormGroup';
4
- import { Input } from '../Form/inputs/Input';
5
- import { formatDateForInput, getDateFormatPattern, parseDateFromInput } from './Calendar/utils/format';
6
- import { useDatePicker } from './DatePickerContext';
7
-
8
- /**
9
- * Props for DatePickerInput. When used inside DatePicker, only overrides (e.g. placeholder, label).
10
- * In range mode, use rangePart to bind to start or end date. When outside DatePicker, pass value, onChange, etc.
11
- */
12
- import { jsx as _jsx } from "react/jsx-runtime";
13
- /**
14
- * Date input. When inside DatePicker: owns local input value state and syncs to
15
- * shared selectedDate via context on blur/parse; opens calendar on click/arrow down.
16
- * When outside DatePicker: fully controlled by props.
17
- */
18
- export const DatePickerInput = /*#__PURE__*/forwardRef(({
19
- placeholder,
20
- label,
21
- rangePart,
22
- ...rest
23
- }, ref) => {
24
- const context = useDatePicker();
25
- if (context == null) {
26
- throw new Error('DatePickerInput must be used inside a DatePicker (it reads shared state from context).');
27
- }
28
- const {
29
- mode,
30
- startOrSelectedDate,
31
- setSelection,
32
- openCalendar,
33
- focusCalendarGrid,
34
- locale,
35
- isCalendarOpen,
36
- calendarDialogId,
37
- translations
38
- } = context;
39
- const isRange = mode === 'range';
40
- const inputID = useId();
41
- const inputId = `datepicker-input-${inputID.replace(/:/g, '')}`;
42
-
43
- // Range with two inputs: each input binds to one part. Single or range combined: one value.
44
- const boundDate = isRange && rangePart === 'end' ? context.endDate : startOrSelectedDate;
45
- const formattedValue = boundDate != null ? formatDateForInput(boundDate, locale) : '';
46
- const [inputValue, setInputValue] = useState(() => formattedValue);
47
- const isInputFocusedRef = useRef(false);
48
-
49
- // Sync input from shared state. Skip when focused so we don't overwrite while typing.
50
- useEffect(() => {
51
- if (!isInputFocusedRef.current) {
52
- setInputValue(formattedValue);
53
- }
54
- }, [formattedValue]);
55
-
56
- /** Apply raw input string to selection state. Returns formatted string if parsed so caller can sync input (e.g. on blur). */
57
- const applyValueToSelection = raw => {
58
- const trimmed = raw.trim();
59
- if (!trimmed) {
60
- if (isRange && rangePart) {
61
- if (rangePart === 'start') setSelection(null, context.endDate);else setSelection(startOrSelectedDate, null);
62
- } else setSelection(null);
63
- return undefined;
64
- }
65
- const parsed = parseDateFromInput(trimmed, locale);
66
- if (!parsed) return undefined;
67
- if (isRange && rangePart) {
68
- if (rangePart === 'start') setSelection(parsed, context.endDate);else setSelection(startOrSelectedDate, parsed);
69
- } else setSelection(parsed);
70
- return formatDateForInput(parsed, locale);
71
- };
72
- const handleChange = e => {
73
- const raw = e.target.value;
74
- setInputValue(raw);
75
- applyValueToSelection(raw);
76
- };
77
- const handleBlur = () => {
78
- isInputFocusedRef.current = false;
79
- const formatted = applyValueToSelection(inputValue.trim());
80
- if (formatted) setInputValue(formatted);else if (inputValue.trim()) setInputValue(formattedValue);
81
- };
82
- const handleKeyDown = e => {
83
- if (e.key === 'ArrowDown' || e.key === 'Down') {
84
- e.preventDefault();
85
- if (isCalendarOpen) {
86
- focusCalendarGrid();
87
- } else {
88
- openCalendar({
89
- moveFocusIntoCalendar: true
90
- });
91
- }
92
- }
93
- };
94
- const handleOpenCalendar = () => {
95
- openCalendar({
96
- moveFocusIntoCalendar: false
97
- });
98
- };
99
- const defaultLabel = !isRange ? translations.dateLabel : rangePart === 'end' ? translations.endDateLabel : translations.startDateLabel;
100
- return /*#__PURE__*/_jsx(FormGroup, {
101
- htmlFor: inputId,
102
- isSoloField: true // should probaly be based on a prop
103
- ,
104
- label: label ?? defaultLabel,
105
- mb: 0,
106
- pb: 0,
107
- spacing: "tight",
108
- width: "170px",
109
- children: /*#__PURE__*/_jsx(Input, {
110
- ...rest,
111
- "aria-autocomplete": "none",
112
- "aria-controls": calendarDialogId,
113
- "aria-expanded": isCalendarOpen,
114
- "aria-haspopup": "dialog",
115
- icon: () => /*#__PURE__*/_jsx(MiniCalendarIcon, {
116
- size: 16
117
- }),
118
- id: inputId,
119
- placeholder: placeholder ?? getDateFormatPattern(locale),
120
- ref: ref,
121
- role: "combobox",
122
- type: "text",
123
- value: inputValue,
124
- onBlur: handleBlur,
125
- onChange: handleChange,
126
- onClick: handleOpenCalendar,
127
- onFocus: () => {
128
- isInputFocusedRef.current = true;
129
- if (isRange && rangePart) context.setActiveRangePart(rangePart);
130
- },
131
- onKeyDown: handleKeyDown
132
- })
133
- });
134
- });