Calendar field and time field

This commit is contained in:
Ben Grant 2021-03-02 20:31:32 +11:00
parent edcd4dcaa0
commit 0dde47109f
32 changed files with 901 additions and 65 deletions

View file

@ -0,0 +1,14 @@
import { Wrapper, Top, Bottom } from './buttonStyle';
const Button = ({
buttonHeight,
buttonWidth,
...props
}) => (
<Wrapper buttonHeight={buttonHeight} buttonWidth={buttonWidth}>
<Top {...props} />
<Bottom />
</Wrapper>
);
export default Button;

View file

@ -0,0 +1,47 @@
import styled from '@emotion/styled';
export const Wrapper = styled.div`
display: inline-block;
position: relative;
--btn-height: ${props => props.buttonHeight || '40px'};
--btn-width: ${props => props.buttonWidth || '100px'};
height: var(--btn-height);
width: var(--btn-width);
`;
export const Top = styled.button`
border: 0;
cursor: pointer;
font: inherit;
box-sizing: border-box;
background: ${props => props.theme.primary};
color: #FFF;
font-weight: 600;
text-shadow: 0 -1.5px .5px ${props => props.theme.primaryDark};
padding: ${props => props.padding || '10px 14px'};
border-radius: 3px;
height: var(--btn-height);
width: var(--btn-width);
position: absolute;
top: -4px;
user-select: none;
transition: top .15s;
outline: none;
&:active {
top: 0;
}
&:focus-visible {
filter: brightness(1.2);
}
`;
export const Bottom = styled.div`
box-sizing: border-box;
background: ${props => props.theme.primaryDark};
border-radius: 3px;
height: var(--btn-height);
width: var(--btn-width);
`;

View file

