@strategicos/ui 0.8.0 → 0.9.1
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/README.md +23 -8
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/dist/components/date-input.d.ts +46 -0
- package/dist/components/date-input.d.ts.map +1 -0
- package/dist/components/date-input.js +202 -0
- package/dist/components/date-input.js.map +1 -0
- package/dist/components/date-picker-input.d.ts +49 -0
- package/dist/components/date-picker-input.d.ts.map +1 -0
- package/dist/components/date-picker-input.js +126 -0
- package/dist/components/date-picker-input.js.map +1 -0
- package/dist/components/date-time-picker.d.ts +27 -0
- package/dist/components/date-time-picker.d.ts.map +1 -0
- package/dist/components/date-time-picker.js +77 -0
- package/dist/components/date-time-picker.js.map +1 -0
- package/dist/components/dates.d.ts +94 -0
- package/dist/components/dates.d.ts.map +1 -0
- package/dist/components/dates.js +94 -0
- package/dist/components/dates.js.map +1 -0
- package/dist/components/popover.d.ts +1 -1
- package/dist/components/popover.d.ts.map +1 -1
- package/dist/components/popover.js +9 -2
- package/dist/components/popover.js.map +1 -1
- package/dist/components/time-input.d.ts +16 -0
- package/dist/components/time-input.d.ts.map +1 -0
- package/dist/components/time-input.js +18 -0
- package/dist/components/time-input.js.map +1 -0
- package/package.json +1 -1
- package/src/components/date-input.tsx +287 -0
- package/src/components/date-picker-input.tsx +287 -0
- package/src/components/date-time-picker.tsx +152 -0
- package/src/components/dates.tsx +274 -0
- package/src/components/popover.tsx +10 -0
- package/src/components/time-input.tsx +34 -0
- package/dist/components/date-picker.d.ts +0 -78
- package/dist/components/date-picker.d.ts.map +0 -1
- package/dist/components/date-picker.js +0 -142
- package/dist/components/date-picker.js.map +0 -1
- package/src/components/date-picker.tsx +0 -414
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { CalendarIcon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../lib/utils.js"
|
|
7
|
+
import { Calendar } from "./calendar.js"
|
|
8
|
+
import {
|
|
9
|
+
clampDate, ClearButton, formatDate, formatDateTime, inRange, outOfRange,
|
|
10
|
+
parseDateValue, toDateValue, type FieldControlProps,
|
|
11
|
+
} from "./dates.js"
|
|
12
|
+
import { Input } from "./input.js"
|
|
13
|
+
import { Popover, PopoverAnchor, PopoverContent } from "./popover.js"
|
|
14
|
+
|
|
15
|
+
/** Põe as barras (e o espaço e os dois-pontos da hora) entre os dígitos.
|
|
16
|
+
* Separador só ENTRE grupos já começados: "15" e não "15/" — assim apagar
|
|
17
|
+
* para trás nunca esbarra num separador que volta sozinho. */
|
|
18
|
+
function mask(digits: string, withTime: boolean) {
|
|
19
|
+
const d = digits.slice(0, withTime ? 12 : 8)
|
|
20
|
+
let out = d.slice(0, 2)
|
|
21
|
+
if (d.length > 2) out += "/" + d.slice(2, 4)
|
|
22
|
+
if (d.length > 4) out += "/" + d.slice(4, 8)
|
|
23
|
+
if (d.length > 8) out += " " + d.slice(8, 10)
|
|
24
|
+
if (d.length > 10) out += ":" + d.slice(10, 12)
|
|
25
|
+
return out
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const countDigits = (s: string) => s.replace(/\D/g, "").length
|
|
29
|
+
|
|
30
|
+
/** Posição do cursor logo depois do n-ésimo dígito do texto mascarado. */
|
|
31
|
+
function caretAfterDigits(masked: string, n: number) {
|
|
32
|
+
if (n === 0) return 0
|
|
33
|
+
let seen = 0
|
|
34
|
+
for (let i = 0; i < masked.length; i++) {
|
|
35
|
+
if (/\d/.test(masked[i]!) && ++seen === n) return i + 1
|
|
36
|
+
}
|
|
37
|
+
return masked.length
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Texto digitado → valor. Data completa → "AAAA-MM-DD"; com hora →
|
|
42
|
+
* "AAAA-MM-DD HH:mm:00". Incompleto ou inexistente (31/02) → undefined.
|
|
43
|
+
* `dateOnlyOk`: com hora, aceita só a data (hora 00:00) — vale ao sair do
|
|
44
|
+
* campo, não enquanto se digita (a hora pode estar vindo).
|
|
45
|
+
*/
|
|
46
|
+
function parseTyped(text: string, withTime: boolean, dateOnlyOk: boolean): string | undefined {
|
|
47
|
+
const d = text.replace(/\D/g, "")
|
|
48
|
+
if (d.length < 8) return undefined
|
|
49
|
+
const date = `${d.slice(4, 8)}-${d.slice(2, 4)}-${d.slice(0, 2)}`
|
|
50
|
+
let v: string
|
|
51
|
+
if (!withTime) v = date
|
|
52
|
+
else if (d.length === 12) v = `${date} ${d.slice(8, 10)}:${d.slice(10, 12)}:00`
|
|
53
|
+
else if (dateOnlyOk && d.length === 8) v = `${date} 00:00:00`
|
|
54
|
+
else return undefined
|
|
55
|
+
return parseDateValue(v) ? v : undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Data digitada, como o DateInput do Mantine: dd/mm/aaaa com as barras postas
|
|
60
|
+
* sozinhas, e um calendário que abre no clique (ou ↓) para quem prefere
|
|
61
|
+
* escolher. `withTime` acrescenta a hora: dd/mm/aaaa hh:mm.
|
|
62
|
+
*
|
|
63
|
+
* <Field label="Início">
|
|
64
|
+
* <DateInput value={inicio} onValueChange={setInicio} maxDate={fim} />
|
|
65
|
+
* </Field>
|
|
66
|
+
*
|
|
67
|
+
* - Valor: "AAAA-MM-DD" (com `withTime`, "AAAA-MM-DD HH:mm:ss") ou `null`.
|
|
68
|
+
* - Grava assim que o texto vira uma data válida dentro de `minDate`/`maxDate`.
|
|
69
|
+
* Ao sair do campo, texto incompleto ou fora dos limites volta ao último
|
|
70
|
+
* valor válido; campo vazio grava `null`.
|
|
71
|
+
* - Colar "2026-10-15" (ISO) também funciona.
|
|
72
|
+
*
|
|
73
|
+
* Teclado: ↓ abre o calendário e põe o foco nele; Enter confirma o texto; Esc
|
|
74
|
+
* fecha o calendário.
|
|
75
|
+
*/
|
|
76
|
+
function DateInput({
|
|
77
|
+
value,
|
|
78
|
+
onValueChange,
|
|
79
|
+
withTime = false,
|
|
80
|
+
minDate,
|
|
81
|
+
maxDate,
|
|
82
|
+
placeholder = withTime ? "dd/mm/aaaa hh:mm" : "dd/mm/aaaa",
|
|
83
|
+
clearable = false,
|
|
84
|
+
withCalendar = true,
|
|
85
|
+
captionLayout,
|
|
86
|
+
disabled,
|
|
87
|
+
required,
|
|
88
|
+
name,
|
|
89
|
+
className,
|
|
90
|
+
wrapperClassName,
|
|
91
|
+
onBlur,
|
|
92
|
+
"aria-invalid": ariaInvalid,
|
|
93
|
+
...props
|
|
94
|
+
}: FieldControlProps & {
|
|
95
|
+
value: string | null
|
|
96
|
+
onValueChange: (value: string | null) => void
|
|
97
|
+
/** Data e hora (dd/mm/aaaa hh:mm). */
|
|
98
|
+
withTime?: boolean
|
|
99
|
+
/** Primeiro dia aceito ("AAAA-MM-DD"). */
|
|
100
|
+
minDate?: string
|
|
101
|
+
/** Último dia aceito ("AAAA-MM-DD"). */
|
|
102
|
+
maxDate?: string
|
|
103
|
+
placeholder?: string
|
|
104
|
+
/** Um X no campo limpa o valor. */
|
|
105
|
+
clearable?: boolean
|
|
106
|
+
/** Calendário no clique. Sem ele, o campo é só de digitar. */
|
|
107
|
+
withCalendar?: boolean
|
|
108
|
+
captionLayout?: "label" | "dropdown" | "dropdown-months" | "dropdown-years"
|
|
109
|
+
disabled?: boolean
|
|
110
|
+
required?: boolean
|
|
111
|
+
name?: string
|
|
112
|
+
/** Classes do <input>. */
|
|
113
|
+
className?: string
|
|
114
|
+
/** Classes do invólucro (largura e posição no layout). */
|
|
115
|
+
wrapperClassName?: string
|
|
116
|
+
onBlur?: React.FocusEventHandler<HTMLInputElement>
|
|
117
|
+
}) {
|
|
118
|
+
const display = React.useCallback((v: string | null) => {
|
|
119
|
+
const d = parseDateValue(v)
|
|
120
|
+
return d ? (withTime ? formatDateTime(d) : formatDate(d)) : ""
|
|
121
|
+
}, [withTime])
|
|
122
|
+
|
|
123
|
+
const [text, setText] = React.useState(() => display(value))
|
|
124
|
+
const [open, setOpen] = React.useState(false)
|
|
125
|
+
// Pedido de foco nos dias (↓). Contador, e não booleano: a ↓ com o
|
|
126
|
+
// calendário já aberto também precisa levar o foco.
|
|
127
|
+
const [focusRequest, setFocusRequest] = React.useState(0)
|
|
128
|
+
const [month, setMonth] = React.useState<Date | undefined>(() => parseDateValue(value))
|
|
129
|
+
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
130
|
+
const anchorRef = React.useRef<HTMLDivElement>(null)
|
|
131
|
+
const contentRef = React.useRef<HTMLDivElement>(null)
|
|
132
|
+
const caret = React.useRef<number | null>(null)
|
|
133
|
+
|
|
134
|
+
// Valor trocado por fora (outro campo, reset do formulário): o texto segue,
|
|
135
|
+
// a menos que já represente esse valor (é o que se está digitando).
|
|
136
|
+
const [prevValue, setPrevValue] = React.useState(value)
|
|
137
|
+
if (value !== prevValue) {
|
|
138
|
+
setPrevValue(value)
|
|
139
|
+
if (parseTyped(text, withTime, true) !== value) setText(display(value))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Recoloca o cursor onde estava depois que a máscara reescreveu o texto.
|
|
143
|
+
React.useLayoutEffect(() => {
|
|
144
|
+
if (caret.current === null || !inputRef.current) return
|
|
145
|
+
inputRef.current.setSelectionRange(caret.current, caret.current)
|
|
146
|
+
caret.current = null
|
|
147
|
+
}, [text])
|
|
148
|
+
|
|
149
|
+
const accepts = (v: string | undefined): v is string => !!v && inRange(v, minDate, maxDate)
|
|
150
|
+
|
|
151
|
+
function setTyped(next: string, caretAt: number | null) {
|
|
152
|
+
caret.current = caretAt
|
|
153
|
+
setText(next)
|
|
154
|
+
const v = parseTyped(next, withTime, false)
|
|
155
|
+
if (accepts(v)) {
|
|
156
|
+
setMonth(parseDateValue(v))
|
|
157
|
+
if (v !== value) onValueChange(v)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
162
|
+
const raw = e.target.value
|
|
163
|
+
const before = countDigits(raw.slice(0, e.target.selectionStart ?? raw.length))
|
|
164
|
+
const next = mask(raw.replace(/\D/g, ""), withTime)
|
|
165
|
+
setTyped(next, caretAfterDigits(next, before))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function handlePaste(e: React.ClipboardEvent<HTMLInputElement>) {
|
|
169
|
+
// ISO colado ("2026-10-15", "2026-10-15 14:30") vira o texto brasileiro.
|
|
170
|
+
const d = parseDateValue(e.clipboardData.getData("text").trim())
|
|
171
|
+
if (!d) return
|
|
172
|
+
e.preventDefault()
|
|
173
|
+
setTyped(withTime ? formatDateTime(d) : formatDate(d), null)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Sair do campo (ou Enter): grava o texto válido ou volta ao último valor. */
|
|
177
|
+
function settle() {
|
|
178
|
+
if (!text.trim()) {
|
|
179
|
+
if (value !== null) onValueChange(null)
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
const v = parseTyped(text, withTime, true)
|
|
183
|
+
if (accepts(v)) {
|
|
184
|
+
if (v !== value) onValueChange(v)
|
|
185
|
+
setText(display(v))
|
|
186
|
+
} else {
|
|
187
|
+
setText(display(value))
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// O dia focável do DayPicker é o único com tabindex=0 (o escolhido ou hoje).
|
|
192
|
+
React.useEffect(() => {
|
|
193
|
+
if (!focusRequest) return
|
|
194
|
+
contentRef.current?.querySelector<HTMLButtonElement>('[role=grid] button[tabindex="0"]')?.focus()
|
|
195
|
+
}, [focusRequest])
|
|
196
|
+
|
|
197
|
+
function openCalendar(focus: boolean) {
|
|
198
|
+
if (!withCalendar || disabled) return
|
|
199
|
+
if (!open) setMonth(parseDateValue(value))
|
|
200
|
+
setOpen(true)
|
|
201
|
+
if (focus) setFocusRequest(n => n + 1)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function pickDay(day: Date) {
|
|
205
|
+
// Com hora, mantém a hora já digitada (ou a do valor, ou 00:00).
|
|
206
|
+
const current = parseTyped(text, withTime, true) ?? value
|
|
207
|
+
const time = current && current.length >= 19 ? current.slice(11, 19) : "00:00:00"
|
|
208
|
+
const v = withTime ? `${toDateValue(day)} ${time}` : toDateValue(day)
|
|
209
|
+
onValueChange(v)
|
|
210
|
+
setText(display(v))
|
|
211
|
+
setOpen(false)
|
|
212
|
+
inputRef.current?.focus()
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Digitado e completo, mas inválido ou fora dos limites: borda de erro já
|
|
216
|
+
// enquanto se digita (o Field, se houver, ainda manda no aria-invalid).
|
|
217
|
+
const typedInvalid = countDigits(text) === (withTime ? 12 : 8) && !accepts(parseTyped(text, withTime, false))
|
|
218
|
+
const min = parseDateValue(minDate)
|
|
219
|
+
const max = parseDateValue(maxDate)
|
|
220
|
+
const selected = parseDateValue(value)
|
|
221
|
+
const showClear = clearable && !!text && !disabled
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
225
|
+
<PopoverAnchor asChild>
|
|
226
|
+
<div ref={anchorRef} data-slot="date-input-wrapper" className={cn("relative w-full min-w-0", wrapperClassName)}>
|
|
227
|
+
<Input
|
|
228
|
+
ref={inputRef}
|
|
229
|
+
data-slot="date-input"
|
|
230
|
+
value={text}
|
|
231
|
+
placeholder={placeholder}
|
|
232
|
+
disabled={disabled}
|
|
233
|
+
required={required}
|
|
234
|
+
name={name}
|
|
235
|
+
inputMode="numeric"
|
|
236
|
+
autoComplete="off"
|
|
237
|
+
aria-haspopup={withCalendar ? "dialog" : undefined}
|
|
238
|
+
aria-expanded={withCalendar ? open : undefined}
|
|
239
|
+
aria-invalid={ariaInvalid ?? (typedInvalid || undefined)}
|
|
240
|
+
leftSection={<CalendarIcon />}
|
|
241
|
+
className={cn("tabular-nums", showClear && "pr-9", className)}
|
|
242
|
+
onChange={handleChange}
|
|
243
|
+
onPaste={handlePaste}
|
|
244
|
+
onClick={() => { if (!open) openCalendar(false) }}
|
|
245
|
+
onKeyDown={e => {
|
|
246
|
+
if (e.key === "ArrowDown" && withCalendar) { e.preventDefault(); openCalendar(true) }
|
|
247
|
+
else if (e.key === "Enter") { e.preventDefault(); settle(); setOpen(false) }
|
|
248
|
+
}}
|
|
249
|
+
onBlur={e => { settle(); onBlur?.(e) }}
|
|
250
|
+
{...props}
|
|
251
|
+
/>
|
|
252
|
+
{showClear && (
|
|
253
|
+
<ClearButton onClick={() => { setText(""); onValueChange(null); inputRef.current?.focus() }} />
|
|
254
|
+
)}
|
|
255
|
+
</div>
|
|
256
|
+
</PopoverAnchor>
|
|
257
|
+
|
|
258
|
+
{withCalendar && (
|
|
259
|
+
<PopoverContent
|
|
260
|
+
ref={contentRef}
|
|
261
|
+
className="w-auto p-0"
|
|
262
|
+
// O foco fica no campo: dá para continuar digitando com o calendário
|
|
263
|
+
// aberto. Só a ↓ leva o foco para os dias.
|
|
264
|
+
onOpenAutoFocus={e => e.preventDefault()}
|
|
265
|
+
onCloseAutoFocus={e => e.preventDefault()}
|
|
266
|
+
// Clicar no próprio campo não conta como "fora".
|
|
267
|
+
onInteractOutside={e => { if (anchorRef.current?.contains(e.target as Node)) e.preventDefault() }}
|
|
268
|
+
>
|
|
269
|
+
<Calendar
|
|
270
|
+
mode="single"
|
|
271
|
+
required
|
|
272
|
+
selected={selected}
|
|
273
|
+
onSelect={pickDay}
|
|
274
|
+
month={month ?? clampDate(new Date(), min, max)}
|
|
275
|
+
onMonthChange={setMonth}
|
|
276
|
+
startMonth={min}
|
|
277
|
+
endMonth={max}
|
|
278
|
+
disabled={outOfRange(min, max)}
|
|
279
|
+
captionLayout={captionLayout}
|
|
280
|
+
/>
|
|
281
|
+
</PopoverContent>
|
|
282
|
+
)}
|
|
283
|
+
</Popover>
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export { DateInput }
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
// Derivado do DatePicker do Tremor (Apache-2.0) e modificado pela Strategicos.
|
|
4
|
+
// Ver THIRD_PARTY_NOTICES.md.
|
|
5
|
+
|
|
6
|
+
import * as React from "react"
|
|
7
|
+
|
|
8
|
+
import { useIsMobile } from "../hooks/use-mobile.js"
|
|
9
|
+
import { Calendar } from "./calendar.js"
|
|
10
|
+
import {
|
|
11
|
+
clampDate, formatDate, inRange, outOfRange, parseDateValue, toDateValue,
|
|
12
|
+
PickerFooter, PickerHint, PickerTrigger, PresetList, type PickerBaseProps,
|
|
13
|
+
} from "./dates.js"
|
|
14
|
+
import { useFieldControl } from "./field.js"
|
|
15
|
+
import { Popover, PopoverContent } from "./popover.js"
|
|
16
|
+
|
|
17
|
+
export type DatePreset = { label: string; value: string }
|
|
18
|
+
export type DateRangeValue = [string | null, string | null]
|
|
19
|
+
export type DateRangePreset = { label: string; value: [string, string] }
|
|
20
|
+
|
|
21
|
+
type SingleProps = PickerBaseProps & {
|
|
22
|
+
type?: "default"
|
|
23
|
+
value: string | null
|
|
24
|
+
onValueChange: (value: string | null) => void
|
|
25
|
+
presets?: DatePreset[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type RangeProps = PickerBaseProps & {
|
|
29
|
+
type: "range"
|
|
30
|
+
value: DateRangeValue
|
|
31
|
+
onValueChange: (value: DateRangeValue) => void
|
|
32
|
+
presets?: DateRangePreset[]
|
|
33
|
+
/** Meses lado a lado (no celular é sempre um). Padrão: 2. */
|
|
34
|
+
numberOfColumns?: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const contentCls = "flex w-auto max-w-[calc(100vw-2rem)] flex-col p-0 sm:flex-row"
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Campo que abre um calendário para escolher uma data ou um período, como o
|
|
41
|
+
* DatePickerInput do Mantine. Não se digita nele — para digitar, `DateInput`.
|
|
42
|
+
*
|
|
43
|
+
* <Field label="Prazo">
|
|
44
|
+
* <DatePickerInput value={prazo} onValueChange={setPrazo} minDate={inicio} clearable />
|
|
45
|
+
* </Field>
|
|
46
|
+
*
|
|
47
|
+
* <DatePickerInput type="range" value={[inicio, fim]} onValueChange={([i, f]) => …} withApplyButton />
|
|
48
|
+
*
|
|
49
|
+
* - Valor: "AAAA-MM-DD" (ou `[início, fim]` no `type="range"`), `null` quando vazio.
|
|
50
|
+
* - `withApplyButton`: a escolha só vale no Aplicar; fechar de outro jeito
|
|
51
|
+
* descarta. Sem ele, escolher o dia (ou o fim do período) já grava e fecha.
|
|
52
|
+
* - `presets`: atalhos ao lado ("Hoje", "Fim do mês"…); os que caem fora de
|
|
53
|
+
* `minDate`/`maxDate` ficam desabilitados.
|
|
54
|
+
* - Período: o primeiro clique marca o início e o segundo, o fim; se o segundo
|
|
55
|
+
* vier antes, os dois trocam.
|
|
56
|
+
*
|
|
57
|
+
* Teclado: o calendário abre com o foco no dia escolhido (ou hoje); setas
|
|
58
|
+
* andam pelos dias, PageUp/PageDown pelos meses, Enter escolhe, Esc fecha.
|
|
59
|
+
*/
|
|
60
|
+
function DatePickerInput(props: SingleProps | RangeProps) {
|
|
61
|
+
return props.type === "range" ? <RangePicker {...props} /> : <SinglePicker {...props} />
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function SinglePicker({
|
|
65
|
+
type: _type,
|
|
66
|
+
value,
|
|
67
|
+
onValueChange,
|
|
68
|
+
placeholder = "Selecione a data",
|
|
69
|
+
minDate,
|
|
70
|
+
maxDate,
|
|
71
|
+
clearable = false,
|
|
72
|
+
withApplyButton = false,
|
|
73
|
+
presets,
|
|
74
|
+
valueFormatter = formatDate,
|
|
75
|
+
captionLayout,
|
|
76
|
+
size,
|
|
77
|
+
disabled,
|
|
78
|
+
className,
|
|
79
|
+
wrapperClassName,
|
|
80
|
+
align = "start",
|
|
81
|
+
onOpenChange,
|
|
82
|
+
...props
|
|
83
|
+
}: SingleProps) {
|
|
84
|
+
const controlProps = useFieldControl(props, { withRequired: false })
|
|
85
|
+
const [open, setOpen] = React.useState(false)
|
|
86
|
+
// Com o Aplicar, a escolha fica aqui até confirmar.
|
|
87
|
+
const [draft, setDraft] = React.useState<string | null>(value)
|
|
88
|
+
const shown = withApplyButton ? draft : value
|
|
89
|
+
|
|
90
|
+
const selected = parseDateValue(shown)
|
|
91
|
+
const committed = parseDateValue(value)
|
|
92
|
+
const min = parseDateValue(minDate)
|
|
93
|
+
const max = parseDateValue(maxDate)
|
|
94
|
+
|
|
95
|
+
function handleOpenChange(o: boolean) {
|
|
96
|
+
setOpen(o)
|
|
97
|
+
if (o) setDraft(value)
|
|
98
|
+
onOpenChange?.(o)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function pick(v: string) {
|
|
102
|
+
if (withApplyButton) { setDraft(v); return }
|
|
103
|
+
onValueChange(v)
|
|
104
|
+
handleOpenChange(false)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function apply() {
|
|
108
|
+
if (draft !== value) onValueChange(draft)
|
|
109
|
+
handleOpenChange(false)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<Popover open={open} onOpenChange={handleOpenChange}>
|
|
114
|
+
<PickerTrigger
|
|
115
|
+
slot="date-picker-input"
|
|
116
|
+
text={committed ? valueFormatter(committed) : null}
|
|
117
|
+
placeholder={placeholder}
|
|
118
|
+
open={open}
|
|
119
|
+
size={size}
|
|
120
|
+
disabled={disabled}
|
|
121
|
+
clearable={clearable}
|
|
122
|
+
onClear={() => onValueChange(null)}
|
|
123
|
+
className={className}
|
|
124
|
+
wrapperClassName={wrapperClassName}
|
|
125
|
+
controlProps={controlProps}
|
|
126
|
+
/>
|
|
127
|
+
<PopoverContent
|
|
128
|
+
align={align}
|
|
129
|
+
className={contentCls}
|
|
130
|
+
// O foco vai para o dia (autoFocus do calendário), não para o 1º botão.
|
|
131
|
+
onOpenAutoFocus={e => e.preventDefault()}
|
|
132
|
+
>
|
|
133
|
+
{presets && presets.length > 0 && (
|
|
134
|
+
<PresetList
|
|
135
|
+
presets={presets}
|
|
136
|
+
isActive={v => v === shown}
|
|
137
|
+
isDisabled={v => !inRange(v, minDate, maxDate)}
|
|
138
|
+
onSelect={pick}
|
|
139
|
+
/>
|
|
140
|
+
)}
|
|
141
|
+
<div className="flex flex-col">
|
|
142
|
+
<Calendar
|
|
143
|
+
mode="single"
|
|
144
|
+
required
|
|
145
|
+
autoFocus
|
|
146
|
+
selected={selected}
|
|
147
|
+
onSelect={d => pick(toDateValue(d))}
|
|
148
|
+
defaultMonth={selected ?? clampDate(new Date(), min, max)}
|
|
149
|
+
startMonth={min}
|
|
150
|
+
endMonth={max}
|
|
151
|
+
disabled={outOfRange(min, max)}
|
|
152
|
+
captionLayout={captionLayout}
|
|
153
|
+
/>
|
|
154
|
+
{withApplyButton && (
|
|
155
|
+
<PickerFooter onCancel={() => handleOpenChange(false)} onApply={apply}>
|
|
156
|
+
<PickerHint>{selected ? valueFormatter(selected) : "Nenhuma data"}</PickerHint>
|
|
157
|
+
</PickerFooter>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
</PopoverContent>
|
|
161
|
+
</Popover>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function RangePicker({
|
|
166
|
+
type: _type,
|
|
167
|
+
value,
|
|
168
|
+
onValueChange,
|
|
169
|
+
placeholder = "Selecione o período",
|
|
170
|
+
minDate,
|
|
171
|
+
maxDate,
|
|
172
|
+
clearable = false,
|
|
173
|
+
withApplyButton = false,
|
|
174
|
+
presets,
|
|
175
|
+
numberOfColumns = 2,
|
|
176
|
+
valueFormatter = formatDate,
|
|
177
|
+
captionLayout,
|
|
178
|
+
size,
|
|
179
|
+
disabled,
|
|
180
|
+
className,
|
|
181
|
+
wrapperClassName,
|
|
182
|
+
align = "start",
|
|
183
|
+
onOpenChange,
|
|
184
|
+
...props
|
|
185
|
+
}: RangeProps) {
|
|
186
|
+
const controlProps = useFieldControl(props, { withRequired: false })
|
|
187
|
+
const isMobile = useIsMobile()
|
|
188
|
+
const [open, setOpen] = React.useState(false)
|
|
189
|
+
const [draft, setDraft] = React.useState<DateRangeValue>(value)
|
|
190
|
+
// Início escolhido nesta abertura, à espera do fim.
|
|
191
|
+
const [pendingFrom, setPendingFrom] = React.useState<Date | null>(null)
|
|
192
|
+
const shown = withApplyButton ? draft : value
|
|
193
|
+
|
|
194
|
+
const from = parseDateValue(shown[0])
|
|
195
|
+
const to = parseDateValue(shown[1])
|
|
196
|
+
const min = parseDateValue(minDate)
|
|
197
|
+
const max = parseDateValue(maxDate)
|
|
198
|
+
|
|
199
|
+
const format = (range: DateRangeValue) => {
|
|
200
|
+
const f = parseDateValue(range[0])
|
|
201
|
+
const t = parseDateValue(range[1])
|
|
202
|
+
return f ? `${valueFormatter(f)} – ${t ? valueFormatter(t) : "…"}` : null
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function handleOpenChange(o: boolean) {
|
|
206
|
+
setOpen(o)
|
|
207
|
+
setPendingFrom(null)
|
|
208
|
+
if (o) setDraft(value)
|
|
209
|
+
onOpenChange?.(o)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function commit(range: [string, string]) {
|
|
213
|
+
if (withApplyButton) { setDraft(range); setPendingFrom(null); return }
|
|
214
|
+
onValueChange(range)
|
|
215
|
+
handleOpenChange(false)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function pickDay(day: Date) {
|
|
219
|
+
if (!pendingFrom) { setPendingFrom(day); return }
|
|
220
|
+
const [s, e] = day < pendingFrom ? [day, pendingFrom] : [pendingFrom, day]
|
|
221
|
+
commit([toDateValue(s), toDateValue(e)])
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function apply() {
|
|
225
|
+
if (draft[0] !== value[0] || draft[1] !== value[1]) onValueChange(draft)
|
|
226
|
+
handleOpenChange(false)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const hint = pendingFrom
|
|
230
|
+
? "Agora escolha o último dia."
|
|
231
|
+
: withApplyButton && from
|
|
232
|
+
? format(shown)
|
|
233
|
+
: "Escolha o primeiro dia."
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<Popover open={open} onOpenChange={handleOpenChange}>
|
|
237
|
+
<PickerTrigger
|
|
238
|
+
slot="date-picker-input"
|
|
239
|
+
text={format(value)}
|
|
240
|
+
placeholder={placeholder}
|
|
241
|
+
open={open}
|
|
242
|
+
size={size}
|
|
243
|
+
disabled={disabled}
|
|
244
|
+
clearable={clearable}
|
|
245
|
+
onClear={() => onValueChange([null, null])}
|
|
246
|
+
className={className}
|
|
247
|
+
wrapperClassName={wrapperClassName}
|
|
248
|
+
controlProps={controlProps}
|
|
249
|
+
/>
|
|
250
|
+
<PopoverContent align={align} className={contentCls} onOpenAutoFocus={e => e.preventDefault()}>
|
|
251
|
+
{presets && presets.length > 0 && (
|
|
252
|
+
<PresetList
|
|
253
|
+
presets={presets}
|
|
254
|
+
isActive={v => v[0] === shown[0] && v[1] === shown[1]}
|
|
255
|
+
isDisabled={v => !inRange(v[0], minDate, maxDate) || !inRange(v[1], minDate, maxDate)}
|
|
256
|
+
onSelect={commit}
|
|
257
|
+
/>
|
|
258
|
+
)}
|
|
259
|
+
<div className="flex flex-col">
|
|
260
|
+
<Calendar
|
|
261
|
+
mode="range"
|
|
262
|
+
autoFocus
|
|
263
|
+
numberOfMonths={isMobile ? 1 : numberOfColumns}
|
|
264
|
+
// O cálculo do período é nosso (pickDay): o do DayPicker já fecha
|
|
265
|
+
// um período de um dia no primeiro clique.
|
|
266
|
+
selected={pendingFrom ? { from: pendingFrom, to: undefined } : { from, to }}
|
|
267
|
+
onSelect={(_, day) => pickDay(day)}
|
|
268
|
+
defaultMonth={from ?? clampDate(new Date(), min, max)}
|
|
269
|
+
startMonth={min}
|
|
270
|
+
endMonth={max}
|
|
271
|
+
disabled={outOfRange(min, max)}
|
|
272
|
+
captionLayout={captionLayout}
|
|
273
|
+
/>
|
|
274
|
+
<PickerFooter
|
|
275
|
+
onCancel={withApplyButton ? () => handleOpenChange(false) : undefined}
|
|
276
|
+
onApply={withApplyButton ? apply : undefined}
|
|
277
|
+
applyDisabled={!!pendingFrom}
|
|
278
|
+
>
|
|
279
|
+
<PickerHint>{hint}</PickerHint>
|
|
280
|
+
</PickerFooter>
|
|
281
|
+
</div>
|
|
282
|
+
</PopoverContent>
|
|
283
|
+
</Popover>
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export { DatePickerInput }
|