Refactor TimeRangeField

This commit is contained in:
Ben Grant 2023-05-21 22:53:22 +10:00
parent 12004b8584
commit e9945a19d5
8 changed files with 154 additions and 194 deletions

View file

@ -8,7 +8,8 @@
"@next/next/no-img-element": "off",
"react/display-name": "off",
"react-hooks/exhaustive-deps": "off",
"space-infix-ops": "warn"
"space-infix-ops": "warn",
"comma-spacing": "warn"
},
"overrides": [
{

View file

@ -1,6 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { useRouter } from 'next/navigation'
@ -8,7 +8,7 @@ import Button from '/src/components/Button/Button'
import CalendarField from '/src/components/CalendarField/CalendarField'
import { default as ErrorAlert } from '/src/components/Error/Error'
import TextField from '/src/components/TextField/TextField'
import ToggleField from '/src/components/ToggleField/ToggleField'
import TimeRangeField from '/src/components/TimeRangeField/TimeRangeField'
import { API_BASE } from '/src/config/api'
import dayjs from '/src/config/dayjs'
import { useTranslation } from '/src/i18n/client'
@ -47,7 +47,7 @@ const CreateForm = () => {
const [error, setError] = useState<React.ReactNode>()
const onSubmit: SubmitHandler<Fields> = async values => {
console.log({values})
console.log({values}) // TODO:
setIsLoading(true)
setError(undefined)
@ -57,7 +57,7 @@ const CreateForm = () => {
if (dates.length === 0) {
return setError(t('form.errors.no_dates'))
}
const isSpecificDates = typeof dates[0] === 'string' && dates[0].length === 8
const isSpecificDates = dates[0].length === 8
if (time.start === time.end) {
return setError(t('form.errors.same_times'))
}
@ -73,7 +73,7 @@ const CreateForm = () => {
} else {
day.push(
dayjs().tz(timezone)
.day(date).hour(i).minute(0).utc().format('HHmm-d')
.day(Number(date)).hour(i).minute(0).utc().format('HHmm-d')
)
}
}
@ -87,13 +87,13 @@ const CreateForm = () => {
} else {
day.push(
dayjs().tz(timezone)
.day(date).hour(i).minute(0).utc().format('HHmm-d')
.day(Number(date)).hour(i).minute(0).utc().format('HHmm-d')
)
}
}
}
return [...times, ...day]
}, [])
}, [] as string[])
if (times.length === 0) {
return setError(t('form.errors.no_time'))
@ -141,15 +141,14 @@ const CreateForm = () => {
name="dates"
/>
{/* <TimeRangeField
<TimeRangeField
label={t('form.times.label')}
subLabel={t('form.times.sublabel')}
required
setValue={setValue}
{...register('time')}
description={t('form.times.sublabel')}
control={control}
name="time"
/>
<SelectField
{/* <SelectField
label={t('form.timezone.label')}
options={timezones}
required

View file

@ -1,146 +0,0 @@
import { useState, useEffect, useRef, forwardRef } from 'react'
import dayjs from 'dayjs'
import { useSettingsStore, useLocaleUpdateStore } from '/src/stores'
import {
Wrapper,
StyledLabel,
StyledSubLabel,
Range,
Handle,
Selected,
} from './TimeRangeField.styles'
const times = ['00','01','02','03','04','05','06','07','08','09','10','11','12','13','14','15','16','17','18','19','20','21','22','23','24']
const TimeRangeField = forwardRef(({
label,
subLabel,
id,
setValue,
...props
}, ref) => {
const timeFormat = useSettingsStore(state => state.timeFormat)
const locale = useLocaleUpdateStore(state => state.locale)
const [start, setStart] = useState(9)
const [end, setEnd] = useState(17)
const isStartMoving = useRef(false)
const isEndMoving = useRef(false)
const rangeRef = useRef()
const rangeRect = useRef()
useEffect(() => {
if (rangeRef.current) {
rangeRect.current = rangeRef.current.getBoundingClientRect()
}
}, [rangeRef])
useEffect(() => setValue(props.name, JSON.stringify({start, end})), [start, end, setValue, props.name])
const handleMouseMove = e => {
if (isStartMoving.current || isEndMoving.current) {
let step = Math.round(((e.pageX - rangeRect.current.left) / rangeRect.current.width) * 24)
if (step < 0) step = 0
if (step > 24) step = 24
step = Math.abs(step)
if (isStartMoving.current) {
setStart(step)
} else if (isEndMoving.current) {
setEnd(step)
}
}
}
return (
<Wrapper locale={locale}>
{label && <StyledLabel htmlFor={id}>{label}</StyledLabel>}
{subLabel && <StyledSubLabel htmlFor={id}>{subLabel}</StyledSubLabel>}
<input
id={id}
type="hidden"
value={JSON.stringify({start, end})}
ref={ref}
{...props}
/>
<Range ref={rangeRef}>
<Selected $start={start} $end={start > end ? 24 : end} />
{start > end && <Selected $start={start > end ? 0 : start} $end={end} />}
<Handle
$value={start}
label={timeFormat === '24h' ? times[start] : dayjs().hour(times[start]).format('ha')}
$extraPadding={end - start === 1 ? 'padding-right: 20px;' : (start - end === 1 ? 'padding-left: 20px;' : '')}
onMouseDown={() => {
document.addEventListener('mousemove', handleMouseMove)
isStartMoving.current = true
document.addEventListener('mouseup', () => {
isStartMoving.current = false
document.removeEventListener('mousemove', handleMouseMove)
}, { once: true })
}}
onTouchMove={e => {
const touch = e.targetTouches[0]
let step = Math.round(((touch.pageX - rangeRect.current.left) / rangeRect.current.width) * 24)
if (step < 0) step = 0
if (step > 24) step = 24
step = Math.abs(step)
setStart(step)
}}
tabIndex="0"
onKeyDown={e => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault()
setStart(Math.max(start-1, 0))
}
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault()
setStart(Math.min(start+1, 24))
}
}}
/>
<Handle
$value={end}
label={timeFormat === '24h' ? times[end] : dayjs().hour(times[end]).format('ha')}
$extraPadding={end - start === 1 ? 'padding-left: 20px;' : (start - end === 1 ? 'padding-right: 20px;' : '')}
onMouseDown={() => {
document.addEventListener('mousemove', handleMouseMove)
isEndMoving.current = true
document.addEventListener('mouseup', () => {
isEndMoving.current = false
document.removeEventListener('mousemove', handleMouseMove)
}, { once: true })
}}
onTouchMove={e => {
const touch = e.targetTouches[0]
let step = Math.round(((touch.pageX - rangeRect.current.left) / rangeRect.current.width) * 24)
if (step < 0) step = 0
if (step > 24) step = 24
step = Math.abs(step)
setEnd(step)
}}
tabIndex="0"
onKeyDown={e => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault()
setEnd(Math.max(end-1, 0))
}
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault()
setEnd(Math.min(end+1, 24))
}
}}
/>
</Range>
</Wrapper>
)
})
export default TimeRangeField

View file

@ -1,24 +1,4 @@
import { styled } from 'goober'
import { forwardRef } from 'react'
export const Wrapper = styled('div')`
margin: 30px 0;
`
export const StyledLabel = styled('label')`
display: block;
padding-bottom: 4px;
font-size: 18px;
`
export const StyledSubLabel = styled('label')`
display: block;
padding-bottom: 6px;
font-size: 13px;
opacity: .6;
`
export const Range = styled('div', forwardRef)`
.range {
user-select: none;
background-color: var(--surface);
border: 1px solid var(--primary);
@ -26,9 +6,9 @@ export const Range = styled('div', forwardRef)`
height: 50px;
position: relative;
margin: 38px 6px 18px;
`
}
export const Handle = styled('div')`
.handle {
height: calc(100% + 20px);
width: 20px;
border: 1px solid var(--primary);
@ -36,10 +16,10 @@ export const Handle = styled('div')`
border-radius: 3px;
position: absolute;
top: -10px;
left: calc(${props => props.$value * 4.166}% - 11px);
cursor: ew-resize;
touch-action: none;
transition: left .1s;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
@ -59,27 +39,26 @@ export const Handle = styled('div')`
}
&:before {
content: '${props => props.label}';
content: attr(data-label);
position: absolute;
bottom: calc(100% + 8px);
text-align: center;
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
${props => props.$extraPadding}
padding-inline: var(--extra-padding);
}
}
`
export const Selected = styled('div')`
.selected {
position: absolute;
height: 100%;
left: ${props => props.$start * 4.166}%;
right: calc(100% - ${props => props.$end * 4.166}%);
top: 0;
background-color: var(--primary);
border-radius: 2px;
transition: left .1s, right .1s;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
`
}

View file

@ -0,0 +1,127 @@
import { useRef } from 'react'
import { FieldValues, useController, UseControllerProps } from 'react-hook-form'
import dayjs from 'dayjs'
import { Description, Label, Wrapper } from '/src/components/Field/Field'
import useSettingsStore from '/src/stores/settingsStore'
import styles from './TimeRangeField.module.scss'
const times = ['00', '01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23', '24'] as const
interface TimeRangeFieldProps<TValues extends FieldValues> extends UseControllerProps<TValues> {
label?: React.ReactNode
description?: React.ReactNode
}
const TimeRangeField = <TValues extends FieldValues>({
label,
description,
...props
}: TimeRangeFieldProps<TValues>) => {
const { field: { value, onChange } } = useController(props)
return <Wrapper>
{label && <Label>{label}</Label>}
{description && <Description>{description}</Description>}
<div className={styles.range}>
<Selection
start={value.start}
end={value.start > value.end ? 24 : value.end}
/>
{value.start > value.end && <Selection
start={value.start > value.end ? 0 : value.start}
end={value.end}
/>}
<Handle
value={value.start}
onChange={start => onChange({ ...value, start })}
labelPadding={value.end - value.start === 1 ? '0 20px' : (value.start - value.end === 1 ? '20px 0' : '0')}
/>
<Handle
value={value.end}
onChange={end => onChange({ ...value, end })}
labelPadding={value.end - value.start === 1 ? '20px 0' : (value.start - value.end === 1 ? '0 20px' : '0')}
/>
</div>
</Wrapper>
}
export default TimeRangeField
const Selection = ({ start, end }: { start: number, end: number }) => <div
className={styles.selected}
style={{
left: `${start * 4.166}%`,
right: `calc(100% - ${end * 4.166}%)`,
}}
/>
interface HandleProps {
value: number
onChange: (value: number) => void
labelPadding: string
}
const Handle = ({ value, onChange, labelPadding }: HandleProps) => {
const timeFormat = useSettingsStore(state => state.timeFormat)
const isMoving = useRef(false)
const rangeRect = useRef({ left: 0, width: 0 })
const handleMouseMove = (e: MouseEvent) => {
if (isMoving.current) {
let step = Math.round(((e.pageX - rangeRect.current.left) / rangeRect.current.width) * 24)
if (step < 0) step = 0
if (step > 24) step = 24
step = Math.abs(step)
onChange(step)
}
}
return <div
ref={el => {
const bb = el?.parentElement?.getBoundingClientRect()
rangeRect.current = { left: bb?.left ?? 0, width: bb?.width ?? 0 }
}}
className={styles.handle}
style={{
left: `calc(${value * 4.166}% - 11px)`,
'--extra-padding': labelPadding,
} as React.CSSProperties}
data-label={timeFormat === '24h' ? times[value] : dayjs().hour(Number(times[value])).format('ha')}
onMouseDown={() => {
document.addEventListener('mousemove', handleMouseMove)
isMoving.current = true
document.addEventListener('mouseup', () => {
isMoving.current = false
document.removeEventListener('mousemove', handleMouseMove)
}, { once: true })
}}
onTouchMove={e => {
const touch = e.targetTouches[0]
let step = Math.round(((touch.pageX - rangeRect.current.left) / rangeRect.current.width) * 24)
if (step < 0) step = 0
if (step > 24) step = 24
step = Math.abs(step)
onChange(step)
}}
tabIndex={0}
onKeyDown={e => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault()
onChange(Math.max(value - 1, 0))
}
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault()
onChange(Math.min(value + 1, 24))
}
}}
/>
}