final notif changes
This commit is contained in:
274
src/components/ClockTimePicker.tsx
Normal file
274
src/components/ClockTimePicker.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
|
||||
interface Props {
|
||||
value: string // "HH:MM" 24-hour format
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const SIZE = 240
|
||||
const CENTER = SIZE / 2
|
||||
const CLOCK_RADIUS = 108
|
||||
const NUM_RADIUS = 82
|
||||
const HAND_RADIUS = 74
|
||||
const TIP_RADIUS = 16
|
||||
|
||||
function polarToXY(angleDeg: number, radius: number) {
|
||||
const rad = ((angleDeg - 90) * Math.PI) / 180
|
||||
return {
|
||||
x: CENTER + radius * Math.cos(rad),
|
||||
y: CENTER + radius * Math.sin(rad),
|
||||
}
|
||||
}
|
||||
|
||||
function parseValue(v: string): { h: number; m: number } {
|
||||
const [h, m] = v.split(':').map(Number)
|
||||
return { h: isNaN(h) ? 8 : h, m: isNaN(m) ? 0 : m }
|
||||
}
|
||||
|
||||
export default function ClockTimePicker({ value, onChange, disabled }: Props) {
|
||||
const { h: initH, m: initM } = parseValue(value)
|
||||
|
||||
const [mode, setMode] = useState<'hours' | 'minutes'>('hours')
|
||||
const [hour24, setHour24] = useState(initH)
|
||||
const [minute, setMinute] = useState(initM)
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const isDragging = useRef(false)
|
||||
// Keep mutable refs for use inside native event listeners
|
||||
const modeRef = useRef(mode)
|
||||
const isPMRef = useRef(initH >= 12)
|
||||
const hour24Ref = useRef(initH)
|
||||
const minuteRef = useRef(initM)
|
||||
|
||||
// Keep refs in sync with state
|
||||
useEffect(() => { modeRef.current = mode }, [mode])
|
||||
useEffect(() => { isPMRef.current = hour24 >= 12 }, [hour24])
|
||||
useEffect(() => { hour24Ref.current = hour24 }, [hour24])
|
||||
useEffect(() => { minuteRef.current = minute }, [minute])
|
||||
|
||||
// Sync when value prop changes externally
|
||||
useEffect(() => {
|
||||
const { h, m } = parseValue(value)
|
||||
setHour24(h)
|
||||
setMinute(m)
|
||||
}, [value])
|
||||
|
||||
const isPM = hour24 >= 12
|
||||
const hour12 = hour24 === 0 ? 12 : hour24 > 12 ? hour24 - 12 : hour24
|
||||
|
||||
const emit = useCallback(
|
||||
(h24: number, m: number) => {
|
||||
onChange(`${h24.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`)
|
||||
},
|
||||
[onChange]
|
||||
)
|
||||
|
||||
const handleAmPm = (pm: boolean) => {
|
||||
if (disabled) return
|
||||
let newH = hour24
|
||||
if (pm && hour24 < 12) newH = hour24 + 12
|
||||
else if (!pm && hour24 >= 12) newH = hour24 - 12
|
||||
setHour24(newH)
|
||||
emit(newH, minute)
|
||||
}
|
||||
|
||||
const applyAngle = useCallback(
|
||||
(angle: number, currentMode: 'hours' | 'minutes') => {
|
||||
if (currentMode === 'hours') {
|
||||
const h12 = Math.round(angle / 30) % 12 || 12
|
||||
const pm = isPMRef.current
|
||||
const newH24 = pm ? (h12 === 12 ? 12 : h12 + 12) : (h12 === 12 ? 0 : h12)
|
||||
setHour24(newH24)
|
||||
emit(newH24, minuteRef.current)
|
||||
} else {
|
||||
const m = Math.round(angle / 6) % 60
|
||||
setMinute(m)
|
||||
emit(hour24Ref.current, m)
|
||||
}
|
||||
},
|
||||
[emit]
|
||||
)
|
||||
|
||||
const getSVGAngle = (clientX: number, clientY: number): number => {
|
||||
if (!svgRef.current) return 0
|
||||
const rect = svgRef.current.getBoundingClientRect()
|
||||
const scale = rect.width / SIZE
|
||||
const x = clientX - rect.left - CENTER * scale
|
||||
const y = clientY - rect.top - CENTER * scale
|
||||
return ((Math.atan2(y, x) * 180) / Math.PI + 90 + 360) % 360
|
||||
}
|
||||
|
||||
// Mouse handlers (mouse events don't need passive:false)
|
||||
const handleMouseDown = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (disabled) return
|
||||
isDragging.current = true
|
||||
applyAngle(getSVGAngle(e.clientX, e.clientY), modeRef.current)
|
||||
}
|
||||
const handleMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (!isDragging.current || disabled) return
|
||||
applyAngle(getSVGAngle(e.clientX, e.clientY), modeRef.current)
|
||||
}
|
||||
const handleMouseUp = (e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (!isDragging.current) return
|
||||
isDragging.current = false
|
||||
applyAngle(getSVGAngle(e.clientX, e.clientY), modeRef.current)
|
||||
if (modeRef.current === 'hours') setTimeout(() => setMode('minutes'), 120)
|
||||
}
|
||||
const handleMouseLeave = () => { isDragging.current = false }
|
||||
|
||||
// Attach non-passive touch listeners imperatively to avoid the passive warning
|
||||
useEffect(() => {
|
||||
const svg = svgRef.current
|
||||
if (!svg) return
|
||||
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
if (disabled) return
|
||||
e.preventDefault()
|
||||
isDragging.current = true
|
||||
const t = e.touches[0]
|
||||
applyAngle(getSVGAngle(t.clientX, t.clientY), modeRef.current)
|
||||
}
|
||||
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
if (!isDragging.current || disabled) return
|
||||
e.preventDefault()
|
||||
const t = e.touches[0]
|
||||
applyAngle(getSVGAngle(t.clientX, t.clientY), modeRef.current)
|
||||
}
|
||||
|
||||
const onTouchEnd = (e: TouchEvent) => {
|
||||
if (!isDragging.current) return
|
||||
e.preventDefault()
|
||||
isDragging.current = false
|
||||
const t = e.changedTouches[0]
|
||||
applyAngle(getSVGAngle(t.clientX, t.clientY), modeRef.current)
|
||||
if (modeRef.current === 'hours') setTimeout(() => setMode('minutes'), 120)
|
||||
}
|
||||
|
||||
svg.addEventListener('touchstart', onTouchStart, { passive: false })
|
||||
svg.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
svg.addEventListener('touchend', onTouchEnd, { passive: false })
|
||||
|
||||
return () => {
|
||||
svg.removeEventListener('touchstart', onTouchStart)
|
||||
svg.removeEventListener('touchmove', onTouchMove)
|
||||
svg.removeEventListener('touchend', onTouchEnd)
|
||||
}
|
||||
}, [applyAngle, disabled])
|
||||
|
||||
const handAngle = mode === 'hours' ? (hour12 / 12) * 360 : (minute / 60) * 360
|
||||
const handTip = polarToXY(handAngle, HAND_RADIUS)
|
||||
const displayH = hour12.toString()
|
||||
const displayM = minute.toString().padStart(2, '0')
|
||||
const selectedNum = mode === 'hours' ? hour12 : minute
|
||||
|
||||
const hourPositions = Array.from({ length: 12 }, (_, i) => {
|
||||
const h = i + 1
|
||||
return { h, ...polarToXY((h / 12) * 360, NUM_RADIUS) }
|
||||
})
|
||||
|
||||
const minutePositions = Array.from({ length: 12 }, (_, i) => {
|
||||
const m = i * 5
|
||||
return { m, ...polarToXY((m / 60) * 360, NUM_RADIUS) }
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="clock-picker">
|
||||
{/* Time display */}
|
||||
<div className="clock-picker__display">
|
||||
<button
|
||||
type="button"
|
||||
className={`clock-picker__seg${mode === 'hours' ? ' clock-picker__seg--active' : ''}`}
|
||||
onClick={() => !disabled && setMode('hours')}
|
||||
>
|
||||
{displayH}
|
||||
</button>
|
||||
<span className="clock-picker__colon">:</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`clock-picker__seg${mode === 'minutes' ? ' clock-picker__seg--active' : ''}`}
|
||||
onClick={() => !disabled && setMode('minutes')}
|
||||
>
|
||||
{displayM}
|
||||
</button>
|
||||
<div className="clock-picker__ampm">
|
||||
<button
|
||||
type="button"
|
||||
className={`clock-picker__ampm-btn${!isPM ? ' clock-picker__ampm-btn--active' : ''}`}
|
||||
onClick={() => handleAmPm(false)}
|
||||
disabled={disabled}
|
||||
>AM</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`clock-picker__ampm-btn${isPM ? ' clock-picker__ampm-btn--active' : ''}`}
|
||||
onClick={() => handleAmPm(true)}
|
||||
disabled={disabled}
|
||||
>PM</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clock face */}
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||
className="clock-picker__face"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
style={{ cursor: disabled ? 'default' : 'pointer', touchAction: 'none', userSelect: 'none' }}
|
||||
>
|
||||
<circle cx={CENTER} cy={CENTER} r={CLOCK_RADIUS} className="clock-picker__bg" />
|
||||
|
||||
{/* Shaded sector */}
|
||||
{(() => {
|
||||
const start = polarToXY(0, HAND_RADIUS)
|
||||
const end = polarToXY(handAngle, HAND_RADIUS)
|
||||
const large = handAngle > 180 ? 1 : 0
|
||||
return (
|
||||
<path
|
||||
d={`M ${CENTER} ${CENTER} L ${start.x} ${start.y} A ${HAND_RADIUS} ${HAND_RADIUS} 0 ${large} 1 ${end.x} ${end.y} Z`}
|
||||
className="clock-picker__sector"
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
<line x1={CENTER} y1={CENTER} x2={handTip.x} y2={handTip.y} className="clock-picker__hand" />
|
||||
<circle cx={CENTER} cy={CENTER} r={4} className="clock-picker__center-dot" />
|
||||
<circle cx={handTip.x} cy={handTip.y} r={TIP_RADIUS} className="clock-picker__hand-tip" />
|
||||
|
||||
{mode === 'hours' && hourPositions.map(({ h, x, y }) => (
|
||||
<text key={h} x={x} y={y} textAnchor="middle" dominantBaseline="central"
|
||||
className={`clock-picker__num${h === selectedNum ? ' clock-picker__num--selected' : ''}`}
|
||||
>{h}</text>
|
||||
))}
|
||||
|
||||
{mode === 'minutes' && minutePositions.map(({ m, x, y }) => (
|
||||
<text key={m} x={x} y={y} textAnchor="middle" dominantBaseline="central"
|
||||
className={`clock-picker__num${m === selectedNum ? ' clock-picker__num--selected' : ''}`}
|
||||
>{m.toString().padStart(2, '0')}</text>
|
||||
))}
|
||||
|
||||
{mode === 'minutes' && Array.from({ length: 60 }, (_, i) => {
|
||||
if (i % 5 === 0) return null
|
||||
const angle = (i / 60) * 360
|
||||
const inner = polarToXY(angle, CLOCK_RADIUS - 10)
|
||||
const outer = polarToXY(angle, CLOCK_RADIUS - 4)
|
||||
return <line key={i} x1={inner.x} y1={inner.y} x2={outer.x} y2={outer.y} className="clock-picker__tick" />
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Mode pills */}
|
||||
<div className="clock-picker__modes">
|
||||
<button type="button"
|
||||
className={`clock-picker__mode-btn${mode === 'hours' ? ' clock-picker__mode-btn--active' : ''}`}
|
||||
onClick={() => !disabled && setMode('hours')}
|
||||
>Hours</button>
|
||||
<button type="button"
|
||||
className={`clock-picker__mode-btn${mode === 'minutes' ? ' clock-picker__mode-btn--active' : ''}`}
|
||||
onClick={() => !disabled && setMode('minutes')}
|
||||
>Minutes</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user