@ -0,0 +1,192 @@
import { useState, useEffect, useRef } from 'react';
import dayjs from 'dayjs';
import isToday from 'dayjs/plugin/isToday';
import { Button } from 'components';
import {
Wrapper,
StyledLabel,
StyledSubLabel,
CalendarHeader,
CalendarBody,
Date,
Day,
} from './calendarFieldStyle';
dayjs.extend(isToday);
const days = [
'Sun',
'Mon',
'Tue',
'Wed',
'Thu',
'Fri',
'Sat',
];
const months = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
const calculateMonth = (month, year) => {
const date = dayjs().month(month).year(year);
const daysInMonth = date.daysInMonth();
const daysBefore = date.date(1).day();
const daysAfter = 6 - date.date(daysInMonth).day();
let 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 = ({
label,
subLabel,
id,
register,
...props
}) => {
const [dates, setDates] = useState(calculateMonth(dayjs().month(), dayjs().year()));
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 startPos = useRef({});
const staticMode = useRef(null);
const [mode, _setMode] = useState(staticMode.current);
const setMode = newMode => {
staticMode.current = newMode;
_setMode(newMode);
};
useEffect(() => {
setDates(calculateMonth(month, year));
}, [month, year]);
return (
<Wrapper>
{label && <StyledLabel htmlFor={id}>{label}</StyledLabel>}
{subLabel && <StyledSubLabel htmlFor={id}>{subLabel}</StyledSubLabel>}
<input
id={id}
type="hidden"
ref={register}
value={JSON.stringify(selectedDates)}
{...props}
/>
<CalendarHeader>
<Button
buttonHeight="30px"
buttonWidth="30px"
padding="0"
title="Previous month"
type="button"
onClick={() => {
if (month-1 < 0) {
setYear(year-1);
setMonth(11);
} else {
setMonth(month-1);
}
}}
>&lt;</Button>
<span>{months[month]} {year}</span>
<Button
buttonHeight="30px"
buttonWidth="30px"
padding="0"
title="Next month"
type="button"
onClick={() => {
if (month+1 > 11) {
setYear(year+1);
setMonth(0);
} else {
setMonth(month+1);
}
}}
>&gt;</Button>
</CalendarHeader>
<CalendarBody>
{days.map((name, i) =>
<Day key={i}>{name}</Day>
)}
{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()} ${months[date.month()]}${date.isToday() ? ' (today)' : ''}`}
selected={selectedDates.includes(date.format('DDMMYYYY'))}
selecting={selectingDates.includes(date)}
mode={mode}
onMouseDown={() => {
startPos.current = {x, y};
setMode(selectedDates.includes(date.format('DDMMYYYY')) ? 'remove' : 'add');
setSelectingDates([date]);
document.addEventListener('mouseup', () => {
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 });
}}
onMouseEnter={() => {
if (staticMode.current) {
let 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>
</Wrapper>
);
};
export default CalendarField;

View file

@ -0,0 +1,73 @@
import styled from '@emotion/styled';
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 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 CalendarBody = styled.div`
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-gap: 2px;
`;
export const Date = styled.div`
background-color: ${props => props.theme.primary}22;
border: 1px solid ${props => props.theme.primaryLight};
display: flex;
align-items: center;
justify-content: center;
padding: 10px 0;
border-radius: 3px;
user-select: none;
${props => props.otherMonth && `
color: ${props.theme.primaryLight};
`}
${props => props.isToday && `
font-weight: 900;
color: ${props.theme.primaryDark};
`}
${props => (props.selected || (props.mode === 'add' && props.selecting)) && `
color: ${props.otherMonth ? 'rgba(255,255,255,.5)' : '#FFF'};
background-color: ${props.theme.primary};
border-color: ${props.theme.primary};
`}
${props => props.mode === 'remove' && props.selecting && `
background-color: ${props.theme.primary}22;
border: 1px solid ${props.theme.primaryLight};
color: ${props.isToday ? props.theme.primaryDark : (props.otherMonth ? props.theme.primaryLight : 'inherit')};
`}
`;
export const Day = styled.div`
display: flex;
align-items: center;
justify-content: center;
padding: 3px 10px;
font-weight: bold;
user-select: none;
opacity: .7;
`;

View file

@ -0,0 +1,22 @@
import {
Wrapper,
StyledLabel,
StyledSubLabel,
StyledInput,
} from './textFieldStyle';
const TextField = ({
label,
subLabel,
id,
register,
...props
}) => (
<Wrapper>
{label && <StyledLabel htmlFor={id}>{label}</StyledLabel>}
{subLabel && <StyledSubLabel htmlFor={id}>{subLabel}</StyledSubLabel>}
<StyledInput id={id} ref={register} {...props} />
</Wrapper>
);
export default TextField;

View file

@ -0,0 +1,38 @@
import styled from '@emotion/styled';
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 StyledInput = styled.input`
width: 100%;
box-sizing: border-box;
font: inherit;
background: ${props => props.theme.primary}22;
color: inherit;
padding: 10px 14px;
border: 1px solid ${props => props.theme.primaryLight};
box-shadow: inset 0 0 0 0 ${props => props.theme.primaryLight};
border-radius: 3px;
font-size: 18px;
outline: none;
transition: border-color .15s, box-shadow .15s;
&:focus {
border: 1px solid ${props => props.theme.primary};
box-shadow: inset 0 -3px 0 0 ${props => props.theme.primary};
}
`;

View file

@ -0,0 +1,139 @@
import { useState, useEffect, useRef } from 'react';
import {
Wrapper,
StyledLabel,
StyledSubLabel,
Range,
Handle,
Selected,
} from './timeRangeFieldStyle';
const times = [
'12am',
'1am',
'2am',
'3am',
'4am',
'5am',
'6am',
'7am',
'8am',
'9am',
'10am',
'11am',
'12pm',
'1pm',
'2pm',
'3pm',
'4pm',
'5pm',
'6pm',
'7pm',
'8pm',
'9pm',
'10pm',
'11pm',
'12am',
];
const TimeRangeField = ({
label,
subLabel,
id,
register,
...props
}) => {
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]);
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>
{label && <StyledLabel htmlFor={id}>{label}</StyledLabel>}
{subLabel && <StyledSubLabel htmlFor={id}>{subLabel}</StyledSubLabel>}
<input
id={id}
type="hidden"
ref={register}
value={JSON.stringify(start > end ? {start: end, end: start} : {start, end})}
{...props}
/>
<Range ref={rangeRef}>
<Selected start={start > end ? end : start} end={start > end ? start : end} />
<Handle
value={start}
label={times[start]}
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);
}}
/>
<Handle
value={end}
label={times[end]}
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);
}}
/>
</Range>
</Wrapper>
);
};
export default TimeRangeField;

View file

@ -0,0 +1,72 @@
import styled from '@emotion/styled';
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`
user-select: none;
background-color: ${props => props.theme.primary}22;
border: 1px solid ${props => props.theme.primaryLight};
border-radius: 3px;
height: 50px;
position: relative;
margin: 38px 6px 18px;
`;
export const Handle = styled.div`
height: calc(100% + 20px);
width: 20px;
border: 1px solid ${props => props.theme.primary};
background-color: ${props => props.theme.primaryLight};
border-radius: 3px;
position: absolute;
top: -10px;
left: calc(${props => props.value * 4.1666666666666666}% - 11px);
cursor: ew-resize;
&:after {
content: '|||';
font-size: 8px;
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
color: ${props => props.theme.primaryDark};
}
&:before {
content: '${props => props.label}';
position: absolute;
bottom: calc(100% + 8px);
text-align: center;
left: 50%;
transform: translateX(-50%);
}
`;
export const Selected = styled.div`
position: absolute;
height: 100%;
left: ${props => props.start * 4.1666666666666666}%;
right: calc(100% - ${props => props.end * 4.1666666666666666}%);
top: 0;
background-color: ${props => props.theme.primary};
`;

View file

@ -0,0 +1,4 @@
export { default as TextField } from './TextField/TextField';
export { default as CalendarField } from './CalendarField/CalendarField';
export { default as TimeRangeField } from './TimeRangeField/TimeRangeField';
export { default as Button } from './Button/Button';