Calendar field and time field
This commit is contained in:
parent
edcd4dcaa0
commit
0dde47109f
32 changed files with 901 additions and 65 deletions
14
crabfit-frontend/src/components/Button/Button.tsx
Normal file
14
crabfit-frontend/src/components/Button/Button.tsx
Normal 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;
|
||||
47
crabfit-frontend/src/components/Button/buttonStyle.ts
Normal file
47
crabfit-frontend/src/components/Button/buttonStyle.ts
Normal 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);
|
||||
`;
|
||||
192
crabfit-frontend/src/components/CalendarField/CalendarField.tsx
Normal file
192
crabfit-frontend/src/components/CalendarField/CalendarField.tsx
Normal 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);
|
||||
}
|
||||
}}
|
||||
><</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);
|
||||
}
|
||||
}}
|
||||
>></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;
|
||||
|
|
@ -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;
|
||||
`;
|
||||
22
crabfit-frontend/src/components/TextField/TextField.tsx
Normal file
22
crabfit-frontend/src/components/TextField/TextField.tsx
Normal 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;
|
||||
38
crabfit-frontend/src/components/TextField/textFieldStyle.ts
Normal file
38
crabfit-frontend/src/components/TextField/textFieldStyle.ts
Normal 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};
|
||||
}
|
||||
`;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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};
|
||||
`;
|
||||
4
crabfit-frontend/src/components/index.ts
Normal file
4
crabfit-frontend/src/components/index.ts
Normal 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';
|
||||
Loading…
Add table
Add a link
Reference in a new issue