Files
SkinbaseNova/resources/js/components/ui/DateRangePicker.jsx
Gregor Klevze a875203482 feat: Nova UI component library + Studio dropdown/picker polish
- Add Nova UI library: Button, TextInput, Textarea, FormField, Select,
  NovaSelect, Checkbox, Radio/RadioGroup, Toggle, DatePicker,
  DateRangePicker, Modal + barrel index.js
- Replace all native <select> in Studio with NovaSelect (StudioFilters,
  StudioToolbar, BulkActionsBar) including frosted-glass portal and
  category group headers
- Replace native checkboxes in StudioGridCard, StudioTable, UploadSidebar,
  UploadWizard, Upload/Index with custom Checkbox component
- Add nova-scrollbar CSS utility (thin 4px, semi-transparent)
- Fix portal position drift: use viewport-relative coords (no scrollY offset)
  for NovaSelect, DatePicker and DateRangePicker
- Close portals on external scroll instead of remeasuring
- Improve hover highlight visibility in NovaSelect (bg-white/[0.13])
- Move search icon to right side in NovaSelect dropdown
- Reduce Studio layout top spacing (py-6 -> pt-4 pb-8)
- Add StudioCheckbox and SquareCheckbox backward-compat shims
- Add sync.sh rsync deploy script
2026-03-01 10:41:43 +01:00

