Rebuild TextField and CalendarField
This commit is contained in:
parent
1e77205518
commit
12004b8584
28 changed files with 783 additions and 845 deletions
|
|
@ -58,6 +58,16 @@
|
|||
}
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
padding: 0;
|
||||
|
||||
& svg, & img {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.small {
|
||||
padding: .4em 1.3em;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}}
|
||||
><</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)
|
||||
}
|
||||
}}
|
||||
>></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
|
||||
|
|
@ -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')};
|
||||
`}
|
||||
`
|
||||
61
frontend/src/components/CalendarField/CalendarField.tsx
Normal file
61
frontend/src/components/CalendarField/CalendarField.tsx
Normal 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
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
182
frontend/src/components/CalendarField/components/Month/Month.tsx
Normal file
182
frontend/src/components/CalendarField/components/Month/Month.tsx
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
.form {
|
||||
margin: 0 0 60px;
|
||||
}
|
||||
|
||||
.buttonWrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
172
frontend/src/components/CreateForm/CreateForm.tsx
Normal file
172
frontend/src/components/CreateForm/CreateForm.tsx
Normal 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
|
||||
16
frontend/src/components/Field/Field.module.scss
Normal file
16
frontend/src/components/Field/Field.module.scss
Normal 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;
|
||||
}
|
||||
22
frontend/src/components/Field/Field.tsx
Normal file
22
frontend/src/components/Field/Field.tsx
Normal 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} />
|
||||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
19
frontend/src/components/TextField/TextField.module.scss
Normal file
19
frontend/src/components/TextField/TextField.module.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
`
|
||||
31
frontend/src/components/TextField/TextField.tsx
Normal file
31
frontend/src/components/TextField/TextField.tsx
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
`
|
||||
}
|
||||
47
frontend/src/components/ToggleField/ToggleField.tsx
Normal file
47
frontend/src/components/ToggleField/ToggleField.tsx
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue