Переглянути джерело

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
Bernt Christian Egeland 3 роки тому
батько
коміт
a10970d7c9

+ 329 - 0
web/src/components/Calender.jsx

@@ -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 - 0
web/src/components/DatePicker.jsx

@@ -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>
+  );
+}

+ 7 - 6
web/src/components/RelativeModal.jsx

@@ -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;

+ 191 - 44
web/src/components/Select.jsx

@@ -3,39 +3,103 @@ 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(
+  const [selected, setSelected] = useState();
+  const [datePickerValue, setDatePickerValue] = useState();
+
+  // Reset the state if the prop value changes
+  useEffect(() => {
+    const selectedIndex = Math.max(
       options.findIndex(({ value }) => value === propSelected),
       0
-    )
-  );
-  const [focused, setFocused] = useState(null);
+    );
+    if (propSelected && selectedIndex !== selected) {
+      setSelected(selectedIndex);
+      setFocused(selectedIndex);
+    }
+    // DO NOT include `selected`
+  }, [options, propSelected]); // eslint-disable-line react-hooks/exhaustive-deps
+
+  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, label) => {
-      setSelected(options.findIndex((opt) => opt.value === value));
-      onChange && onChange(value, label);
+    (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, options]
+    [onChange]
   );
 
   const handleClick = useCallback(() => {
     setShowMenu(true);
   }, [setShowMenu]);
 
-  const handleKeydown = useCallback(
+  const handleKeydownDatePicker = useCallback(
     (event) => {
       switch (event.key) {
         case 'Enter': {
@@ -44,19 +108,26 @@ export default function Select({ label, onChange, options: inputOptions = [], se
             setFocused(selected);
           } else {
             setSelected(focused);
-            onChange && onChange(options[focused].value, options[focused].label);
+            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;
@@ -68,42 +139,118 @@ export default function Select({ label, onChange, options: inputOptions = [], se
     [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]);
 
-  // Reset the state if the prop value changes
+  const findDOMNodes = (component) => {
+    return (component && (component.base || (component.nodeType === 1 && component))) || null;
+  };
+
   useEffect(() => {
-    const selectedIndex = Math.max(
-      options.findIndex(({ value }) => value === propSelected),
-      0
-    );
-    if (propSelected && selectedIndex !== selected) {
-      setSelected(selectedIndex);
-      setFocused(selectedIndex);
-    }
-    // DO NOT include `selected`
-  }, [options, propSelected]); // eslint-disable-line react-hooks/exhaustive-deps
+    const addBackDrop = (e) => {
+      if (showCalender && !findDOMNodes(calenderRef.current).contains(e.target)) {
+        setShowCalender(false);
+      }
+    };
+    window.addEventListener('click', addBackDrop);
 
-  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>
-  );
+    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>
+      );
+  }
 }

+ 24 - 5
web/src/components/__tests__/Select.test.jsx

@@ -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' });
   });
 });

+ 2 - 1
web/src/hooks/useSearchString.jsx

@@ -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 - 0
web/src/icons/ArrowLeft.jsx

@@ -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 - 0
web/src/icons/ArrowRight.jsx

@@ -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 - 0
web/src/icons/ArrowRightDouble.jsx

@@ -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);

+ 13 - 18
web/src/routes/Events/components/filter.jsx

@@ -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;

+ 8 - 2
web/src/routes/Events/components/filterable.jsx

@@ -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;

+ 49 - 7
web/src/routes/Events/components/filters.jsx

@@ -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>
   );
 };