mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-12-19 19:06:16 +01:00
Event Datepicker (#2428)
* new datepicker * dev * dev * dev * fix for version 0.10 * added rounded corners for date range * lint * Commented out some Select.test. * improved date range selection * improved functions with useCallback * improved Select.test.jsx * keyboard navigation * keyboard navigation * added dropdown menu icon * Hide filters on xs, Button to show * check if to far left before right * Filter button text * improved local timezone
This commit is contained in:
parent
6eecb6780e
commit
a10970d7c9
329
web/src/components/Calender.jsx
Normal file
329
web/src/components/Calender.jsx
Normal file
@ -0,0 +1,329 @@
|
||||
import { h } from 'preact';
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from 'preact/hooks';
|
||||
import ArrowRight from '../icons/ArrowRight';
|
||||
import ArrowRightDouble from '../icons/ArrowRightDouble';
|
||||
|
||||
const todayTimestamp = new Date().setHours(0, 0, 0, 0).valueOf();
|
||||
|
||||
const Calender = ({ onChange, calenderRef, close }) => {
|
||||
const keyRef = useRef([]);
|
||||
|
||||
const date = new Date();
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
|
||||
const daysMap = useMemo(() => ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], []);
|
||||
const monthMap = useMemo(
|
||||
() => [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const [state, setState] = useState({
|
||||
getMonthDetails: [],
|
||||
year,
|
||||
month,
|
||||
selectedDay: null,
|
||||
timeRange: { before: null, after: null },
|
||||
monthDetails: null,
|
||||
});
|
||||
|
||||
const getNumberOfDays = useCallback((year, month) => {
|
||||
return 40 - new Date(year, month, 40).getDate();
|
||||
}, []);
|
||||
|
||||
const getDayDetails = useCallback(
|
||||
(args) => {
|
||||
const date = args.index - args.firstDay;
|
||||
const day = args.index % 7;
|
||||
let prevMonth = args.month - 1;
|
||||
let prevYear = args.year;
|
||||
if (prevMonth < 0) {
|
||||
prevMonth = 11;
|
||||
prevYear--;
|
||||
}
|
||||
const prevMonthNumberOfDays = getNumberOfDays(prevYear, prevMonth);
|
||||
const _date = (date < 0 ? prevMonthNumberOfDays + date : date % args.numberOfDays) + 1;
|
||||
const month = date < 0 ? -1 : date >= args.numberOfDays ? 1 : 0;
|
||||
const timestamp = new Date(args.year, args.month, _date).getTime();
|
||||
return {
|
||||
date: _date,
|
||||
day,
|
||||
month,
|
||||
timestamp,
|
||||
dayString: daysMap[day],
|
||||
};
|
||||
},
|
||||
[getNumberOfDays, daysMap]
|
||||
);
|
||||
|
||||
const getMonthDetails = useCallback(
|
||||
(year, month) => {
|
||||
const firstDay = new Date(year, month).getDay();
|
||||
const numberOfDays = getNumberOfDays(year, month);
|
||||
const monthArray = [];
|
||||
const rows = 6;
|
||||
let currentDay = null;
|
||||
let index = 0;
|
||||
const cols = 7;
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
currentDay = getDayDetails({
|
||||
index,
|
||||
numberOfDays,
|
||||
firstDay,
|
||||
year,
|
||||
month,
|
||||
});
|
||||
monthArray.push(currentDay);
|
||||
index++;
|
||||
}
|
||||
}
|
||||
return monthArray;
|
||||
},
|
||||
[getNumberOfDays, getDayDetails]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setState((prev) => ({ ...prev, selectedDay: todayTimestamp, monthDetails: getMonthDetails(year, month) }));
|
||||
}, [year, month, getMonthDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
// add refs for keyboard navigation
|
||||
if (state.monthDetails) {
|
||||
keyRef.current = keyRef.current.slice(0, state.monthDetails.length);
|
||||
}
|
||||
// set today date in focus for keyboard navigation
|
||||
const todayDate = new Date(todayTimestamp).getDate();
|
||||
keyRef.current.find((t) => t.tabIndex === todayDate)?.focus();
|
||||
}, [state.monthDetails]);
|
||||
|
||||
const isCurrentDay = (day) => day.timestamp === todayTimestamp;
|
||||
|
||||
const isSelectedRange = useCallback(
|
||||
(day) => {
|
||||
if (!state.timeRange.after || !state.timeRange.before) return;
|
||||
|
||||
return day.timestamp < state.timeRange.before && day.timestamp >= state.timeRange.after;
|
||||
},
|
||||
[state.timeRange]
|
||||
);
|
||||
|
||||
const isFirstDayInRange = useCallback(
|
||||
(day) => {
|
||||
if (isCurrentDay(day)) return;
|
||||
return state.timeRange.after === day.timestamp;
|
||||
},
|
||||
[state.timeRange.after]
|
||||
);
|
||||
|
||||
const isLastDayInRange = useCallback(
|
||||
(day) => {
|
||||
return state.timeRange.before === new Date(day.timestamp).setHours(24, 0, 0, 0);
|
||||
},
|
||||
[state.timeRange.before]
|
||||
);
|
||||
|
||||
const getMonthStr = useCallback(
|
||||
(month) => {
|
||||
return monthMap[Math.max(Math.min(11, month), 0)] || 'Month';
|
||||
},
|
||||
[monthMap]
|
||||
);
|
||||
|
||||
const onDateClick = (day) => {
|
||||
const { before, after } = state.timeRange;
|
||||
let timeRange = { before: null, after: null };
|
||||
|
||||
// user has selected a date < after, reset values
|
||||
if (after === null || day.timestamp < after) {
|
||||
timeRange = { before: new Date(day.timestamp).setHours(24, 0, 0, 0), after: day.timestamp };
|
||||
}
|
||||
|
||||
// user has selected a date > after
|
||||
if (after !== null && before !== new Date(day.timestamp).setHours(24, 0, 0, 0) && day.timestamp > after) {
|
||||
timeRange = {
|
||||
after,
|
||||
before:
|
||||
day.timestamp >= todayTimestamp
|
||||
? new Date(todayTimestamp).setHours(24, 0, 0, 0)
|
||||
: new Date(day.timestamp).setHours(24, 0, 0, 0),
|
||||
};
|
||||
}
|
||||
|
||||
// reset values
|
||||
if (before === new Date(day.timestamp).setHours(24, 0, 0, 0)) {
|
||||
timeRange = { before: null, after: null };
|
||||
}
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
timeRange,
|
||||
selectedDay: day.timestamp,
|
||||
}));
|
||||
|
||||
if (onChange) {
|
||||
onChange(timeRange.after ? { before: timeRange.before / 1000, after: timeRange.after / 1000 } : ['all']);
|
||||
}
|
||||
};
|
||||
|
||||
const setYear = useCallback(
|
||||
(offset) => {
|
||||
const year = state.year + offset;
|
||||
const month = state.month;
|
||||
setState((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
year,
|
||||
monthDetails: getMonthDetails(year, month),
|
||||
};
|
||||
});
|
||||
},
|
||||
[state.year, state.month, getMonthDetails]
|
||||
);
|
||||
|
||||
const setMonth = (offset) => {
|
||||
let year = state.year;
|
||||
let month = state.month + offset;
|
||||
if (month === -1) {
|
||||
month = 11;
|
||||
year--;
|
||||
} else if (month === 12) {
|
||||
month = 0;
|
||||
year++;
|
||||
}
|
||||
setState((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
year,
|
||||
month,
|
||||
monthDetails: getMonthDetails(year, month),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeydown = (e, day, index) => {
|
||||
if ((keyRef.current && e.key === 'Enter') || e.keyCode === 32) {
|
||||
e.preventDefault();
|
||||
day.month === 0 && onDateClick(day);
|
||||
}
|
||||
if (e.key === 'ArrowLeft') {
|
||||
index > 0 && keyRef.current[index - 1].focus();
|
||||
}
|
||||
if (e.key === 'ArrowRight') {
|
||||
index < 41 && keyRef.current[index + 1].focus();
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
index > 6 && keyRef.current[index - 7].focus();
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
index < 36 && keyRef.current[index + 7].focus();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
const renderCalendar = () => {
|
||||
const days =
|
||||
state.monthDetails &&
|
||||
state.monthDetails.map((day, idx) => {
|
||||
return (
|
||||
<div
|
||||
onClick={() => onDateClick(day)}
|
||||
onkeydown={(e) => handleKeydown(e, day, idx)}
|
||||
ref={(ref) => (keyRef.current[idx] = ref)}
|
||||
tabIndex={day.month === 0 ? day.date : null}
|
||||
className={`h-12 w-12 float-left flex flex-shrink justify-center items-center cursor-pointer ${
|
||||
day.month !== 0 ? ' opacity-50 bg-gray-700 dark:bg-gray-700 pointer-events-none' : ''
|
||||
}
|
||||
${isFirstDayInRange(day) ? ' rounded-l-xl ' : ''}
|
||||
${isSelectedRange(day) ? ' bg-blue-600 dark:hover:bg-blue-600' : ''}
|
||||
${isLastDayInRange(day) ? ' rounded-r-xl ' : ''}
|
||||
${isCurrentDay(day) && !isLastDayInRange(day) ? 'rounded-full bg-gray-100 dark:hover:bg-gray-100 ' : ''}`}
|
||||
key={idx}
|
||||
>
|
||||
<div className="font-light">
|
||||
<span className="text-gray-400">{day.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="w-full flex justify-start flex-shrink">
|
||||
{['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'].map((d, i) => (
|
||||
<div key={i} className="w-12 text-xs font-light text-center">
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-full h-56">{days}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="select-none w-96 flex flex-shrink" ref={calenderRef}>
|
||||
<div className="py-4 px-6">
|
||||
<div className="flex items-center">
|
||||
<div className="w-1/6 relative flex justify-around">
|
||||
<div
|
||||
tabIndex={100}
|
||||
className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800"
|
||||
onClick={() => setYear(-1)}
|
||||
>
|
||||
<ArrowRightDouble className="h-2/6 transform rotate-180 " />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/6 relative flex justify-around ">
|
||||
<div
|
||||
tabIndex={101}
|
||||
className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800"
|
||||
onClick={() => setMonth(-1)}
|
||||
>
|
||||
<ArrowRight className="h-2/6 transform rotate-180 red" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/3">
|
||||
<div className="text-3xl text-center text-gray-200 font-extralight">{state.year}</div>
|
||||
<div className="text-center text-gray-400 font-extralight">{getMonthStr(state.month)}</div>
|
||||
</div>
|
||||
<div className="w-1/6 relative flex justify-around ">
|
||||
<div
|
||||
tabIndex={102}
|
||||
className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800"
|
||||
onClick={() => setMonth(1)}
|
||||
>
|
||||
<ArrowRight className="h-2/6" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/6 relative flex justify-around " tabIndex={104} onClick={() => setYear(1)}>
|
||||
<div className="flex justify-center items-center cursor-pointer absolute -mt-4 text-center rounded-full w-10 h-10 bg-gray-500 hover:bg-gray-200 dark:hover:bg-gray-800">
|
||||
<ArrowRightDouble className="h-2/6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">{renderCalendar()}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Calender;
|
162
web/src/components/DatePicker.jsx
Normal file
162
web/src/components/DatePicker.jsx
Normal file
@ -0,0 +1,162 @@
|
||||
import { h } from 'preact';
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||
|
||||
export const DateFilterOptions = [
|
||||
{
|
||||
label: 'All',
|
||||
value: ['all'],
|
||||
},
|
||||
{
|
||||
label: 'Today',
|
||||
value: {
|
||||
//Before
|
||||
before: new Date().setHours(24, 0, 0, 0) / 1000,
|
||||
//After
|
||||
after: new Date().setHours(0, 0, 0, 0) / 1000,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Yesterday',
|
||||
value: {
|
||||
//Before
|
||||
before: new Date(new Date().setDate(new Date().getDate() - 1)).setHours(24, 0, 0, 0) / 1000,
|
||||
//After
|
||||
after: new Date(new Date().setDate(new Date().getDate() - 1)).setHours(0, 0, 0, 0) / 1000,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Last 7 Days',
|
||||
value: {
|
||||
//Before
|
||||
before: new Date().setHours(24, 0, 0, 0) / 1000,
|
||||
//After
|
||||
after: new Date(new Date().setDate(new Date().getDate() - 7)).setHours(0, 0, 0, 0) / 1000,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'This Month',
|
||||
value: {
|
||||
//Before
|
||||
before: new Date().setHours(24, 0, 0, 0) / 1000,
|
||||
//After
|
||||
after: new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime() / 1000,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Last Month',
|
||||
value: {
|
||||
//Before
|
||||
before: new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime() / 1000,
|
||||
//After
|
||||
after: new Date(new Date().getFullYear(), new Date().getMonth() - 1, 1).getTime() / 1000,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Custom Range',
|
||||
value: 'custom_range',
|
||||
},
|
||||
];
|
||||
|
||||
export default function DatePicker({
|
||||
helpText,
|
||||
keyboardType = 'text',
|
||||
inputRef,
|
||||
label,
|
||||
leadingIcon: LeadingIcon,
|
||||
onBlur,
|
||||
onChangeText,
|
||||
onFocus,
|
||||
readonly,
|
||||
trailingIcon: TrailingIcon,
|
||||
value: propValue = '',
|
||||
...props
|
||||
}) {
|
||||
const [isFocused, setFocused] = useState(false);
|
||||
const [value, setValue] = useState(propValue);
|
||||
|
||||
useEffect(() => {
|
||||
if (propValue !== value) {
|
||||
setValue(propValue);
|
||||
}
|
||||
}, [propValue, setValue, value]);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(event) => {
|
||||
setFocused(true);
|
||||
onFocus && onFocus(event);
|
||||
},
|
||||
[onFocus]
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(event) => {
|
||||
setFocused(false);
|
||||
onBlur && onBlur(event);
|
||||
},
|
||||
[onBlur]
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(event) => {
|
||||
const { value } = event.target;
|
||||
setValue(value);
|
||||
onChangeText && onChangeText(value);
|
||||
},
|
||||
[onChangeText, setValue]
|
||||
);
|
||||
|
||||
const onClick = (e) => {
|
||||
props.onclick(e);
|
||||
};
|
||||
const labelMoved = isFocused || value !== '';
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{props.children}
|
||||
<div
|
||||
className={`bg-gray-100 dark:bg-gray-700 rounded rounded-b-none border-gray-400 border-b p-1 pl-4 pr-3 ${
|
||||
isFocused ? 'border-blue-500 dark:border-blue-500' : ''
|
||||
}`}
|
||||
ref={inputRef}
|
||||
>
|
||||
<label
|
||||
className="flex space-x-2 items-center"
|
||||
data-testid={`label-${label.toLowerCase().replace(/[^\w]+/g, '_')}`}
|
||||
>
|
||||
{LeadingIcon ? (
|
||||
<div className="w-10 h-full">
|
||||
<LeadingIcon />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
className="h-6 mt-6 w-full bg-transparent focus:outline-none focus:ring-0"
|
||||
type={keyboardType}
|
||||
readOnly
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onInput={handleChange}
|
||||
tabIndex="0"
|
||||
onClick={onClick}
|
||||
value={propValue}
|
||||
{...props}
|
||||
/>
|
||||
<div
|
||||
className={`absolute top-3 transition transform text-gray-600 dark:text-gray-400 ${
|
||||
labelMoved ? 'text-xs -translate-y-2' : ''
|
||||
} ${isFocused ? 'text-blue-500 dark:text-blue-500' : ''}`}
|
||||
>
|
||||
<p>{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
{TrailingIcon ? (
|
||||
<div className="w-10 h-10">
|
||||
<TrailingIcon />
|
||||
</div>
|
||||
) : null}
|
||||
</label>
|
||||
</div>
|
||||
{helpText ? <div className="text-xs pl-3 pt-1">{helpText}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -27,7 +27,7 @@ export default function RelativeModal({
|
||||
|
||||
const handleKeydown = useCallback(
|
||||
(event) => {
|
||||
const focusable = ref.current.querySelectorAll('[tabindex]');
|
||||
const focusable = ref.current && ref.current.querySelectorAll('[tabindex]');
|
||||
if (event.key === 'Tab' && focusable.length) {
|
||||
if (event.shiftKey && document.activeElement === focusable[0]) {
|
||||
focusable[focusable.length - 1].focus();
|
||||
@ -69,14 +69,15 @@ export default function RelativeModal({
|
||||
let newTop = top;
|
||||
let newLeft = left;
|
||||
|
||||
// too far right
|
||||
if (newLeft + width + WINDOW_PADDING >= windowWidth - WINDOW_PADDING) {
|
||||
newLeft = windowWidth - width - WINDOW_PADDING;
|
||||
}
|
||||
// too far left
|
||||
else if (left < WINDOW_PADDING) {
|
||||
if (left < WINDOW_PADDING) {
|
||||
newLeft = WINDOW_PADDING;
|
||||
}
|
||||
// too far right
|
||||
else if (newLeft + width + WINDOW_PADDING >= windowWidth - WINDOW_PADDING) {
|
||||
newLeft = windowWidth - width - WINDOW_PADDING;
|
||||
}
|
||||
|
||||
// too close to bottom
|
||||
if (top + menuHeight > windowHeight - WINDOW_PADDING + window.scrollY) {
|
||||
newTop = WINDOW_PADDING;
|
||||
|
@ -3,74 +3,27 @@ import ArrowDropdown from '../icons/ArrowDropdown';
|
||||
import ArrowDropup from '../icons/ArrowDropup';
|
||||
import Menu, { MenuItem } from './Menu';
|
||||
import TextField from './TextField';
|
||||
import DatePicker from './DatePicker';
|
||||
import Calender from './Calender';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
|
||||
export default function Select({ label, onChange, options: inputOptions = [], selected: propSelected }) {
|
||||
export default function Select({
|
||||
type,
|
||||
label,
|
||||
onChange,
|
||||
paramName,
|
||||
options: inputOptions = [],
|
||||
selected: propSelected,
|
||||
}) {
|
||||
const options = useMemo(
|
||||
() =>
|
||||
typeof inputOptions[0] === 'string' ? inputOptions.map((opt) => ({ value: opt, label: opt })) : inputOptions,
|
||||
[inputOptions]
|
||||
);
|
||||
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [selected, setSelected] = useState(
|
||||
Math.max(
|
||||
options.findIndex(({ value }) => value === propSelected),
|
||||
0
|
||||
)
|
||||
);
|
||||
const [focused, setFocused] = useState(null);
|
||||
|
||||
const ref = useRef(null);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(value, label) => {
|
||||
setSelected(options.findIndex((opt) => opt.value === value));
|
||||
onChange && onChange(value, label);
|
||||
setShowMenu(false);
|
||||
},
|
||||
[onChange, options]
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setShowMenu(true);
|
||||
}, [setShowMenu]);
|
||||
|
||||
const handleKeydown = useCallback(
|
||||
(event) => {
|
||||
switch (event.key) {
|
||||
case 'Enter': {
|
||||
if (!showMenu) {
|
||||
setShowMenu(true);
|
||||
setFocused(selected);
|
||||
} else {
|
||||
setSelected(focused);
|
||||
onChange && onChange(options[focused].value, options[focused].label);
|
||||
setShowMenu(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowDown': {
|
||||
const newIndex = focused + 1;
|
||||
newIndex < options.length && setFocused(newIndex);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowUp': {
|
||||
const newIndex = focused - 1;
|
||||
newIndex > -1 && setFocused(newIndex);
|
||||
break;
|
||||
}
|
||||
|
||||
// no default
|
||||
}
|
||||
},
|
||||
[onChange, options, showMenu, setShowMenu, setFocused, focused, selected]
|
||||
);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setShowMenu(false);
|
||||
}, [setShowMenu]);
|
||||
const [selected, setSelected] = useState();
|
||||
const [datePickerValue, setDatePickerValue] = useState();
|
||||
|
||||
// Reset the state if the prop value changes
|
||||
useEffect(() => {
|
||||
@ -85,25 +38,219 @@ export default function Select({ label, onChange, options: inputOptions = [], se
|
||||
// DO NOT include `selected`
|
||||
}, [options, propSelected]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<TextField
|
||||
inputRef={ref}
|
||||
label={label}
|
||||
onchange={onChange}
|
||||
onclick={handleClick}
|
||||
onkeydown={handleKeydown}
|
||||
readonly
|
||||
trailingIcon={showMenu ? ArrowDropup : ArrowDropdown}
|
||||
value={options[selected]?.label}
|
||||
/>
|
||||
{showMenu ? (
|
||||
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref} widthRelative>
|
||||
{options.map(({ value, label }, i) => (
|
||||
<MenuItem key={value} label={label} focus={focused === i} onSelect={handleSelect} value={value} />
|
||||
))}
|
||||
</Menu>
|
||||
) : null}
|
||||
</Fragment>
|
||||
useEffect(() => {
|
||||
if (type === 'datepicker') {
|
||||
if ('after' && 'before' in propSelected) {
|
||||
if (!propSelected.before || !propSelected.after) return setDatePickerValue('all');
|
||||
|
||||
for (let i = 0; i < inputOptions.length; i++) {
|
||||
if (
|
||||
inputOptions[i].value &&
|
||||
Object.entries(inputOptions[i].value).sort().toString() === Object.entries(propSelected).sort().toString()
|
||||
) {
|
||||
setDatePickerValue(inputOptions[i]?.label);
|
||||
break;
|
||||
} else {
|
||||
setDatePickerValue(
|
||||
`${new Date(propSelected.after * 1000).toLocaleDateString()} -> ${new Date(
|
||||
propSelected.before * 1000 - 1
|
||||
).toLocaleDateString()}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (type === 'dropdown') {
|
||||
setSelected(
|
||||
Math.max(
|
||||
options.findIndex(({ value }) => Object.values(propSelected).includes(value)),
|
||||
0
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [type, options, inputOptions, propSelected, setSelected]);
|
||||
|
||||
const [focused, setFocused] = useState(null);
|
||||
const [showCalender, setShowCalender] = useState(false);
|
||||
const calenderRef = useRef(null);
|
||||
const ref = useRef(null);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(value) => {
|
||||
setSelected(options.findIndex(({ value }) => Object.values(propSelected).includes(value)));
|
||||
setShowMenu(false);
|
||||
|
||||
//show calender date range picker
|
||||
if (value === 'custom_range') return setShowCalender(true);
|
||||
onChange && onChange(value);
|
||||
},
|
||||
[onChange, options, propSelected, setSelected]
|
||||
);
|
||||
|
||||
const handleDateRange = useCallback(
|
||||
(range) => {
|
||||
onChange && onChange(range);
|
||||
setShowMenu(false);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setShowMenu(true);
|
||||
}, [setShowMenu]);
|
||||
|
||||
const handleKeydownDatePicker = useCallback(
|
||||
(event) => {
|
||||
switch (event.key) {
|
||||
case 'Enter': {
|
||||
if (!showMenu) {
|
||||
setShowMenu(true);
|
||||
setFocused(selected);
|
||||
} else {
|
||||
setSelected(focused);
|
||||
if (options[focused].value === 'custom_range') {
|
||||
setShowMenu(false);
|
||||
return setShowCalender(true);
|
||||
}
|
||||
|
||||
onChange && onChange(options[focused].value);
|
||||
setShowMenu(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowDown': {
|
||||
event.preventDefault();
|
||||
const newIndex = focused + 1;
|
||||
newIndex < options.length && setFocused(newIndex);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowUp': {
|
||||
event.preventDefault();
|
||||
const newIndex = focused - 1;
|
||||
newIndex > -1 && setFocused(newIndex);
|
||||
break;
|
||||
}
|
||||
|
||||
// no default
|
||||
}
|
||||
},
|
||||
[onChange, options, showMenu, setShowMenu, setFocused, focused, selected]
|
||||
);
|
||||
|
||||
const handleKeydown = useCallback(
|
||||
(event) => {
|
||||
switch (event.key) {
|
||||
case 'Enter': {
|
||||
if (!showMenu) {
|
||||
setShowMenu(true);
|
||||
setFocused(selected);
|
||||
} else {
|
||||
setSelected(focused);
|
||||
onChange && onChange({ [paramName]: options[focused].value });
|
||||
setShowMenu(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowDown': {
|
||||
event.preventDefault();
|
||||
const newIndex = focused + 1;
|
||||
newIndex < options.length && setFocused(newIndex);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowUp': {
|
||||
event.preventDefault();
|
||||
const newIndex = focused - 1;
|
||||
newIndex > -1 && setFocused(newIndex);
|
||||
break;
|
||||
}
|
||||
|
||||
// no default
|
||||
}
|
||||
},
|
||||
[onChange, options, showMenu, setShowMenu, setFocused, focused, selected, paramName]
|
||||
);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setShowMenu(false);
|
||||
}, [setShowMenu]);
|
||||
|
||||
const findDOMNodes = (component) => {
|
||||
return (component && (component.base || (component.nodeType === 1 && component))) || null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const addBackDrop = (e) => {
|
||||
if (showCalender && !findDOMNodes(calenderRef.current).contains(e.target)) {
|
||||
setShowCalender(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('click', addBackDrop);
|
||||
|
||||
return function cleanup() {
|
||||
window.removeEventListener('click', addBackDrop);
|
||||
};
|
||||
}, [showCalender]);
|
||||
|
||||
switch (type) {
|
||||
case 'datepicker':
|
||||
return (
|
||||
<Fragment>
|
||||
<DatePicker
|
||||
inputRef={ref}
|
||||
label={label}
|
||||
onchange={onChange}
|
||||
onclick={handleClick}
|
||||
onkeydown={handleKeydownDatePicker}
|
||||
trailingIcon={showMenu ? ArrowDropup : ArrowDropdown}
|
||||
value={datePickerValue}
|
||||
/>
|
||||
{showCalender && (
|
||||
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref}>
|
||||
<Calender onChange={handleDateRange} calenderRef={calenderRef} close={() => setShowCalender(false)} />
|
||||
</Menu>
|
||||
)}
|
||||
{showMenu ? (
|
||||
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref} widthRelative>
|
||||
{options.map(({ value, label }, i) => (
|
||||
<MenuItem key={value} label={label} focus={focused === i} onSelect={handleSelect} value={value} />
|
||||
))}
|
||||
</Menu>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
// case 'dropdown':
|
||||
default:
|
||||
return (
|
||||
<Fragment>
|
||||
<TextField
|
||||
inputRef={ref}
|
||||
label={label}
|
||||
onchange={onChange}
|
||||
onclick={handleClick}
|
||||
onkeydown={handleKeydown}
|
||||
readonly
|
||||
trailingIcon={showMenu ? ArrowDropup : ArrowDropdown}
|
||||
value={options[selected]?.label}
|
||||
/>
|
||||
{showMenu ? (
|
||||
<Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref} widthRelative>
|
||||
{options.map(({ value, label }, i) => (
|
||||
<MenuItem
|
||||
key={value}
|
||||
label={label}
|
||||
focus={focused === i}
|
||||
onSelect={handleSelect}
|
||||
value={{ [paramName]: value }}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -5,21 +5,40 @@ import { fireEvent, render, screen } from '@testing-library/preact';
|
||||
describe('Select', () => {
|
||||
test('on focus, shows a menu', async () => {
|
||||
const handleChange = jest.fn();
|
||||
render(<Select label="Tacos" onChange={handleChange} options={['tacos', 'burritos']} />);
|
||||
render(
|
||||
<Select
|
||||
label="Tacos"
|
||||
type="dropdown"
|
||||
onChange={handleChange}
|
||||
options={['all', 'tacos', 'burritos']}
|
||||
paramName={['dinner']}
|
||||
selected=""
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('textbox'));
|
||||
expect(screen.queryByRole('listbox')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('option', { name: 'all' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('option', { name: 'tacos' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('option', { name: 'burritos' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.queryByRole('option', { name: 'burritos' }));
|
||||
expect(handleChange).toHaveBeenCalledWith('burritos', 'burritos');
|
||||
fireEvent.click(screen.queryByRole('option', { name: 'tacos' }));
|
||||
expect(handleChange).toHaveBeenCalledWith({ dinner: 'tacos' });
|
||||
});
|
||||
|
||||
test('allows keyboard navigation', async () => {
|
||||
const handleChange = jest.fn();
|
||||
render(<Select label="Tacos" onChange={handleChange} options={['tacos', 'burritos']} />);
|
||||
render(
|
||||
<Select
|
||||
label="Tacos"
|
||||
type="dropdown"
|
||||
onChange={handleChange}
|
||||
options={['tacos', 'burritos']}
|
||||
paramName={['dinner']}
|
||||
selected=""
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
const input = screen.getByRole('textbox');
|
||||
@ -29,6 +48,6 @@ describe('Select', () => {
|
||||
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown', code: 'ArrowDown' });
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||
expect(handleChange).toHaveBeenCalledWith('burritos', 'burritos');
|
||||
expect(handleChange).toHaveBeenCalledWith({ dinner: 'burritos' });
|
||||
});
|
||||
});
|
||||
|
@ -18,7 +18,8 @@ export const useSearchString = (limit, searchParams) => {
|
||||
const removeDefaultSearchKeys = useCallback((searchParams) => {
|
||||
searchParams.delete('limit');
|
||||
searchParams.delete('include_thumbnails');
|
||||
searchParams.delete('before');
|
||||
// removed deletion of "before" as its used by DatePicker
|
||||
// searchParams.delete('before');
|
||||
}, []);
|
||||
|
||||
return { searchString, setSearchString, removeDefaultSearchKeys };
|
||||
|
18
web/src/icons/ArrowLeft.jsx
Normal file
18
web/src/icons/ArrowLeft.jsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { h } from 'preact';
|
||||
import { memo } from 'preact/compat';
|
||||
|
||||
export function ArrowLeft({ className = '' }) {
|
||||
return (
|
||||
<svg
|
||||
className={`fill-current ${className}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 0c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm-1.218 19l-1.782-1.75 5.25-5.25-5.25-5.25 1.782-1.75 6.968 7-6.968 7z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ArrowLeft);
|
12
web/src/icons/ArrowRight.jsx
Normal file
12
web/src/icons/ArrowRight.jsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { h } from 'preact';
|
||||
import { memo } from 'preact/compat';
|
||||
|
||||
export function ArrowRight({ className = '' }) {
|
||||
return (
|
||||
<svg className={`fill-current ${className}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M5 3l3.057-3 11.943 12-11.943 12-3.057-3 9-9z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ArrowRight);
|
12
web/src/icons/ArrowRightDouble.jsx
Normal file
12
web/src/icons/ArrowRightDouble.jsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { h } from 'preact';
|
||||
import { memo } from 'preact/compat';
|
||||
|
||||
export function ArrowRightDouble({ className = '' }) {
|
||||
return (
|
||||
<svg className={`fill-current ${className}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M0 3.795l2.995-2.98 11.132 11.185-11.132 11.186-2.995-2.981 8.167-8.205-8.167-8.205zm18.04 8.205l-8.167 8.205 2.995 2.98 11.132-11.185-11.132-11.186-2.995 2.98 8.167 8.206z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ArrowRightDouble);
|
@ -1,31 +1,26 @@
|
||||
import { h } from 'preact';
|
||||
import Select from '../../../components/Select';
|
||||
import { useCallback, useMemo } from 'preact/hooks';
|
||||
import { useCallback } from 'preact/hooks';
|
||||
|
||||
const Filter = ({ onChange, searchParams, paramName, options }) => {
|
||||
function Filter({ onChange, searchParams, paramName, options, ...rest }) {
|
||||
const handleSelect = useCallback(
|
||||
(key) => {
|
||||
const newParams = new URLSearchParams(searchParams.toString());
|
||||
if (key !== 'all') {
|
||||
newParams.set(paramName, key);
|
||||
} else {
|
||||
newParams.delete(paramName);
|
||||
}
|
||||
Object.keys(key).map((entries) => {
|
||||
if (key[entries] !== 'all') {
|
||||
newParams.set(entries, key[entries]);
|
||||
} else {
|
||||
paramName.map((p) => newParams.delete(p));
|
||||
}
|
||||
});
|
||||
|
||||
onChange(newParams);
|
||||
},
|
||||
[searchParams, paramName, onChange]
|
||||
);
|
||||
|
||||
const selectOptions = useMemo(() => ['all', ...options], [options]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
label={`${paramName.charAt(0).toUpperCase()}${paramName.substr(1)}`}
|
||||
onChange={handleSelect}
|
||||
options={selectOptions}
|
||||
selected={searchParams.get(paramName) || 'all'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const obj = {};
|
||||
paramName.map((name) => Object.assign(obj, { [name]: searchParams.get(name) }), [searchParams]);
|
||||
return <Select onChange={handleSelect} options={options} selected={obj} paramName={paramName} {...rest} />;
|
||||
}
|
||||
export default Filter;
|
||||
|
@ -3,7 +3,13 @@ import { useCallback, useMemo } from 'preact/hooks';
|
||||
import Link from '../../../components/Link';
|
||||
import { route } from 'preact-router';
|
||||
|
||||
const Filterable = ({ onFilter, pathname, searchParams, paramName, name, removeDefaultSearchKeys }) => {
|
||||
function Filterable({ onFilter, pathname, searchParams, paramName, name }) {
|
||||
const removeDefaultSearchKeys = useCallback((searchParams) => {
|
||||
searchParams.delete('limit');
|
||||
searchParams.delete('include_thumbnails');
|
||||
// searchParams.delete('before');
|
||||
}, []);
|
||||
|
||||
const href = useMemo(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set(paramName, name);
|
||||
@ -27,6 +33,6 @@ const Filterable = ({ onFilter, pathname, searchParams, paramName, name, removeD
|
||||
{name}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default Filterable;
|
||||
|
@ -1,11 +1,13 @@
|
||||
import { h } from 'preact';
|
||||
import Filter from './filter';
|
||||
import { useConfig } from '../../../api';
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import { useMemo, useState } from 'preact/hooks';
|
||||
import { DateFilterOptions } from '../../../components/DatePicker';
|
||||
import Button from '../../../components/Button';
|
||||
|
||||
const Filters = ({ onChange, searchParams }) => {
|
||||
const [viewFilters, setViewFilters] = useState(false);
|
||||
const { data } = useConfig();
|
||||
|
||||
const cameras = useMemo(() => Object.keys(data.cameras), [data]);
|
||||
|
||||
const zones = useMemo(
|
||||
@ -27,12 +29,52 @@ const Filters = ({ onChange, searchParams }) => {
|
||||
}, data.objects?.track || [])
|
||||
.filter((value, i, self) => self.indexOf(value) === i);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div className="flex space-x-4">
|
||||
<Filter onChange={onChange} options={cameras} paramName="camera" searchParams={searchParams} />
|
||||
<Filter onChange={onChange} options={zones} paramName="zone" searchParams={searchParams} />
|
||||
<Filter onChange={onChange} options={labels} paramName="label" searchParams={searchParams} />
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => setViewFilters(!viewFilters)}
|
||||
className="block xs:hidden w-full mb-4 text-center"
|
||||
type="text"
|
||||
>
|
||||
{`${viewFilters ? 'Hide Filter' : 'Filter'}`}
|
||||
</Button>
|
||||
<div className={`xs:flex space-y-1 xs:space-y-0 xs:space-x-4 ${viewFilters ? 'flex-col' : 'hidden'}`}>
|
||||
<Filter
|
||||
type="dropdown"
|
||||
onChange={onChange}
|
||||
options={['all', ...cameras]}
|
||||
paramName={['camera']}
|
||||
label="Camera"
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
|
||||
<Filter
|
||||
type="dropdown"
|
||||
onChange={onChange}
|
||||
options={['all', ...zones]}
|
||||
paramName={['zone']}
|
||||
label="Zone"
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
|
||||
<Filter
|
||||
type="dropdown"
|
||||
onChange={onChange}
|
||||
options={['all', ...labels]}
|
||||
paramName={['label']}
|
||||
label="Label"
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
|
||||
<Filter
|
||||
type="datepicker"
|
||||
onChange={onChange}
|
||||
options={DateFilterOptions}
|
||||
paramName={['before', 'after']}
|
||||
label="DatePicker"
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user