@motor-hero/ui-kit 0.33.0 → 0.34.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motor-hero/ui-kit",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "description": "Componentes React reutilizáveis para o ecossistema MotorHero — shadcn/ui + Tailwind CSS v4",
5
5
  "author": "MotorHero",
6
6
  "license": "UNLICENSED",
@@ -60,9 +60,12 @@
60
60
  "@radix-ui/react-dropdown-menu": "^2.1.0",
61
61
  "@radix-ui/react-popover": "^1.1.16",
62
62
  "@radix-ui/react-slot": "^1.1.0",
63
+ "@react-input/mask": "^2.0.4",
63
64
  "class-variance-authority": "^0.7.0",
64
65
  "cmdk": "^1.1.1",
66
+ "date-fns": "^4.4.0",
65
67
  "react-colorful": "^5.7.0",
68
+ "react-day-picker": "^10.0.1",
66
69
  "sonner": "^2.0.7",
67
70
  "vaul": "^1.1.2"
68
71
  },
@@ -0,0 +1,348 @@
1
+ import * as PopoverPrimitive from "@radix-ui/react-popover"
2
+ import { InputMask } from "@react-input/mask"
3
+ import { format, isValid, parse } from "date-fns"
4
+ import { ptBR } from "date-fns/locale"
5
+ import { CalendarIcon, ChevronLeft, ChevronRight, Clock } from "lucide-react"
6
+ import * as React from "react"
7
+ import { DayPicker, type Matcher } from "react-day-picker"
8
+ import { Drawer as DrawerPrimitive } from "vaul"
9
+ import { useIsDesktop } from "../hooks/use-is-desktop"
10
+ import { cn } from "../lib/utils"
11
+
12
+ const DATE_MASK = "__/__/____"
13
+ const TIME_MASK = "__:__"
14
+ const MASK_REPLACEMENT = { _: /\d/ }
15
+
16
+ const DATE_FORMAT = "dd/MM/yyyy"
17
+ const TIME_FORMAT = "HH:mm"
18
+ const DATE_VALUE_FORMAT = "yyyy-MM-dd"
19
+
20
+ export interface DatePickerProps {
21
+ /** `DatePicker`: data pura `"yyyy-MM-dd"`, sem conversão de fuso. `DateTimePicker`:
22
+ * ISO UTC completo (`...Z`). `""`/`undefined` = sem valor. */
23
+ value?: string
24
+ onChange: (value: string) => void
25
+ placeholder?: string
26
+ disabled?: boolean
27
+ /** `"yyyy-MM-dd"`, inclusive. */
28
+ minDate?: string
29
+ /** `"yyyy-MM-dd"`, inclusive. */
30
+ maxDate?: string
31
+ disabledDates?: (date: Date) => boolean
32
+ id?: string
33
+ className?: string
34
+ "aria-invalid"?: boolean | "true" | "false"
35
+ "aria-describedby"?: string
36
+ }
37
+
38
+ interface DatePickerImplProps extends DatePickerProps {
39
+ withTime?: boolean
40
+ }
41
+
42
+ function pad(n: number): string {
43
+ return String(n).padStart(2, "0")
44
+ }
45
+
46
+ /** Parseia `text` num formato estrito, rejeitando rollover (ex. 31/02 -> 03/03,
47
+ * 25:99 -> next day) via round-trip: só é válido se formatar de volta pro
48
+ * mesmo texto exato digitado. */
49
+ function parseStrict(text: string, fmt: string): Date | null {
50
+ const parsed = parse(text, fmt, new Date())
51
+ if (!isValid(parsed)) return null
52
+ return format(parsed, fmt) === text ? parsed : null
53
+ }
54
+
55
+ function dateOnlyToDate(value: string | undefined): Date | undefined {
56
+ if (!value) return undefined
57
+ const parsed = parse(value, DATE_VALUE_FORMAT, new Date())
58
+ return isValid(parsed) ? parsed : undefined
59
+ }
60
+
61
+ function dateTimeToDate(value: string | undefined): Date | undefined {
62
+ if (!value) return undefined
63
+ const parsed = new Date(value)
64
+ return Number.isNaN(parsed.getTime()) ? undefined : parsed
65
+ }
66
+
67
+ function displayFor(value: string | undefined, withTime: boolean): string {
68
+ const date = withTime ? dateTimeToDate(value) : dateOnlyToDate(value)
69
+ if (!date) return ""
70
+ const datePart = format(date, DATE_FORMAT)
71
+ return withTime ? `${datePart} ${pad(date.getHours())}:${pad(date.getMinutes())}` : datePart
72
+ }
73
+
74
+ function toMatchers(
75
+ minDate: string | undefined,
76
+ maxDate: string | undefined,
77
+ disabledDates: ((date: Date) => boolean) | undefined,
78
+ ): Matcher[] {
79
+ const matchers: Matcher[] = []
80
+ const min = dateOnlyToDate(minDate)
81
+ const max = dateOnlyToDate(maxDate)
82
+ if (min) matchers.push({ before: min })
83
+ if (max) matchers.push({ after: max })
84
+ if (disabledDates) matchers.push(disabledDates)
85
+ return matchers
86
+ }
87
+
88
+ const triggerInputClass =
89
+ "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 pr-10 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:focus-visible:ring-destructive"
90
+
91
+ const fieldInputClass =
92
+ "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
93
+
94
+ const calendarClassNames = {
95
+ months: "flex flex-col",
96
+ month: "flex flex-col gap-4",
97
+ month_caption: "flex justify-center pt-1 relative items-center h-7",
98
+ caption_label: "text-sm font-medium",
99
+ nav: "flex items-center gap-1 absolute inset-x-0 top-0 justify-between px-1",
100
+ button_previous:
101
+ "inline-flex size-7 items-center justify-center rounded-md border border-input bg-transparent p-0 opacity-50 hover:opacity-100",
102
+ button_next:
103
+ "inline-flex size-7 items-center justify-center rounded-md border border-input bg-transparent p-0 opacity-50 hover:opacity-100",
104
+ month_grid: "w-full border-collapse space-y-1",
105
+ weekdays: "flex",
106
+ weekday: "text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
107
+ week: "flex w-full mt-2",
108
+ day: "relative p-0 text-center text-sm focus-within:relative focus-within:z-20",
109
+ day_button:
110
+ "inline-flex size-8 items-center justify-center rounded-md p-0 font-normal hover:bg-accent hover:text-accent-foreground aria-selected:opacity-100",
111
+ selected:
112
+ "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground rounded-md [&>button]:bg-primary [&>button]:text-primary-foreground",
113
+ today: "bg-accent text-accent-foreground rounded-md",
114
+ outside: "text-muted-foreground opacity-50",
115
+ disabled: "text-muted-foreground opacity-50 pointer-events-none",
116
+ hidden: "invisible",
117
+ }
118
+
119
+ const calendarComponents = {
120
+ Chevron: ({ orientation }: { orientation?: string }) =>
121
+ orientation === "left" ? (
122
+ <ChevronLeft className="size-4" />
123
+ ) : (
124
+ <ChevronRight className="size-4" />
125
+ ),
126
+ }
127
+
128
+ /**
129
+ * DatePicker / DateTimePicker — calendário + campo digitável com máscara
130
+ * (nunca o `<input type="date"/time">` nativo). Desktop: Popover não-modal;
131
+ * mobile: Drawer (bottom sheet) — mesmo padrão de `Combobox`/`ColorPicker`.
132
+ *
133
+ * `DatePicker` fecha ao escolher o dia (não tem hora pra confirmar).
134
+ * `DateTimePicker` exige "Aplicar" — calendário e campo de horário só viram
135
+ * `value` quando os dois estão preenchidos.
136
+ */
137
+ function DatePickerImpl({
138
+ value,
139
+ onChange,
140
+ placeholder,
141
+ disabled,
142
+ minDate,
143
+ maxDate,
144
+ disabledDates,
145
+ withTime = false,
146
+ id,
147
+ className,
148
+ "aria-invalid": ariaInvalid,
149
+ "aria-describedby": ariaDescribedby,
150
+ }: DatePickerImplProps) {
151
+ const isDesktop = useIsDesktop()
152
+ const triggerRef = React.useRef<HTMLDivElement>(null)
153
+ const [open, setOpenRaw] = React.useState(false)
154
+ const [focused, setFocused] = React.useState(false)
155
+ const [editText, setEditText] = React.useState("")
156
+ const [pendingDate, setPendingDate] = React.useState<Date | undefined>()
157
+ const [pendingTime, setPendingTime] = React.useState("")
158
+
159
+ const committedDate = withTime ? dateTimeToDate(value) : dateOnlyToDate(value)
160
+ const displayValue = displayFor(value, withTime)
161
+ const matchers = toMatchers(minDate, maxDate, disabledDates)
162
+
163
+ const setOpen = (next: boolean) => {
164
+ setOpenRaw(next)
165
+ if (next) {
166
+ setEditText(displayValue)
167
+ setPendingDate(committedDate)
168
+ setPendingTime(
169
+ committedDate ? `${pad(committedDate.getHours())}:${pad(committedDate.getMinutes())}` : "",
170
+ )
171
+ }
172
+ }
173
+
174
+ const commitDateOnly = (date: Date) => {
175
+ onChange(format(date, DATE_VALUE_FORMAT))
176
+ setOpen(false)
177
+ }
178
+
179
+ const commitDateTime = (date: Date, timeText: string) => {
180
+ const time = parseStrict(timeText, TIME_FORMAT)
181
+ if (!time) return
182
+ const combined = new Date(date)
183
+ combined.setHours(time.getHours(), time.getMinutes(), 0, 0)
184
+ onChange(combined.toISOString())
185
+ setOpen(false)
186
+ }
187
+
188
+ const handleCalendarSelect = (date: Date) => {
189
+ if (!withTime) {
190
+ commitDateOnly(date)
191
+ return
192
+ }
193
+ setPendingDate(date)
194
+ setEditText(`${format(date, DATE_FORMAT)} ${pendingTime}`.trim())
195
+ }
196
+
197
+ const handleTriggerChange = (event: React.ChangeEvent<HTMLInputElement>) => {
198
+ const text = event.target.value
199
+ setEditText(text)
200
+ if (!withTime) {
201
+ const parsed = parseStrict(text, DATE_FORMAT)
202
+ if (parsed) commitDateOnly(parsed)
203
+ return
204
+ }
205
+ const [datePart, timePart] = [text.slice(0, 10), text.slice(11)]
206
+ const parsedDate = parseStrict(datePart, DATE_FORMAT)
207
+ if (parsedDate) {
208
+ setPendingDate(parsedDate)
209
+ if (timePart) setPendingTime(timePart)
210
+ }
211
+ if (parsedDate && timePart && parseStrict(timePart, TIME_FORMAT)) {
212
+ commitDateTime(parsedDate, timePart)
213
+ }
214
+ }
215
+
216
+ const handleTimeFieldChange = (event: React.ChangeEvent<HTMLInputElement>) => {
217
+ setPendingTime(event.target.value)
218
+ if (pendingDate) {
219
+ setEditText(`${format(pendingDate, DATE_FORMAT)} ${event.target.value}`.trim())
220
+ }
221
+ }
222
+
223
+ const canApply = withTime && !!pendingDate && !!parseStrict(pendingTime, TIME_FORMAT)
224
+
225
+ const trigger = (
226
+ <div ref={triggerRef} className="relative">
227
+ <InputMask
228
+ id={id}
229
+ mask={withTime ? `${DATE_MASK} ${TIME_MASK}` : DATE_MASK}
230
+ replacement={MASK_REPLACEMENT}
231
+ value={focused ? editText : displayValue}
232
+ onChange={handleTriggerChange}
233
+ onFocus={() => {
234
+ setFocused(true)
235
+ setOpen(true)
236
+ }}
237
+ onBlur={() => setFocused(false)}
238
+ placeholder={placeholder ?? (withTime ? "dd/mm/aaaa hh:mm" : "dd/mm/aaaa")}
239
+ disabled={disabled}
240
+ aria-invalid={ariaInvalid}
241
+ aria-describedby={ariaDescribedby}
242
+ className={cn(triggerInputClass, className)}
243
+ />
244
+ <button
245
+ type="button"
246
+ disabled={disabled}
247
+ aria-label={withTime ? "Abrir calendário e horário" : "Abrir calendário"}
248
+ aria-haspopup="dialog"
249
+ aria-expanded={open}
250
+ onClick={() => setOpen(!open)}
251
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
252
+ >
253
+ {withTime ? <Clock className="h-4 w-4" /> : <CalendarIcon className="h-4 w-4" />}
254
+ </button>
255
+ </div>
256
+ )
257
+
258
+ const content = (
259
+ <div className={withTime ? "p-3" : ""}>
260
+ <DayPicker
261
+ mode="single"
262
+ locale={ptBR}
263
+ selected={withTime ? pendingDate : committedDate}
264
+ defaultMonth={(withTime ? pendingDate : committedDate) ?? new Date()}
265
+ onSelect={(date) => date && handleCalendarSelect(date)}
266
+ disabled={matchers}
267
+ className={withTime ? "p-0" : "p-3"}
268
+ classNames={calendarClassNames}
269
+ components={calendarComponents}
270
+ />
271
+ {withTime && (
272
+ <>
273
+ <div className="mt-2 flex items-center gap-2 border-t pt-3">
274
+ <span className="text-sm text-muted-foreground">Horário</span>
275
+ <InputMask
276
+ mask={TIME_MASK}
277
+ replacement={MASK_REPLACEMENT}
278
+ value={pendingTime}
279
+ onChange={handleTimeFieldChange}
280
+ placeholder="hh:mm"
281
+ aria-label="Horário"
282
+ className={cn(fieldInputClass, "flex-1")}
283
+ />
284
+ </div>
285
+ <button
286
+ type="button"
287
+ disabled={!canApply}
288
+ onClick={() => pendingDate && commitDateTime(pendingDate, pendingTime)}
289
+ className="mt-3 flex h-9 w-full items-center justify-center rounded-md bg-primary text-sm font-medium text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 disabled:pointer-events-none disabled:opacity-50"
290
+ >
291
+ Aplicar
292
+ </button>
293
+ </>
294
+ )}
295
+ </div>
296
+ )
297
+
298
+ if (isDesktop) {
299
+ return (
300
+ <PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
301
+ <PopoverPrimitive.Anchor asChild>{trigger}</PopoverPrimitive.Anchor>
302
+ <PopoverPrimitive.Portal>
303
+ <PopoverPrimitive.Content
304
+ align="start"
305
+ sideOffset={4}
306
+ onOpenAutoFocus={(event) => event.preventDefault()}
307
+ onInteractOutside={(event) => {
308
+ if (triggerRef.current?.contains(event.target as Node)) event.preventDefault()
309
+ }}
310
+ className="pointer-events-auto z-50 w-auto overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md"
311
+ >
312
+ {content}
313
+ </PopoverPrimitive.Content>
314
+ </PopoverPrimitive.Portal>
315
+ </PopoverPrimitive.Root>
316
+ )
317
+ }
318
+
319
+ return (
320
+ <DrawerPrimitive.Root open={open} onOpenChange={setOpen}>
321
+ {trigger}
322
+ <DrawerPrimitive.Portal>
323
+ <DrawerPrimitive.Overlay className="fixed inset-0 z-50 bg-black/80" />
324
+ <DrawerPrimitive.Content
325
+ onOpenAutoFocus={(event) => event.preventDefault()}
326
+ onInteractOutside={(event) => {
327
+ if (triggerRef.current?.contains(event.target as Node)) event.preventDefault()
328
+ }}
329
+ className="fixed inset-x-0 bottom-0 z-50 mt-24 flex max-h-[85vh] flex-col rounded-t-2xl border bg-popover text-popover-foreground outline-none"
330
+ >
331
+ <DrawerPrimitive.Title className="sr-only">
332
+ {withTime ? "Selecione a data e o horário" : "Selecione a data"}
333
+ </DrawerPrimitive.Title>
334
+ <div className="mx-auto my-3 h-1.5 w-12 shrink-0 rounded-full bg-muted" />
335
+ <div className="flex justify-center overflow-y-auto pb-4">{content}</div>
336
+ </DrawerPrimitive.Content>
337
+ </DrawerPrimitive.Portal>
338
+ </DrawerPrimitive.Root>
339
+ )
340
+ }
341
+
342
+ export function DatePicker(props: DatePickerProps) {
343
+ return <DatePickerImpl {...props} />
344
+ }
345
+
346
+ export function DateTimePicker(props: DatePickerProps) {
347
+ return <DatePickerImpl {...props} withTime />
348
+ }
package/src/index.ts CHANGED
@@ -35,6 +35,8 @@ export { MultiCombobox } from "./components/multi-combobox"
35
35
  export type { MultiComboboxProps } from "./components/multi-combobox"
36
36
  export { ColorPicker } from "./components/color-picker"
37
37
  export type { ColorPickerProps } from "./components/color-picker"
38
+ export { DatePicker, DateTimePicker } from "./components/date-picker"
39
+ export type { DatePickerProps } from "./components/date-picker"
38
40
  export { StatCard } from "./components/stat-card"
39
41
  export { DataTableWrapper } from "./components/data-table-wrapper"
40
42
  export { MobileCardList } from "./components/mobile-card-list"