@neoptocom/neopto-ui 1.5.4 → 1.6.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.
@@ -0,0 +1,217 @@
1
+ import * as React from "react";
2
+
3
+ export type CalendarProps = {
4
+ /** Currently selected date */
5
+ selectedDate?: Date;
6
+ /** Callback when a date is selected */
7
+ onDateSelect: (date: Date) => void;
8
+ /** Today's date (for highlighting) */
9
+ today?: Date;
10
+ /** Minimum selectable date */
11
+ minDate?: Date;
12
+ /** Maximum selectable date */
13
+ maxDate?: Date;
14
+ };
15
+
16
+ const DAYS_OF_WEEK = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
17
+ const MONTHS = [
18
+ "January",
19
+ "February",
20
+ "March",
21
+ "April",
22
+ "May",
23
+ "June",
24
+ "July",
25
+ "August",
26
+ "September",
27
+ "October",
28
+ "November",
29
+ "December",
30
+ ];
31
+
32
+ function isSameDay(date1: Date, date2: Date): boolean {
33
+ return (
34
+ date1.getDate() === date2.getDate() &&
35
+ date1.getMonth() === date2.getMonth() &&
36
+ date1.getFullYear() === date2.getFullYear()
37
+ );
38
+ }
39
+
40
+ function isSameMonth(date1: Date, date2: Date): boolean {
41
+ return (
42
+ date1.getMonth() === date2.getMonth() &&
43
+ date1.getFullYear() === date2.getFullYear()
44
+ );
45
+ }
46
+
47
+ function startOfDay(date: Date): Date {
48
+ const d = new Date(date);
49
+ d.setHours(0, 0, 0, 0);
50
+ return d;
51
+ }
52
+
53
+ export default function Calendar({
54
+ selectedDate,
55
+ onDateSelect,
56
+ today = new Date(),
57
+ minDate,
58
+ maxDate,
59
+ }: CalendarProps) {
60
+ const [currentMonth, setCurrentMonth] = React.useState(
61
+ selectedDate || new Date()
62
+ );
63
+
64
+ const todayStart = startOfDay(today);
65
+ const selectedDateStart = selectedDate ? startOfDay(selectedDate) : null;
66
+
67
+ const firstDayOfMonth = new Date(
68
+ currentMonth.getFullYear(),
69
+ currentMonth.getMonth(),
70
+ 1
71
+ );
72
+ const lastDayOfMonth = new Date(
73
+ currentMonth.getFullYear(),
74
+ currentMonth.getMonth() + 1,
75
+ 0
76
+ );
77
+ const daysInMonth = lastDayOfMonth.getDate();
78
+ const startingDayOfWeek = firstDayOfMonth.getDay();
79
+
80
+ const prevMonth = () => {
81
+ setCurrentMonth(
82
+ new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1)
83
+ );
84
+ };
85
+
86
+ const nextMonth = () => {
87
+ setCurrentMonth(
88
+ new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1)
89
+ );
90
+ };
91
+
92
+ const handleDateClick = (day: number) => {
93
+ const date = new Date(
94
+ currentMonth.getFullYear(),
95
+ currentMonth.getMonth(),
96
+ day
97
+ );
98
+ const dateStart = startOfDay(date);
99
+
100
+ if (minDate && dateStart < startOfDay(minDate)) return;
101
+ if (maxDate && dateStart > startOfDay(maxDate)) return;
102
+
103
+ onDateSelect(date);
104
+ };
105
+
106
+ const isDateDisabled = (day: number): boolean => {
107
+ const date = new Date(
108
+ currentMonth.getFullYear(),
109
+ currentMonth.getMonth(),
110
+ day
111
+ );
112
+ const dateStart = startOfDay(date);
113
+
114
+ if (minDate && dateStart < startOfDay(minDate)) return true;
115
+ if (maxDate && dateStart > startOfDay(maxDate)) return true;
116
+ return false;
117
+ };
118
+
119
+ const days = [];
120
+ // Empty cells for days before the first day of the month
121
+ for (let i = 0; i < startingDayOfWeek; i++) {
122
+ days.push(null);
123
+ }
124
+ // Days of the month
125
+ for (let day = 1; day <= daysInMonth; day++) {
126
+ days.push(day);
127
+ }
128
+
129
+ return (
130
+ <div className="w-full">
131
+ {/* Header */}
132
+ <div className="flex items-center justify-between mb-4">
133
+ <button
134
+ type="button"
135
+ onClick={prevMonth}
136
+ className="p-2 rounded-full hover:bg-[var(--muted)] transition-colors"
137
+ aria-label="Previous month"
138
+ >
139
+ <span className="material-symbols-rounded text-[var(--fg)]">
140
+ chevron_left
141
+ </span>
142
+ </button>
143
+ <h3 className="text-sm font-medium text-[var(--fg)]">
144
+ {MONTHS[currentMonth.getMonth()]} {currentMonth.getFullYear()}
145
+ </h3>
146
+ <button
147
+ type="button"
148
+ onClick={nextMonth}
149
+ className="p-2 rounded-full hover:bg-[var(--muted)] transition-colors"
150
+ aria-label="Next month"
151
+ >
152
+ <span className="material-symbols-rounded text-[var(--fg)]">
153
+ chevron_right
154
+ </span>
155
+ </button>
156
+ </div>
157
+
158
+ {/* Days of week header */}
159
+ <div className="grid grid-cols-7 gap-1 mb-2">
160
+ {DAYS_OF_WEEK.map((day) => (
161
+ <div
162
+ key={day}
163
+ className="text-xs text-center text-[var(--muted-fg)] font-medium py-1"
164
+ >
165
+ {day}
166
+ </div>
167
+ ))}
168
+ </div>
169
+
170
+ {/* Calendar grid */}
171
+ <div className="grid grid-cols-7 gap-1">
172
+ {days.map((day, idx) => {
173
+ if (day === null) {
174
+ return <div key={`empty-${idx}`} className="aspect-square" />;
175
+ }
176
+
177
+ const date = new Date(
178
+ currentMonth.getFullYear(),
179
+ currentMonth.getMonth(),
180
+ day
181
+ );
182
+ const dateStart = startOfDay(date);
183
+ const isSelected = selectedDateStart && isSameDay(dateStart, selectedDateStart);
184
+ const isToday = isSameDay(dateStart, todayStart);
185
+ const isDisabled = isDateDisabled(day);
186
+ const isCurrentMonth = isSameMonth(dateStart, currentMonth);
187
+
188
+ return (
189
+ <button
190
+ key={day}
191
+ type="button"
192
+ onClick={() => handleDateClick(day)}
193
+ disabled={isDisabled}
194
+ className={[
195
+ "aspect-square rounded-lg text-sm transition-colors",
196
+ isSelected
197
+ ? "bg-[var(--color-brand)] text-white font-medium"
198
+ : isToday
199
+ ? "bg-[var(--muted)] text-[var(--fg)] font-medium border border-[var(--color-brand)]"
200
+ : "text-[var(--fg)] hover:bg-[var(--muted)]",
201
+ isDisabled
202
+ ? "opacity-30 cursor-not-allowed"
203
+ : "cursor-pointer",
204
+ !isCurrentMonth ? "opacity-50" : "",
205
+ ]
206
+ .filter(Boolean)
207
+ .join(" ")}
208
+ >
209
+ {day}
210
+ </button>
211
+ );
212
+ })}
213
+ </div>
214
+ </div>
215
+ );
216
+ }
217
+
@@ -0,0 +1,225 @@
1
+ import * as React from "react";
2
+ import { Input } from "./Input";
3
+ import Calendar from "./Calendar";
4
+ import { Card } from "./Card";
5
+
6
+ export type DateInputProps = Omit<
7
+ React.InputHTMLAttributes<HTMLInputElement>,
8
+ "value" | "onChange" | "type"
9
+ > & {
10
+ /** Label text displayed above the input */
11
+ label?: string;
12
+ /** Current date value */
13
+ value?: Date | null;
14
+ /** Callback when date changes */
15
+ onChange: (date: Date) => void;
16
+ /** Flag to visually mark the input as errored */
17
+ error?: boolean;
18
+ /** Minimum selectable date */
19
+ minDate?: Date;
20
+ /** Maximum selectable date */
21
+ maxDate?: Date;
22
+ /** Placeholder text (default: "00/00/0000") */
23
+ placeholder?: string;
24
+ };
25
+
26
+ function formatDate(date: Date): string {
27
+ const day = String(date.getDate()).padStart(2, "0");
28
+ const month = String(date.getMonth() + 1).padStart(2, "0");
29
+ const year = date.getFullYear();
30
+ return `${day}/${month}/${year}`;
31
+ }
32
+
33
+ function parseDate(dateString: string): Date | null {
34
+ const parts = dateString.split("/");
35
+ if (parts.length !== 3) return null;
36
+
37
+ const day = parseInt(parts[0], 10);
38
+ const month = parseInt(parts[1], 10) - 1; // Month is 0-indexed
39
+ const year = parseInt(parts[2], 10);
40
+
41
+ if (isNaN(day) || isNaN(month) || isNaN(year)) return null;
42
+
43
+ const date = new Date(year, month, day);
44
+ // Check if date is valid
45
+ if (
46
+ date.getDate() !== day ||
47
+ date.getMonth() !== month ||
48
+ date.getFullYear() !== year
49
+ ) {
50
+ return null;
51
+ }
52
+
53
+ return date;
54
+ }
55
+
56
+ function isValidDate(date: Date): boolean {
57
+ return date instanceof Date && !isNaN(date.getTime());
58
+ }
59
+
60
+ function startOfDay(date: Date): Date {
61
+ const d = new Date(date);
62
+ d.setHours(0, 0, 0, 0);
63
+ return d;
64
+ }
65
+
66
+ export const DateInput = React.forwardRef<HTMLInputElement, DateInputProps>(
67
+ (
68
+ {
69
+ label,
70
+ value,
71
+ onChange,
72
+ error = false,
73
+ disabled = false,
74
+ minDate,
75
+ maxDate,
76
+ placeholder = "00/00/0000",
77
+ className = "",
78
+ ...props
79
+ },
80
+ ref
81
+ ) => {
82
+ const [inputValue, setInputValue] = React.useState(
83
+ value && isValidDate(value) ? formatDate(value) : placeholder
84
+ );
85
+ const [isFocused, setIsFocused] = React.useState(false);
86
+ const [showCalendar, setShowCalendar] = React.useState(false);
87
+ const [initialDateSet, setInitialDateSet] = React.useState(true);
88
+ const containerRef = React.useRef<HTMLDivElement>(null);
89
+
90
+ // Update input value when value prop changes
91
+ React.useEffect(() => {
92
+ if (value && isValidDate(value)) {
93
+ setInputValue(formatDate(value));
94
+ } else {
95
+ setInputValue(placeholder);
96
+ }
97
+ }, [value, placeholder]);
98
+
99
+ // Set today's date when calendar first opens
100
+ React.useEffect(() => {
101
+ if (showCalendar && initialDateSet) {
102
+ const today = new Date();
103
+ onChange(today);
104
+ setInputValue(formatDate(today));
105
+ setInitialDateSet(false);
106
+ }
107
+ }, [showCalendar, initialDateSet, onChange]);
108
+
109
+ // Handle click outside
110
+ React.useEffect(() => {
111
+ const handleClickOutside = (event: MouseEvent) => {
112
+ if (
113
+ containerRef.current &&
114
+ !containerRef.current.contains(event.target as Node)
115
+ ) {
116
+ setShowCalendar(false);
117
+ setInitialDateSet(false);
118
+ }
119
+ };
120
+
121
+ document.addEventListener("mousedown", handleClickOutside);
122
+ return () => document.removeEventListener("mousedown", handleClickOutside);
123
+ }, []);
124
+
125
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
126
+ let rawValue = e.target.value;
127
+
128
+ // Remove non-digits and format as dd/MM/yyyy
129
+ rawValue = rawValue
130
+ .replace(/\D/g, "")
131
+ .replace(/^(\d{2})/, "$1/")
132
+ .replace(/^(\d{2}\/\d{2})/, "$1/")
133
+ .slice(0, 10);
134
+
135
+ setInputValue(rawValue);
136
+
137
+ // Parse and validate when complete
138
+ if (rawValue.length === 10) {
139
+ const parsedDate = parseDate(rawValue);
140
+ if (parsedDate && isValidDate(parsedDate)) {
141
+ onChange(parsedDate);
142
+ }
143
+ }
144
+ };
145
+
146
+ const handleCalendarSelect = (date: Date) => {
147
+ const selectedDate = parseDate(inputValue);
148
+ const sameDay =
149
+ selectedDate &&
150
+ isValidDate(selectedDate) &&
151
+ selectedDate.getDate() === date.getDate() &&
152
+ selectedDate.getMonth() === date.getMonth() &&
153
+ selectedDate.getFullYear() === date.getFullYear();
154
+
155
+ if (!sameDay) {
156
+ onChange(date);
157
+ setInputValue(formatDate(date));
158
+ }
159
+
160
+ setInitialDateSet(false);
161
+ setShowCalendar(false);
162
+ };
163
+
164
+ const handleInputFocus = () => {
165
+ setIsFocused(true);
166
+ setShowCalendar(true);
167
+ };
168
+
169
+ const handleInputBlur = () => {
170
+ setIsFocused(false);
171
+ const parsed = parseDate(inputValue);
172
+ if (!parsed || !isValidDate(parsed)) {
173
+ const today = new Date();
174
+ onChange(today);
175
+ setInputValue(formatDate(today));
176
+ }
177
+ };
178
+
179
+ const isEmpty = inputValue === placeholder;
180
+ const textColorClass = isEmpty ? "text-[var(--muted-fg)]" : "";
181
+
182
+ return (
183
+ <div className={["relative w-full", className].join(" ")} ref={containerRef}>
184
+ <Input
185
+ ref={ref}
186
+ label={label}
187
+ type="text"
188
+ value={inputValue}
189
+ onChange={handleInputChange}
190
+ onFocus={handleInputFocus}
191
+ onBlur={handleInputBlur}
192
+ onClick={() => !disabled && setShowCalendar(true)}
193
+ disabled={disabled}
194
+ error={error}
195
+ icon="calendar_today"
196
+ className={textColorClass}
197
+ {...props}
198
+ />
199
+
200
+ {showCalendar && !disabled && (
201
+ <div className="absolute z-20 mt-2 w-full max-w-sm">
202
+ <Card className="p-4" showDecorations={false}>
203
+ <Calendar
204
+ selectedDate={
205
+ inputValue !== placeholder &&
206
+ parseDate(inputValue) &&
207
+ isValidDate(parseDate(inputValue)!)
208
+ ? parseDate(inputValue)!
209
+ : new Date()
210
+ }
211
+ onDateSelect={handleCalendarSelect}
212
+ today={startOfDay(new Date())}
213
+ minDate={minDate}
214
+ maxDate={maxDate}
215
+ />
216
+ </Card>
217
+ </div>
218
+ )}
219
+ </div>
220
+ );
221
+ }
222
+ );
223
+
224
+ DateInput.displayName = "DateInput";
225
+
package/src/index.ts CHANGED
@@ -23,6 +23,8 @@ export * from "./components/Chat";
23
23
  export * from "./components/MessageBubble";
