@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.
- package/dist/DatePicker/Calendar/CalendarBody.js +7 -3
- package/dist/DatePicker/Calendar/utils/format.d.ts +0 -16
- package/dist/DatePicker/Calendar/utils/format.js +0 -56
- package/dist/DatePicker/DatePicker.js +7 -5
- package/dist/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.d.ts +20 -0
- package/dist/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.js +138 -0
- package/dist/DatePicker/DatePickerInput/Segment/elements.d.ts +16 -0
- package/dist/DatePicker/DatePickerInput/Segment/elements.js +34 -0
- package/dist/DatePicker/DatePickerInput/Segment/index.d.ts +4 -0
- package/dist/DatePicker/DatePickerInput/Segment/index.js +3 -0
- package/dist/DatePicker/DatePickerInput/Segment/segmentUtils.d.ts +49 -0
- package/dist/DatePicker/DatePickerInput/Segment/segmentUtils.js +202 -0
- package/dist/DatePicker/DatePickerInput/elements.d.ts +15 -0
- package/dist/DatePicker/DatePickerInput/elements.js +19 -0
- package/dist/DatePicker/DatePickerInput/index.d.ts +13 -0
- package/dist/DatePicker/DatePickerInput/index.js +187 -0
- package/dist/DatePicker/DatePickerInput/utils.d.ts +17 -0
- package/dist/DatePicker/DatePickerInput/utils.js +50 -0
- package/package.json +6 -6
- package/dist/DatePicker/DatePickerInput.d.ts +0 -16
- package/dist/DatePicker/DatePickerInput.js +0 -134
|
@@ -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
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
|
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,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.
|
|
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.
|
|
8
|
-
"@codecademy/gamut-illustrations": "0.58.10-alpha.
|
|
9
|
-
"@codecademy/gamut-patterns": "0.10.29-alpha.
|
|
10
|
-
"@codecademy/gamut-styles": "17.13.2-alpha.
|
|
11
|
-
"@codecademy/variance": "0.26.2-alpha.
|
|
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
|
-
});
|