Wire admin studio SSR and search infrastructure
This commit is contained in:
@@ -58,6 +58,34 @@ function mergeDateTime(date, time) {
|
||||
return `${date}T${time || '00:00'}`
|
||||
}
|
||||
|
||||
function maxDateValue(a, b) {
|
||||
if (!a) return b || ''
|
||||
if (!b) return a || ''
|
||||
return a > b ? a : b
|
||||
}
|
||||
|
||||
function minDateValue(a, b) {
|
||||
if (!a) return b || ''
|
||||
if (!b) return a || ''
|
||||
return a < b ? a : b
|
||||
}
|
||||
|
||||
function clampTimeToBounds(date, time, minDateTime, maxDateTime) {
|
||||
const nextTime = time || '00:00'
|
||||
const minParts = splitDateTime(minDateTime)
|
||||
const maxParts = splitDateTime(maxDateTime)
|
||||
|
||||
if (date && minParts.date === date && minParts.time && nextTime < minParts.time) {
|
||||
return minParts.time
|
||||
}
|
||||
|
||||
if (date && maxParts.date === date && maxParts.time && nextTime > maxParts.time) {
|
||||
return maxParts.time
|
||||
}
|
||||
|
||||
return nextTime
|
||||
}
|
||||
|
||||
function formatDisplay(value) {
|
||||
if (!value) return ''
|
||||
|
||||
@@ -147,15 +175,18 @@ export default function DateTimePicker({
|
||||
value = '',
|
||||
onChange,
|
||||
label,
|
||||
placeholder = 'Pick a date and time',
|
||||
placeholder,
|
||||
error,
|
||||
hint,
|
||||
required = false,
|
||||
clearable = false,
|
||||
id,
|
||||
disabled = false,
|
||||
mode = 'datetime',
|
||||
minDate,
|
||||
maxDate,
|
||||
minDateTime,
|
||||
maxDateTime,
|
||||
className = '',
|
||||
}) {
|
||||
const today = new Date()
|
||||
@@ -168,6 +199,7 @@ export default function DateTimePicker({
|
||||
const [viewMonth, setViewMonth] = useState(initialDate.getMonth())
|
||||
const [draftDate, setDraftDate] = useState(initial.date)
|
||||
const [draftTime, setDraftTime] = useState(initial.time || '12:00')
|
||||
const effectivePlaceholder = placeholder || (mode === 'date' ? 'Pick a date' : 'Pick a date and time')
|
||||
|
||||
const triggerRef = useRef(null)
|
||||
const inputId = id ?? (label ? `dtp-${label.toLowerCase().replace(/\s+/g, '-')}` : 'date-time-picker')
|
||||
@@ -239,16 +271,23 @@ export default function DateTimePicker({
|
||||
}, [open, panelId])
|
||||
|
||||
const applyValue = useCallback((date, time) => {
|
||||
onChange?.(date ? mergeDateTime(date, time) : '')
|
||||
}, [onChange])
|
||||
if (!date) {
|
||||
onChange?.('')
|
||||
return
|
||||
}
|
||||
|
||||
onChange?.(mode === 'date' ? date : mergeDateTime(date, time))
|
||||
}, [mode, onChange])
|
||||
|
||||
const handleDateSelect = (nextDate) => {
|
||||
const nextTime = clampTimeToBounds(nextDate, draftTime, minDateTime, maxDateTime)
|
||||
setDraftDate(nextDate)
|
||||
applyValue(nextDate, draftTime)
|
||||
setDraftTime(nextTime)
|
||||
applyValue(nextDate, nextTime)
|
||||
}
|
||||
|
||||
const handleTimeChange = (event) => {
|
||||
const nextTime = event.target.value
|
||||
const nextTime = clampTimeToBounds(draftDate, event.target.value, minDateTime, maxDateTime)
|
||||
setDraftTime(nextTime)
|
||||
applyValue(draftDate, nextTime)
|
||||
}
|
||||
@@ -293,6 +332,12 @@ export default function DateTimePicker({
|
||||
].join(' ')
|
||||
|
||||
const selectedDate = parseDatePart(draftDate)
|
||||
const minDateTimeParts = splitDateTime(minDateTime)
|
||||
const maxDateTimeParts = splitDateTime(maxDateTime)
|
||||
const effectiveMinDate = maxDateValue(minDate, minDateTimeParts.date)
|
||||
const effectiveMaxDate = minDateValue(maxDate, maxDateTimeParts.date)
|
||||
const minTime = draftDate && draftDate === minDateTimeParts.date ? minDateTimeParts.time || undefined : undefined
|
||||
const maxTime = draftDate && draftDate === maxDateTimeParts.date ? maxDateTimeParts.time || undefined : undefined
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -308,7 +353,7 @@ export default function DateTimePicker({
|
||||
id={inputId}
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-label={label ?? placeholder}
|
||||
aria-label={label ?? effectivePlaceholder}
|
||||
className={triggerClass}
|
||||
onClick={openPicker}
|
||||
onKeyDown={(event) => {
|
||||
@@ -328,7 +373,7 @@ export default function DateTimePicker({
|
||||
</svg>
|
||||
|
||||
<span className={`flex-1 truncate ${value ? 'text-white' : 'text-slate-500'}`}>
|
||||
{value ? formatDisplay(value) : placeholder}
|
||||
{value ? formatDisplay(value) : effectivePlaceholder}
|
||||
</span>
|
||||
|
||||
{clearable && value && (
|
||||
@@ -386,28 +431,32 @@ export default function DateTimePicker({
|
||||
month={viewMonth}
|
||||
selectedDate={selectedDate}
|
||||
onSelect={handleDateSelect}
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
minDate={effectiveMinDate}
|
||||
maxDate={effectiveMaxDate}
|
||||
/>
|
||||
|
||||
<div className="border-t border-white/8 px-4 py-3">
|
||||
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_7rem] sm:items-end">
|
||||
<div className={`grid gap-3 ${mode === 'date' ? '' : 'sm:grid-cols-[minmax(0,1fr)_7rem] sm:items-end'}`}>
|
||||
<div>
|
||||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Selected date</div>
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white">
|
||||
{draftDate ? formatDisplay(mergeDateTime(draftDate, draftTime)).replace(` at ${draftTime}`, '') : 'Pick a day'}
|
||||
{draftDate ? formatDisplay(draftDate) : 'Pick a day'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="grid gap-1.5 text-sm text-slate-300">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Time</span>
|
||||
<input
|
||||
type="time"
|
||||
value={draftTime}
|
||||
onChange={handleTimeChange}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-white outline-none transition focus:border-accent/50 focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
</label>
|
||||
{mode !== 'date' ? (
|
||||
<label className="grid gap-1.5 text-sm text-slate-300">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Time</span>
|
||||
<input
|
||||
type="time"
|
||||
value={draftTime}
|
||||
onChange={handleTimeChange}
|
||||
min={minTime}
|
||||
max={maxTime}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-white outline-none transition focus:border-accent/50 focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
|
||||
@@ -26,6 +26,8 @@ import { createPortal } from 'react-dom'
|
||||
* @prop {boolean} required - asterisk on label
|
||||
* @prop {boolean} disabled
|
||||
* @prop {function} renderOption - custom render fn: (option) => ReactNode
|
||||
* @prop {function} renderValue - custom render fn for single-value trigger: (option) => ReactNode
|
||||
* @prop {string} searchPlaceholder - placeholder shown in the dropdown search input
|
||||
*/
|
||||
export default function NovaSelect({
|
||||
options = [],
|
||||
@@ -41,8 +43,10 @@ export default function NovaSelect({
|
||||
required = false,
|
||||
disabled = false,
|
||||
renderOption,
|
||||
renderValue,
|
||||
id,
|
||||
className = '',
|
||||
searchPlaceholder = 'Search…',
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
@@ -211,9 +215,10 @@ export default function NovaSelect({
|
||||
}, [open, filtered, highlighted, search, multi, selected, selectOption, closeDropdown, openDropdown, onChange])
|
||||
|
||||
// Build display label(s)
|
||||
const optionMap = useMemo(() => Object.fromEntries(options.map((o) => [String(o.value), o])), [options])
|
||||
const labelMap = useMemo(() => Object.fromEntries(options.map((o) => [String(o.value), o.label])), [options])
|
||||
|
||||
const hasValue = selected.length > 0
|
||||
const selectedOption = !multi && hasValue ? optionMap[String(selected[0])] ?? null : null
|
||||
|
||||
// Trigger appearance
|
||||
const triggerClass = [
|
||||
@@ -273,7 +278,9 @@ export default function NovaSelect({
|
||||
))}
|
||||
|
||||
{!multi && hasValue && (
|
||||
<span className="truncate text-white">{labelMap[String(selected[0])] ?? selected[0]}</span>
|
||||
renderValue && selectedOption
|
||||
? renderValue(selectedOption)
|
||||
: <span className="truncate text-white">{labelMap[String(selected[0])] ?? selected[0]}</span>
|
||||
)}
|
||||
|
||||
{!hasValue && (
|
||||
@@ -339,7 +346,7 @@ export default function NovaSelect({
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setHigh(0) }}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search…"
|
||||
placeholder={searchPlaceholder}
|
||||
className="w-full pl-3 pr-7 py-1.5 rounded-lg bg-white/5 border border-white/8 text-white text-xs placeholder:text-slate-500 focus:outline-none focus:ring-1 focus:ring-accent/50"
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user