352 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
/* ─── Date helpers (duplicated locally so component is self-contained) ─ */
const MONTH_NAMES = [
'January','February','March','April','May','June',
'July','August','September','October','November','December',
]
const DAY_ABBR = ['Mo','Tu','We','Th','Fr','Sa','Su']
function daysInMonth(year, month) {
return new Date(year, month + 1, 0).getDate()
}
function firstWeekday(year, month) {
return (new Date(year, month, 1).getDay() + 6) % 7
}
function toISO(date) {
return `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')}`
}
function fromISO(str) {
if (!str) return null
const [y,m,d] = str.split('-').map(Number)
return new Date(y, m-1, d)
}
function fmt(isoStr) {
if (!isoStr) return ''
const d = fromISO(isoStr)
return d ? `${MONTH_NAMES[d.getMonth()].slice(0,3)} ${d.getDate()}, ${d.getFullYear()}` : ''
}
function isSameDay(a, b) {
return !!a && !!b &&
a.getFullYear()===b.getFullYear() && a.getMonth()===b.getMonth() && a.getDate()===b.getDate()
}
/* ─── Single month grid ─────────────────────────────────────────── */
function MonthGrid({ year, month, start, end, hover, onHover, onSelect, minDate, maxDate }) {
const numDays = daysInMonth(year, month)
const startWd = firstWeekday(year, month)
const prevDays = daysInMonth(year, month - 1 < 0 ? 11 : month - 1)
const cells = []
for (let i = startWd - 1; i >= 0; i--)
cells.push({ day: prevDays - i, current: false, date: new Date(year, month - 1, prevDays - i) })
for (let d = 1; d <= numDays; d++)
cells.push({ day: d, current: true, date: new Date(year, month, d) })
let next = 1
while (cells.length % 7 !== 0)
cells.push({ day: next++, current: false, date: new Date(year, month + 1, next - 1) })
const startDate = fromISO(start)
const endDate = fromISO(end)
const hoverDate = hover ? fromISO(hover) : null
const today = new Date(); today.setHours(0,0,0,0)
// Range boundary to highlight (end could be hover while selecting)
const rangeEnd = endDate ?? hoverDate
return (
<div className="p-3 min-w-[224px]">
<div className="grid grid-cols-7 mb-1">
{DAY_ABBR.map((d) => (
<div key={d} className="text-center text-[10px] font-semibold text-slate-500 py-1">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-y-0.5">
{cells.map((cell, i) => {
const iso = toISO(cell.date)
const isStart = isSameDay(cell.date, startDate)
const isEnd = isSameDay(cell.date, endDate)
const isToday = isSameDay(cell.date, today)
const disabled = (minDate && iso < minDate) || (maxDate && iso > maxDate)
// In range?
let inRange = false
if (startDate && rangeEnd) {
const lo = startDate <= rangeEnd ? startDate : rangeEnd
const hi = startDate <= rangeEnd ? rangeEnd : startDate
inRange = cell.date > lo && cell.date < hi
}
const isEdge = isStart || isEnd
return (
<button
key={i}
type="button"
disabled={disabled || !cell.current}
onClick={() => cell.current && !disabled && onSelect(iso)}
onMouseEnter={() => cell.current && !disabled && onHover(iso)}
className={[
'relative flex items-center justify-center w-8 h-8 mx-auto rounded-lg text-sm transition-all',
!cell.current ? 'opacity-0 pointer-events-none' : '',
isEdge ? 'bg-accent text-white font-semibold shadow shadow-accent/30 z-10' : '',
!isEdge && inRange ? 'bg-accent/20 text-white rounded-none' : '',
!isEdge && !inRange && !disabled ? 'hover:bg-white/10 text-white' : '',
isToday && !isEdge ? 'ring-1 ring-accent/50 text-accent' : '',
disabled ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer',
].join(' ')}
>
{cell.day}
</button>
)
})}
</div>
</div>
)
}
/* ─── DateRangePicker ────────────────────────────────────────────── */
/**
* Nova DateRangePicker
*
* @prop {string} start - ISO 'YYYY-MM-DD'
* @prop {string} end - ISO 'YYYY-MM-DD'
* @prop {function} onChange - called with { start, end }
* @prop {string} label
* @prop {string} placeholder
* @prop {string} error
* @prop {string} hint
* @prop {boolean} required
* @prop {boolean} clearable
* @prop {string} minDate
* @prop {string} maxDate
*/
export default function DateRangePicker({
start = '',
end = '',
onChange,
label,
placeholder = 'Select date range',
error,
hint,
required = false,
clearable = false,
minDate,
maxDate,
id,
disabled = false,
className = '',
}) {
const [open, setOpen] = useState(false)
const [dropPos, setPos] = useState({ top: 0, left: 0, width: 480 })
const [hover, setHover] = useState('')
// Selecting state: if we have a start but no end, next click sets end
const [picking, setPicking] = useState(null) // null | 'start' | 'end'
// View months: left and right panels
const today = new Date()
const [lYear, setLYear] = useState(today.getFullYear())
const [lMonth, setLMonth] = useState(today.getMonth() === 0 ? 11 : today.getMonth() - 1)
// right panel = left + 1
const rYear = lMonth === 11 ? lYear + 1 : lYear
const rMonth = lMonth === 11 ? 0 : lMonth + 1
const triggerRef = useRef(null)
const inputId = id ?? (label ? `drp-${label.toLowerCase().replace(/\s+/g, '-')}` : 'date-range')
const measure = useCallback(() => {
if (!triggerRef.current) return
const rect = triggerRef.current.getBoundingClientRect()
const panelW = Math.max(rect.width, 480)
const height = 340
const openUp = window.innerHeight - rect.bottom < height + 8 && rect.top > height + 8
setPos({
top: openUp ? rect.top - height - 4 : rect.bottom + 4,
left: Math.min(rect.left, window.innerWidth - panelW - 8),
width: panelW,
})
}, [])
const openPicker = () => { if (disabled) return; measure(); setOpen(true) }
useEffect(() => {
if (!open) return
const handler = (e) => {
if (
!triggerRef.current?.contains(e.target) &&
!document.getElementById(`drp-panel-${inputId}`)?.contains(e.target)
) { setOpen(false); setPicking(null) }
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open, inputId])
useEffect(() => {
if (!open) return
const onScroll = (e) => {
if (document.getElementById(`drp-panel-${inputId}`)?.contains(e.target)) return
setOpen(false); setPicking(null)
}
const onResize = () => { setOpen(false); setPicking(null) }
window.addEventListener('scroll', onScroll, true)
window.addEventListener('resize', onResize)
return () => { window.removeEventListener('scroll', onScroll, true); window.removeEventListener('resize', onResize) }
}, [open, inputId])
const handleSelect = (iso) => {
if (!start || picking === 'start' || (start && end)) {
// Start fresh
onChange?.({ start: iso, end: '' })
setPicking('end')
setHover('')
} else {
// We have start, picking end
const s = start < iso ? start : iso
const e = start < iso ? iso : start
onChange?.({ start: s, end: e })
setPicking(null)
setOpen(false)
}
}
const clearValue = (ev) => { ev.stopPropagation(); onChange?.({ start:'', end:'' }); setPicking(null) }
const prevLeft = () => {
if (lMonth === 0) { setLMonth(11); setLYear(y => y - 1) } else setLMonth(m => m - 1)
}
const nextLeft = () => {
if (lMonth === 11) { setLMonth(0); setLYear(y => y + 1) } else setLMonth(m => m + 1)
}
const displayText = start ? `${fmt(start)} ${end ? fmt(end) : '…'}` : ''
const triggerClass = [
'relative flex items-center h-[42px] rounded-xl border px-3.5 gap-2 cursor-pointer w-full',
'bg-white/[0.06] text-sm transition-all duration-150',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-0',
error
? 'border-red-500/60 focus-visible:ring-red-500/40'
: open
? 'border-accent/50 ring-2 ring-accent/40'
: 'border-white/12 hover:border-white/22',
disabled ? 'opacity-50 cursor-not-allowed pointer-events-none' : '',
className,
].join(' ')
return (
<div className="flex flex-col gap-1.5">
{label && (
<label htmlFor={inputId} className="text-sm font-medium text-white/85 select-none">
{label}{required && <span className="text-red-400 ml-1">*</span>}
</label>
)}
<div ref={triggerRef} className={triggerClass} tabIndex={disabled ? -1 : 0}
onClick={openPicker}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPicker() } }}
role="button" aria-label={label ?? placeholder} id={inputId}
>
{/* Calendar icon */}
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="text-slate-500 shrink-0" aria-hidden="true">
<rect x="1" y="2.5" width="12" height="10.5" rx="1.5" stroke="currentColor" strokeWidth="1.3"/>
<path d="M1 6h12" stroke="currentColor" strokeWidth="1.3"/>
<path d="M4 1v3M10 1v3" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round"/>
</svg>
<span className={`flex-1 truncate ${displayText ? 'text-white' : 'text-slate-500'}`}>
{displayText || placeholder}
</span>
{clearable && (start || end) && (
<button type="button" tabIndex={-1} onClick={clearValue}
className="w-5 h-5 flex items-center justify-center rounded text-slate-500 hover:text-white transition-colors"
aria-label="Clear date range"
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
<path d="M1 1l8 8M9 1L1 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
</button>
)}
</div>
{error && <p role="alert" className="text-xs text-red-400">{error}</p>}
{!error && hint && <p className="text-xs text-slate-500">{hint}</p>}
{open && createPortal(
<div
id={`drp-panel-${inputId}`}
className="fixed z-[500] rounded-2xl border border-white/12 bg-nova-900 shadow-2xl shadow-black/50 overflow-hidden"
style={{ top: dropPos.top, left: dropPos.left, width: dropPos.width }}
onMouseLeave={() => setHover('')}
>
{/* Header info */}
{picking === 'end' && (
<div className="px-4 pt-3 pb-1 text-xs text-accent/80 font-medium">
Now click an end date
</div>
)}
{/* Two calendars */}
<div className="flex items-start">
{/* Left month */}
<div className="flex-1 border-r border-white/8">
<div className="flex items-center justify-between px-3 pt-3">
<button type="button" onClick={prevLeft}
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-white hover:bg-white/8 transition-all"
aria-label="Previous month"
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
<path d="M7 1L3 5l4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
<span className="text-sm font-semibold text-white">{MONTH_NAMES[lMonth]} {lYear}</span>
<div className="w-8" />
</div>
<MonthGrid year={lYear} month={lMonth} start={start} end={end} hover={hover}
onHover={setHover} onSelect={handleSelect} minDate={minDate} maxDate={maxDate} />
</div>
{/* Right month */}
<div className="flex-1">
<div className="flex items-center justify-between px-3 pt-3">
<div className="w-8" />
<span className="text-sm font-semibold text-white">{MONTH_NAMES[rMonth]} {rYear}</span>
<button type="button" onClick={nextLeft}
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-white hover:bg-white/8 transition-all"
aria-label="Next month"
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
<path d="M3 1l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
</div>
<MonthGrid year={rYear} month={rMonth} start={start} end={end} hover={hover}
onHover={setHover} onSelect={handleSelect} minDate={minDate} maxDate={maxDate} />
</div>
</div>
{/* Footer with preset shortcuts */}
<div className="border-t border-white/8 px-4 py-2.5 flex items-center gap-3 flex-wrap">
{[
{ label: 'Last 7 days', fn: () => { const e=toISO(new Date()); const s=toISO(new Date(Date.now()-6*864e5)); onChange?.({start:s,end:e}); setOpen(false) } },
{ label: 'Last 30 days', fn: () => { const e=toISO(new Date()); const s=toISO(new Date(Date.now()-29*864e5)); onChange?.({start:s,end:e}); setOpen(false) } },
{ label: 'This month', fn: () => { const n=new Date(); const s=toISO(new Date(n.getFullYear(),n.getMonth(),1)); const e=toISO(new Date(n.getFullYear(),n.getMonth()+1,0)); onChange?.({start:s,end:e}); setOpen(false) } },
].map((p) => (
<button key={p.label} type="button" onClick={p.fn}
className="text-xs text-slate-400 hover:text-accent transition-colors font-medium"
>
{p.label}
</button>
))}
</div>
</div>,
document.body,
)}
</div>
)
}