Rebuild TextField and CalendarField

This commit is contained in:
Ben Grant 2023-05-21 21:34:06 +10:00
parent 1e77205518
commit 12004b8584
28 changed files with 783 additions and 845 deletions

View file

@ -58,6 +58,16 @@
}
}
.iconButton {
height: 30px;
width: 30px;
padding: 0;
& svg, & img {
margin: 0;
}
}
.small {
padding: .4em 1.3em;
}

View file

@ -16,8 +16,6 @@ type ButtonProps = {
surfaceColor?: string
/** Override the shadow color of the button */
shadowColor?: string
// TODO: evaluate
size?: string
} & Omit<React.ComponentProps<'button'> & React.ComponentProps<'a'>, 'ref'>
const Button: React.FC<ButtonProps> = ({
@ -30,7 +28,6 @@ const Button: React.FC<ButtonProps> = ({
isLoading,
surfaceColor,
shadowColor,
size,
style,
...props
}) => {
@ -40,14 +37,14 @@ const Button: React.FC<ButtonProps> = ({
isSecondary && styles.secondary,
isSmall && styles.small,
isLoading && styles.loading,
!children && icon && styles.iconButton,
),
style: {
...surfaceColor && { '--override-surface-color': surfaceColor, '--override-text-color': '#FFFFFF' },
...shadowColor && { '--override-shadow-color': shadowColor },
...size && { padding: 0, height: size, width: size },
...style,
},
children: [icon, children],
children: <>{icon}{children}</>,
...props,
}

View file

@ -1,264 +0,0 @@
import { useState, useEffect, useRef, forwardRef } from 'react'
import { useTranslation } from 'react-i18next'
import dayjs from 'dayjs'
import isToday from 'dayjs/plugin/isToday'
import localeData from 'dayjs/plugin/localeData'
import updateLocale from 'dayjs/plugin/updateLocale'
import { Button, ToggleField } from '/src/components'
import { useSettingsStore, useLocaleUpdateStore } from '/src/stores'
import {
Wrapper,
StyledLabel,
StyledSubLabel,
CalendarHeader,
CalendarDays,
CalendarBody,
Date,
Day,
} from './CalendarField.styles'
dayjs.extend(isToday)
dayjs.extend(localeData)
dayjs.extend(updateLocale)
const calculateMonth = (month, year, weekStart) => {
const date = dayjs().month(month).year(year)
const daysInMonth = date.daysInMonth()
const daysBefore = date.date(1).day() - weekStart
const daysAfter = 6 - date.date(daysInMonth).day() + weekStart
const dates = []
let curDate = date.date(1).subtract(daysBefore, 'day')
let y = 0
let x = 0
for (let i = 0; i < daysBefore + daysInMonth + daysAfter; i++) {
if (x === 0) dates[y] = []
dates[y][x] = curDate.clone()
curDate = curDate.add(1, 'day')
x++
if (x > 6) {
x = 0
y++
}
}
return dates
}
const CalendarField = forwardRef(({
label,
subLabel,
id,
setValue,
...props
}, ref) => {
const weekStart = useSettingsStore(state => state.weekStart)
const locale = useLocaleUpdateStore(state => state.locale)
const { t } = useTranslation('home')
const [type, setType] = useState(0)
const [dates, setDates] = useState(calculateMonth(dayjs().month(), dayjs().year(), weekStart))
const [month, setMonth] = useState(dayjs().month())
const [year, setYear] = useState(dayjs().year())
const [selectedDates, setSelectedDates] = useState([])
const [selectingDates, _setSelectingDates] = useState([])
const staticSelectingDates = useRef([])
const setSelectingDates = newDates => {
staticSelectingDates.current = newDates
_setSelectingDates(newDates)
}
const [selectedDays, setSelectedDays] = useState([])
const [selectingDays, _setSelectingDays] = useState([])
const staticSelectingDays = useRef([])
const setSelectingDays = newDays => {
staticSelectingDays.current = newDays
_setSelectingDays(newDays)
}
const startPos = useRef({})
const staticMode = useRef(null)
const [mode, _setMode] = useState(staticMode.current)
const setMode = newMode => {
staticMode.current = newMode
_setMode(newMode)
}
useEffect(() => setValue(props.name, type ? JSON.stringify(selectedDays) : JSON.stringify(selectedDates)), [type, selectedDays, selectedDates, setValue, props.name])
useEffect(() => {
if (dayjs.Ls?.[locale] && weekStart !== dayjs.Ls[locale].weekStart) {
dayjs.updateLocale(locale, { weekStart })
}
setDates(calculateMonth(month, year, weekStart))
}, [weekStart, month, year, locale])
return (
<Wrapper locale={locale}>
{label && <StyledLabel htmlFor={id}>{label}</StyledLabel>}
{subLabel && <StyledSubLabel htmlFor={id}>{subLabel}</StyledSubLabel>}
<input
id={id}
type="hidden"
ref={ref}
value={type ? JSON.stringify(selectedDays) : JSON.stringify(selectedDates)}
{...props}
/>
<ToggleField
id="calendarMode"
name="calendarMode"
options={{
'specific': t('form.dates.options.specific'),
'week': t('form.dates.options.week'),
}}
value={type === 0 ? 'specific' : 'week'}
onChange={value => setType(value === 'specific' ? 0 : 1)}
/>
{type === 0 ? (
<>
<CalendarHeader>
<Button
size="30px"
title={t('form.dates.tooltips.previous')}
onClick={() => {
if (month-1 < 0) {
setYear(year-1)
setMonth(11)
} else {
setMonth(month-1)
}
}}
>&lt;</Button>
<span>{dayjs.months()[month]} {year}</span>
<Button
size="30px"
title={t('form.dates.tooltips.next')}
onClick={() => {
if (month+1 > 11) {
setYear(year+1)
setMonth(0)
} else {
setMonth(month+1)
}
}}
>&gt;</Button>
</CalendarHeader>
<CalendarDays>
{(weekStart ? [...dayjs.weekdaysShort().filter((_,i) => i !== 0), dayjs.weekdaysShort()[0]] : dayjs.weekdaysShort()).map(name =>
<Day key={name}>{name}</Day>
)}
</CalendarDays>
<CalendarBody>
{dates.length > 0 && dates.map((dateRow, y) =>
dateRow.map((date, x) =>
<Date
key={y+x}
$otherMonth={date.month() !== month}
$isToday={date.isToday()}
title={`${date.date()} ${dayjs.months()[date.month()]}${date.isToday() ? ` (${t('form.dates.tooltips.today')})` : ''}`}
$selected={selectedDates.includes(date.format('DDMMYYYY'))}
$selecting={selectingDates.includes(date)}
$mode={mode}
type="button"
onKeyPress={e => {
if (e.key === ' ' || e.key === 'Enter') {
if (selectedDates.includes(date.format('DDMMYYYY'))) {
setSelectedDates(selectedDates.filter(d => d !== date.format('DDMMYYYY')))
} else {
setSelectedDates([...selectedDates, date.format('DDMMYYYY')])
}
}
}}
onPointerDown={e => {
startPos.current = {x, y}
setMode(selectedDates.includes(date.format('DDMMYYYY')) ? 'remove' : 'add')
setSelectingDates([date])
e.currentTarget.releasePointerCapture(e.pointerId)
document.addEventListener('pointerup', () => {
if (staticMode.current === 'add') {
setSelectedDates([...selectedDates, ...staticSelectingDates.current.map(d => d.format('DDMMYYYY'))])
} else if (staticMode.current === 'remove') {
const toRemove = staticSelectingDates.current.map(d => d.format('DDMMYYYY'))
setSelectedDates(selectedDates.filter(d => !toRemove.includes(d)))
}
setMode(null)
}, { once: true })
}}
onPointerEnter={() => {
if (staticMode.current) {
const found = []
for (let cy = Math.min(startPos.current.y, y); cy < Math.max(startPos.current.y, y)+1; cy++) {
for (let cx = Math.min(startPos.current.x, x); cx < Math.max(startPos.current.x, x)+1; cx++) {
found.push({y: cy, x: cx})
}
}
setSelectingDates(found.map(d => dates[d.y][d.x]))
}
}}
>{date.date()}</Date>
)
)}
</CalendarBody>
</>
) : (
<CalendarBody>
{(weekStart ? [...dayjs.weekdaysShort().filter((_,i) => i !== 0), dayjs.weekdaysShort()[0]] : dayjs.weekdaysShort()).map((name, i) =>
<Date
key={name}
$isToday={(weekStart ? [...dayjs.weekdaysShort().filter((_,i) => i !== 0), dayjs.weekdaysShort()[0]] : dayjs.weekdaysShort())[dayjs().day()-weekStart === -1 ? 6 : dayjs().day()-weekStart] === name}
title={(weekStart ? [...dayjs.weekdaysShort().filter((_,i) => i !== 0), dayjs.weekdaysShort()[0]] : dayjs.weekdaysShort())[dayjs().day()-weekStart === -1 ? 6 : dayjs().day()-weekStart] === name ? t('form.dates.tooltips.today') : ''}
$selected={selectedDays.includes(((i + weekStart) % 7 + 7) % 7)}
$selecting={selectingDays.includes(((i + weekStart) % 7 + 7) % 7)}
$mode={mode}
type="button"
onKeyPress={e => {
if (e.key === ' ' || e.key === 'Enter') {
if (selectedDays.includes(((i + weekStart) % 7 + 7) % 7)) {
setSelectedDays(selectedDays.filter(d => d !== ((i + weekStart) % 7 + 7) % 7))
} else {
setSelectedDays([...selectedDays, ((i + weekStart) % 7 + 7) % 7])
}
}
}}
onPointerDown={e => {
startPos.current = i
setMode(selectedDays.includes(((i + weekStart) % 7 + 7) % 7) ? 'remove' : 'add')
setSelectingDays([((i + weekStart) % 7 + 7) % 7])
e.currentTarget.releasePointerCapture(e.pointerId)
document.addEventListener('pointerup', () => {
if (staticMode.current === 'add') {
setSelectedDays([...selectedDays, ...staticSelectingDays.current])
} else if (staticMode.current === 'remove') {
const toRemove = staticSelectingDays.current
setSelectedDays(selectedDays.filter(d => !toRemove.includes(d)))
}
setMode(null)
}, { once: true })
}}
onPointerEnter={() => {
if (staticMode.current) {
const found = []
for (let ci = Math.min(startPos.current, i); ci < Math.max(startPos.current, i)+1; ci++) {
found.push(((ci + weekStart) % 7 + 7) % 7)
}
setSelectingDays(found)
}
}}
>{name}</Date>
)}
</CalendarBody>
)}
</Wrapper>
)
})
export default CalendarField

View file

@ -1,104 +0,0 @@
import { styled } from 'goober'
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;
font-size: 13px;
opacity: .6;
`
export const CalendarHeader = styled('div')`
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
padding: 6px 0;
font-size: 1.2em;
font-weight: bold;
`
export const CalendarDays = styled('div')`
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-gap: 2px;
`
export const Day = styled('div')`
display: flex;
align-items: center;
justify-content: center;
padding: 3px 0;
font-weight: bold;
user-select: none;
opacity: .7;
@media (max-width: 350px) {
font-size: 12px;
}
`
export const CalendarBody = styled('div')`
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-gap: 2px;
& button:first-of-type {
border-top-left-radius: 3px;
}
& button:nth-of-type(7) {
border-top-right-radius: 3px;
}
& button:nth-last-of-type(7) {
border-bottom-left-radius: 3px;
}
& button:last-of-type {
border-bottom-right-radius: 3px;
}
`
export const Date = styled('button')`
font: inherit;
color: inherit;
background: none;
border: 0;
margin: 0;
appearance: none;
transition: background-color .1s;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
background-color: var(--surface);
border: 1px solid var(--primary);
display: flex;
align-items: center;
justify-content: center;
padding: 10px 0;
user-select: none;
touch-action: none;
${props => props.$otherMonth && `
color: var(--tertiary);
`}
${props => props.$isToday && `
font-weight: 900;
color: var(--secondary);
`}
${props => (props.$selected || (props.$mode === 'add' && props.$selecting)) && `
color: ${props.$otherMonth ? 'rgba(255,255,255,.5)' : '#FFF'};
background-color: var(--primary);
`}
${props => props.$mode === 'remove' && props.$selecting && `
background-color: var(--surface);
color: ${props.$isToday ? 'var(--secondary)' : (props.$otherMonth ? 'var(--tertiary)' : 'inherit')};
`}
`

View file

@ -0,0 +1,61 @@
import { useEffect, useState } from 'react'
import { FieldValues,useController, UseControllerProps } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { Description, Label, Wrapper } from '/src/components/Field/Field'
import ToggleField from '/src/components/ToggleField/ToggleField'
import Month from './components/Month/Month'
import Weekdays from './components/Weekdays/Weekdays'
interface CalendarFieldProps<TValues extends FieldValues> extends UseControllerProps<TValues> {
label?: React.ReactNode
description?: React.ReactNode
}
const CalendarField = <TValues extends FieldValues>({
label,
description,
...props
}: CalendarFieldProps<TValues>) => {
const { t } = useTranslation('home')
const { field } = useController(props)
const [type, setType] = useState<'specific' | 'week'>('specific')
const [innerValue, setInnerValue] = useState({
specific: [],
week: [],
} satisfies Record<typeof type, string[]>)
useEffect(() => {
setInnerValue({ ...innerValue, [type]: field.value })
}, [type, field.value])
return <Wrapper>
{label && <Label htmlFor={props.name}>{label}</Label>}
{description && <Description htmlFor={props.name}>{description}</Description>}
<ToggleField
name="calendarMode"
options={{
specific: t('form.dates.options.specific'),
week: t('form.dates.options.week'),
}}
value={type}
onChange={t => {
setType(t)
field.onChange(innerValue[t])
}}
/>
{type === 'specific' ? (
<Month value={innerValue.specific} onChange={field.onChange} />
) : (
<Weekdays value={innerValue.week} onChange={field.onChange} />
)}
</Wrapper>
}
export default CalendarField

View file

@ -0,0 +1,86 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
padding: 6px 0;
font-size: 1.2em;
font-weight: bold;
}
.dayLabels {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-gap: 2px;
& label {
display: flex;
align-items: center;
justify-content: center;
padding: 3px 0;
font-weight: bold;
user-select: none;
opacity: .7;
@media (max-width: 350px) {
font-size: 12px;
}
}
}
.grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-gap: 2px;
& button:first-of-type {
border-top-left-radius: 3px;
}
& button:nth-of-type(7) {
border-top-right-radius: 3px;
}
& button:nth-last-of-type(7) {
border-bottom-left-radius: 3px;
}
& button:last-of-type {
border-bottom-right-radius: 3px;
}
}
.date {
font: inherit;
color: inherit;
background: none;
border: 0;
margin: 0;
appearance: none;
transition: background-color .1s;
background-color: var(--surface);
border: 1px solid var(--primary);
display: flex;
align-items: center;
justify-content: center;
padding: 10px 0;
user-select: none;
touch-action: none;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
.otherMonth {
color: var(--tertiary);
}
.today {
font-weight: 900;
color: var(--secondary);
}
.selected {
color: #FFF;
background-color: var(--primary);
.otherMonth {
color: rgba(255,255,255,.5);
}
}

View file

@ -0,0 +1,182 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import Button from '/src/components/Button/Button'
import dayjs from '/src/config/dayjs'
import { useTranslation } from '/src/i18n/client'
import useLocaleUpdateStore from '/src/stores/localeUpdateStore'
import useSettingsStore from '/src/stores/settingsStore'
import { makeClass } from '/src/utils'
import styles from './Month.module.scss'
// TODO: use from giraugh tools
export const rotateArray = <T,>(arr: T[], amount = 1): T[] =>
arr.map((_, i) => arr[((( -amount + i ) % arr.length) + arr.length) % arr.length])
interface MonthProps {
/** Array of dates in `DDMMYYYY` format */
value: string[]
onChange: (value: string[]) => void
}
const Month = ({ value, onChange }: MonthProps) => {
const { t } = useTranslation('home')
const weekStart = useSettingsStore(state => state.weekStart)
const locale = useLocaleUpdateStore(state => state.locale)
const [page, setPage] = useState({
month: dayjs().month(),
year: dayjs().year(),
})
const [dates, setDates] = useState(calculateMonth(page, weekStart))
// Ref and state required to rerender but also access static version in callbacks
const selectingRef = useRef<string[]>([])
const [selecting, _setSelecting] = useState<string[]>([])
const setSelecting = useCallback((v: string[]) => {
selectingRef.current = v
_setSelecting(v)
}, [])
const startPos = useRef({ x: 0, y: 0 })
const mode = useRef<'add' | 'remove'>()
// Update month view
useEffect(() => {
if (dayjs.Ls?.[locale] && weekStart !== dayjs.Ls[locale].weekStart) {
dayjs.updateLocale(locale, { weekStart })
}
setDates(calculateMonth(page, weekStart))
}, [weekStart, page, locale])
const handleFinishSelection = useCallback(() => {
if (mode.current === 'add') {
onChange([...value, ...selectingRef.current])
} else {
onChange(value.filter(d => !selectingRef.current.includes(d)))
}
mode.current = undefined
}, [value])
return <>
<div className={styles.header}>
<Button
title={t<string>('form.dates.tooltips.previous')}
onClick={() => {
if (page.month - 1 < 0) {
setPage({ month: 11, year: page.year - 1 })
} else {
setPage({ ...page, month: page.month - 1 })
}
}}
icon={<ChevronLeft />}
/>
<span>{dayjs.months()[page.month]} {page.year}</span>
<Button
title={t<string>('form.dates.tooltips.next')}
onClick={() => {
if (page.month + 1 > 11) {
setPage({ month: 0, year: page.year + 1 })
} else {
setPage({ ...page, month: page.month + 1 })
}
}}
icon={<ChevronRight />}
/>
</div>
<div className={styles.dayLabels}>
{(rotateArray(dayjs.weekdaysShort(), -weekStart)).map(name =>
<label key={name}>{name}</label>
)}
</div>
<div className={styles.grid}>
{dates.length > 0 && dates.map((dateRow, y) =>
dateRow.map((date, x) => <button
type="button"
className={makeClass(
styles.date,
date.month !== page.month && styles.otherMonth,
date.isToday && styles.today,
(
(!(mode.current === 'remove' && selecting.includes(date.str)) && value.includes(date.str))
|| (mode.current === 'add' && selecting.includes(date.str))
) && styles.selected,
)}
key={date.str}
title={`${date.day} ${dayjs.months()[date.month]}${date.isToday ? ` (${t('form.dates.tooltips.today')})` : ''}`}
onKeyDown={e => {
if (e.key === ' ' || e.key === 'Enter') {
if (value.includes(date.str)) {
onChange(value.filter(d => d !== date.str))
} else {
onChange([...value, date.str])
}
}
}}
onPointerDown={e => {
startPos.current = { x, y }
mode.current = value.includes(date.str) ? 'remove' : 'add'
setSelecting([date.str])
e.currentTarget.releasePointerCapture(e.pointerId)
document.addEventListener('pointerup', handleFinishSelection, { once: true })
}}
onPointerEnter={() => {
if (mode) {
const found = []
for (let cy = Math.min(startPos.current.y, y); cy < Math.max(startPos.current.y, y) + 1; cy++) {
for (let cx = Math.min(startPos.current.x, x); cx < Math.max(startPos.current.x, x) + 1; cx++) {
found.push({ y: cy, x: cx })
}
}
setSelecting(found.map(d => dates[d.y][d.x].str))
}
}}
>{date.day}</button>)
)}
</div>
</>
}
export default Month
interface Date {
str: string
day: number
month: number
isToday: boolean
}
/** Calculate the dates to show for the month in a 2d array */
const calculateMonth = ({ month, year }: { month: number, year: number }, weekStart: 0 | 1) => {
const date = dayjs().month(month).year(year)
const daysInMonth = date.daysInMonth()
const daysBefore = date.date(1).day() - weekStart
const daysAfter = 6 - date.date(daysInMonth).day() + weekStart
const dates: Date[][] = []
let curDate = date.date(1).subtract(daysBefore, 'day')
let y = 0
let x = 0
for (let i = 0; i < daysBefore + daysInMonth + daysAfter; i++) {
if (x === 0) dates[y] = []
dates[y][x] = {
str: curDate.format('DDMMYYYY'),
day: curDate.date(),
month: curDate.month(),
isToday: curDate.isToday(),
}
curDate = curDate.add(1, 'day')
x++
if (x > 6) {
x = 0
y++
}
}
return dates
}

View file

@ -0,0 +1,97 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import dayjs from '/src/config/dayjs'
import { useTranslation } from '/src/i18n/client'
import useSettingsStore from '/src/stores/settingsStore'
import { makeClass } from '/src/utils'
// Use styles from Month picker
import styles from '../Month/Month.module.scss'
// TODO: use from giraugh tools
export const rotateArray = <T,>(arr: T[], amount = 1): T[] =>
arr.map((_, i) => arr[((( -amount + i ) % arr.length) + arr.length) % arr.length])
interface WeekdaysProps {
/** Array of weekdays as numbers from 0-6 (as strings) */
value: string[]
onChange: (value: string[]) => void
}
const Weekdays = ({ value, onChange }: WeekdaysProps) => {
const { t } = useTranslation('home')
const weekStart = useSettingsStore(state => state.weekStart)
const weekdays = useMemo(() => rotateArray(dayjs.weekdaysShort().map((name, i) => ({
name,
isToday: dayjs().day() === i,
str: String(i),
})), -weekStart), [weekStart])
// Ref and state required to rerender but also access static version in callbacks
const selectingRef = useRef<string[]>([])
const [selecting, _setSelecting] = useState<string[]>([])
const setSelecting = useCallback((v: string[]) => {
selectingRef.current = v
_setSelecting(v)
}, [])
const startPos = useRef(0)
const mode = useRef<'add' | 'remove'>()
const handleFinishSelection = useCallback(() => {
if (mode.current === 'add') {
onChange([...value, ...selectingRef.current])
} else {
onChange(value.filter(d => !selectingRef.current.includes(d)))
}
mode.current = undefined
}, [value])
return <div className={styles.grid}>
{weekdays.map((day, i) =>
<button
type="button"
className={makeClass(
styles.date,
day.isToday && styles.today,
(
(!(mode.current === 'remove' && selecting.includes(day.str)) && value.includes(day.str))
|| (mode.current === 'add' && selecting.includes(day.str))
) && styles.selected,
)}
key={day.name}
title={day.isToday ? t<string>('form.dates.tooltips.today') : undefined}
onKeyDown={e => {
if (e.key === ' ' || e.key === 'Enter') {
if (value.includes(day.str)) {
onChange(value.filter(d => d !== day.str))
} else {
onChange([...value, day.str])
}
}
}}
onPointerDown={e => {
startPos.current = i
mode.current = value.includes(day.str) ? 'remove' : 'add'
setSelecting([day.str])
e.currentTarget.releasePointerCapture(e.pointerId)
document.addEventListener('pointerup', handleFinishSelection, { once: true })
}}
onPointerEnter={() => {
if (mode.current) {
const found = []
for (let ci = Math.min(startPos.current, i); ci < Math.max(startPos.current, i) + 1; ci++) {
found.push(weekdays[ci].str)
}
setSelecting(found)
}
}}
>{day.name}</button>
)}
</div>
}
export default Weekdays

View file

@ -0,0 +1,8 @@
.form {
margin: 0 0 60px;
}
.buttonWrapper {
display: flex;
justify-content: center;
}

View file

@ -0,0 +1,172 @@
'use client'
import { useEffect, useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { useRouter } from 'next/navigation'
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 { API_BASE } from '/src/config/api'
import dayjs from '/src/config/dayjs'
import { useTranslation } from '/src/i18n/client'
import timezones from '/src/res/timezones.json'
import styles from './CreateForm.module.scss'
interface Fields {
name: string
dates: string[]
time: {
start: number
end: number
}
timezone: string
}
const defaultValues: Fields = {
name: '',
dates: [],
time: { start: 9, end: 17 },
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
}
const CreateForm = () => {
const { t } = useTranslation('home')
const { push } = useRouter()
const {
register,
handleSubmit,
control,
} = useForm({ defaultValues })
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<React.ReactNode>()
const onSubmit: SubmitHandler<Fields> = async values => {
console.log({values})
setIsLoading(true)
setError(undefined)
const { name, dates, time, timezone } = values
try {
if (dates.length === 0) {
return setError(t('form.errors.no_dates'))
}
const isSpecificDates = typeof dates[0] === 'string' && dates[0].length === 8
if (time.start === time.end) {
return setError(t('form.errors.same_times'))
}
const times = dates.reduce((times, date) => {
const day = []
for (let i = time.start; i < (time.start > time.end ? 24 : time.end); i++) {
if (isSpecificDates) {
day.push(
dayjs.tz(date, 'DDMMYYYY', timezone)
.hour(i).minute(0).utc().format('HHmm-DDMMYYYY')
)
} else {
day.push(
dayjs().tz(timezone)
.day(date).hour(i).minute(0).utc().format('HHmm-d')
)
}
}
if (time.start > time.end) {
for (let i = 0; i < time.end; i++) {
if (isSpecificDates) {
day.push(
dayjs.tz(date, 'DDMMYYYY', timezone)
.hour(i).minute(0).utc().format('HHmm-DDMMYYYY')
)
} else {
day.push(
dayjs().tz(timezone)
.day(date).hour(i).minute(0).utc().format('HHmm-d')
)
}
}
}
return [...times, ...day]
}, [])
if (times.length === 0) {
return setError(t('form.errors.no_time'))
}
const res = await fetch(new URL('/event', API_BASE), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
times,
timezone,
}),
})
if (!res.ok) {
console.error(res)
throw new Error('Failed to create event')
}
const { id } = await res.json()
// Navigate to the new event
push(`/${id}`)
} catch (e) {
setError(t('form.errors.unknown'))
console.error(e)
} finally {
setIsLoading(false)
}
}
return <form className={styles.form} onSubmit={handleSubmit(onSubmit)} id="create">
<TextField
label={t('form.name.label')}
description={t('form.name.sublabel')}
type="text"
{...register('name')}
/>
<CalendarField
label={t('form.dates.label')}
description={t('form.dates.sublabel')}
control={control}
name="dates"
/>
{/* <TimeRangeField
label={t('form.times.label')}
subLabel={t('form.times.sublabel')}
required
setValue={setValue}
{...register('time')}
/>
<SelectField
label={t('form.timezone.label')}
options={timezones}
required
{...register('timezone')}
defaultOption={t('form.timezone.defaultOption')}
/> */}
<ErrorAlert onClose={() => setError(undefined)}>{error}</ErrorAlert>
<div className={styles.buttonWrapper}>
<Button
type="submit"
isLoading={isLoading}
disabled={isLoading}
>{t('form.button')}</Button>
</div>
</form>
}
export default CreateForm

View file

@ -0,0 +1,16 @@
.wrapper {
margin: 30px 0;
}
.label {
display: block;
padding-bottom: 4px;
font-size: 18px;
}
.description {
display: block;
padding-bottom: 6px;
font-size: 13px;
opacity: .7;
}

View file

@ -0,0 +1,22 @@
import styles from './Field.module.scss'
interface WrapperProps {
children: React.ReactNode
style?: React.CSSProperties
}
export const Wrapper = (props: WrapperProps) =>
<div className={styles.wrapper} {...props} />
interface LabelProps {
htmlFor?: string
children: React.ReactNode
style?: React.CSSProperties
title?: string
}
export const Label = (props: LabelProps) =>
<label className={styles.label} {...props} />
export const Description = (props: LabelProps) =>
<label className={styles.description} {...props} />

View file

@ -6,7 +6,8 @@ import Content from '/src/components/Content/Content'
import Section from '/src/components/Section/Section'
import dayjs from '/src/config/dayjs'
import { useTranslation } from '/src/i18n/client'
import { useRecentsStore, useStore } from '/src/stores'
import { useStore } from '/src/stores'
import useRecentsStore from '/src/stores/recentsStore'
import styles from './Recents.module.scss'

View file

@ -1,24 +0,0 @@
import { forwardRef } from 'react'
import {
Wrapper,
StyledLabel,
StyledSubLabel,
StyledInput,
} from './TextField.styles'
const TextField = forwardRef(({
label,
subLabel,
id,
inline = false,
...props
}, ref) => (
<Wrapper $inline={inline}>
{label && <StyledLabel htmlFor={id} $inline={inline}>{label}</StyledLabel>}
{subLabel && <StyledSubLabel htmlFor={id}>{subLabel}</StyledSubLabel>}
<StyledInput id={id} ref={ref} {...props} />
</Wrapper>
))
export default TextField

View file

@ -0,0 +1,19 @@
.input {
width: 100%;
box-sizing: border-box;
font: inherit;
background: var(--surface);
color: inherit;
padding: 10px 14px;
border: 1px solid var(--primary);
box-shadow: inset 0 0 0 0 var(--primary);
border-radius: 3px;
font-size: 18px;
outline: none;
transition: border-color .15s, box-shadow .15s;
&:focus {
border: 1px solid var(--secondary);
box-shadow: inset 0 -3px 0 0 var(--secondary);
}
}

View file

@ -1,47 +0,0 @@
import { styled } from 'goober'
import { forwardRef } from 'react'
export const Wrapper = styled('div')`
margin: 30px 0;
${props => props.$inline && `
margin: 0;
`}
`
export const StyledLabel = styled('label')`
display: block;
padding-bottom: 4px;
font-size: 18px;
${props => props.$inline && `
font-size: 16px;
`}
`
export const StyledSubLabel = styled('label')`
display: block;
padding-bottom: 6px;
font-size: 13px;
opacity: .6;
`
export const StyledInput = styled('input', forwardRef)`
width: 100%;
box-sizing: border-box;
font: inherit;
background: var(--surface);
color: inherit;
padding: 10px 14px;
border: 1px solid var(--primary);
box-shadow: inset 0 0 0 0 var(--primary);
border-radius: 3px;
font-size: 18px;
outline: none;
transition: border-color .15s, box-shadow .15s;
&:focus {
border: 1px solid var(--secondary);
box-shadow: inset 0 -3px 0 0 var(--secondary);
}
`

View file

@ -0,0 +1,31 @@
import { forwardRef } from 'react'
import { Description, Label, Wrapper } from '/src/components/Field/Field'
import styles from './TextField.module.scss'
interface TextFieldProps extends React.ComponentProps<'input'> {
label?: React.ReactNode
description?: React.ReactNode
isInline?: boolean
}
const TextField = forwardRef<HTMLInputElement, TextFieldProps>(({
label,
description,
isInline,
...props
}, ref) => (
<Wrapper style={isInline ? { margin: 0 } : undefined}>
{label && <Label
htmlFor={props.name}
style={isInline ? { fontSize: '16px' } : undefined}
>{label}</Label>}
{description && <Description htmlFor={props.name}>{description}</Description>}
<input className={styles.input} id={props.name} ref={ref} {...props} />
</Wrapper>
))
export default TextField

View file

@ -1,43 +0,0 @@
import { Info } from 'lucide-react'
import {
Wrapper,
ToggleContainer,
StyledLabel,
Option,
HiddenInput,
LabelButton,
} from './ToggleField.styles'
const ToggleField = ({
label,
name,
title = '',
options = [],
value,
onChange,
inputRef,
}) => (
<Wrapper>
{label && <StyledLabel title={title}>{label} {title !== '' && <Info />}</StyledLabel>}
<ToggleContainer>
{Object.entries(options).map(([key, label]) =>
<Option key={label}>
<HiddenInput
type="radio"
name={name}
value={label}
id={`${name}-${label}`}
checked={value === key}
onChange={() => onChange(key)}
ref={inputRef}
/>
<LabelButton htmlFor={`${name}-${label}`}>{label}</LabelButton>
</Option>
)}
</ToggleContainer>
</Wrapper>
)
export default ToggleField

View file

@ -1,11 +1,4 @@
import { styled } from 'goober'
import { forwardRef } from 'react'
export const Wrapper = styled('div')`
margin: 10px 0;
`
export const ToggleContainer = styled('div')`
.toggleContainer {
display: flex;
border: 1px solid var(--primary);
border-radius: 3px;
@ -27,26 +20,14 @@ export const ToggleContainer = styled('div')`
& > div:last-of-type label {
border-end-end-radius: 2px;
}
`
}
export const StyledLabel = styled('label')`
display: block;
padding-bottom: 4px;
font-size: .9rem;
& svg {
height: 1em;
width: 1em;
vertical-align: middle;
}
`
export const Option = styled('div')`
.option {
flex: 1;
position: relative;
`
}
export const HiddenInput = styled('input', forwardRef)`
.hiddenInput {
height: 0;
width: 0;
position: absolute;
@ -59,9 +40,9 @@ export const HiddenInput = styled('input', forwardRef)`
color: var(--background);
background-color: var(--focus-color);
}
`
}
export const LabelButton = styled('label')`
.button {
padding: 6px;
display: flex;
text-align: center;
@ -72,4 +53,4 @@ export const LabelButton = styled('label')`
align-items: center;
justify-content: center;
transition: box-shadow .15s, background-color .15s;
`
}

View file

@ -0,0 +1,47 @@
import { Info } from 'lucide-react'
import { Label, Wrapper } from '/src/components/Field/Field'
import styles from './ToggleField.module.scss'
interface ToggleFieldProps<TValue extends string> {
label?: React.ReactNode
description?: React.ReactNode
name: string
value: TValue
onChange: (value: TValue) => void
options: Record<TValue, React.ReactNode>
}
const ToggleField = <TValue extends string>({
label,
description,
name,
options,
value,
onChange,
}: ToggleFieldProps<TValue>) => <Wrapper style={{ marginBlock: '10px' }}>
{/* TODO: Better description viewer */}
{label && <Label style={{ fontSize: '.9em' }} title={description as string}>
{label} {description && <Info size="1em" style={{ verticalAlign: 'middle' }} />}
</Label>}
<div className={styles.toggleContainer}>
{Object.entries(options).map(([key, label]) =>
<div className={styles.option} key={key}>
<input
className={styles.hiddenInput}
type="radio"
name={name}
value={key}
id={`${name}-${key}`}
checked={value === key}
onChange={() => onChange(key as TValue)}
/>
<label className={styles.button} htmlFor={`${name}-${key}`}>{label as React.ReactNode}</label>
</div>
)}
</div>
</Wrapper>
export default ToggleField