24
24
  export * from "./components/Separator";
25
25
  export { Breadcrumb } from "./components/Breadcrumb";
26
+ export { DateInput } from "./components/DateInput";
27
+ export { default as Calendar } from "./components/Calendar";
26
28
 
27
29
  // Types
28
30
  export type { AppBackgroundProps } from "./components/AppBackground";
@@ -45,4 +47,6 @@ export type { CounterProps } from "./components/Counter";
45
47
  export type { AgentButtonProps } from "./components/Chat";
46
48
  export type { MessageBubbleProps } from "./components/MessageBubble";
47
49
  export type { SeparatorProps } from "./components/Separator";
48
- export type { BreadcrumbProps, BreadcrumbItem } from "./components/Breadcrumb";
50
+ export type { BreadcrumbProps, BreadcrumbItem } from "./components/Breadcrumb";
51
+ export type { DateInputProps } from "./components/DateInput";
52
+ export type { CalendarProps } from "./components/Calendar";
@@ -0,0 +1,136 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { DateInput } from "../components/DateInput";
3
+ import { useState } from "react";
4
+
5
+ const meta: Meta<typeof DateInput> = {
6
+ title: "Components/DateInput",
7
+ component: DateInput
8
+ };
9
+ export default meta;
10
+ type Story = StoryObj<typeof DateInput>;
11
+
12
+ export const Default: Story = {
13
+ render: () => {
14
+ const [date, setDate] = useState<Date | null>(null);
15
+ return (
16
+ <div className="flex flex-col gap-4 w-96">
17
+ <DateInput
18
+ label="Select date"
19
+ value={date}
20
+ onChange={(d) => setDate(d)}
21
+ />
22
+ </div>
23
+ );
24
+ }
25
+ };
26
+
27
+ export const WithInitialValue: Story = {
28
+ render: () => {
29
+ const [date, setDate] = useState<Date | null>(new Date());
30
+ return (
31
+ <div className="flex flex-col gap-4 w-96">
32
+ <DateInput
33
+ label="Date"
34
+ value={date}
35
+ onChange={(d) => setDate(d)}
36
+ />
37
+ </div>
38
+ );
39
+ }
40
+ };
41
+
42
+ export const WithoutLabel: Story = {
43
+ render: () => {
44
+ const [date, setDate] = useState<Date | null>(null);
45
+ return (
46
+ <div className="flex flex-col gap-4 w-96">
47
+ <DateInput
48
+ value={date}
49
+ onChange={(d) => setDate(d)}
50
+ />
51
+ </div>
52
+ );
53
+ }
54
+ };
55
+
56
+ export const Disabled: Story = {
57
+ render: () => {
58
+ const [date, setDate] = useState<Date | null>(new Date());
59
+ return (
60
+ <div className="flex flex-col gap-4 w-96">
61
+ <DateInput
62
+ label="Disabled date"
63
+ value={date}
64
+ onChange={(d) => setDate(d)}
65
+ disabled
66
+ />
67
+ </div>
68
+ );
69
+ }
70
+ };
71
+
72
+ export const WithError: Story = {
73
+ render: () => {
74
+ const [date, setDate] = useState<Date | null>(null);
75
+ return (
76
+ <div className="flex flex-col gap-4 w-96">
77
+ <DateInput
78
+ label="Date"
79
+ value={date}
80
+ onChange={(d) => setDate(d)}
81
+ error
82
+ />
83
+ </div>
84
+ );
85
+ }
86
+ };
87
+
88
+ export const WithMinMax: Story = {
89
+ render: () => {
90
+ const [date, setDate] = useState<Date | null>(null);
91
+ const today = new Date();
92
+ const minDate = new Date(today);
93
+ minDate.setDate(today.getDate() - 7); // 7 days ago
94
+ const maxDate = new Date(today);
95
+ maxDate.setDate(today.getDate() + 30); // 30 days from now
96
+
97
+ return (
98
+ <div className="flex flex-col gap-4 w-96">
99
+ <DateInput
100
+ label="Select date (within range)"
101
+ value={date}
102
+ onChange={(d) => setDate(d)}
103
+ minDate={minDate}
104
+ maxDate={maxDate}
105
+ />
106
+ <p className="text-xs text-[var(--muted-fg)]">
107
+ Available range: {minDate.toLocaleDateString()} to {maxDate.toLocaleDateString()}
108
+ </p>
109
+ </div>
110
+ );
111
+ }
112
+ };
113
+
114
+ export const Multiple: Story = {
115
+ render: () => {
116
+ const [startDate, setStartDate] = useState<Date | null>(null);
117
+ const [endDate, setEndDate] = useState<Date | null>(null);
118
+
119
+ return (
120
+ <div className="flex flex-col gap-4 w-96">
121
+ <DateInput
122
+ label="Start date"
123
+ value={startDate}
124
+ onChange={(d) => setStartDate(d)}
125
+ />
126
+ <DateInput
127
+ label="End date"
128
+ value={endDate}
129
+ onChange={(d) => setEndDate(d)}
130
+ minDate={startDate || undefined}
131
+ />
132
+ </div>
133
+ );
134
+ }
135
+ };
136
+