diff --git a/src/components/CustomDropdown/index.tsx b/src/components/CustomDropdown/index.tsx index f5ab5fbf..4dbe58e9 100644 --- a/src/components/CustomDropdown/index.tsx +++ b/src/components/CustomDropdown/index.tsx @@ -1,6 +1,13 @@ -import React, { ReactNode } from "react"; +import React, { + KeyboardEventHandler, + ReactNode, + useEffect, + useRef, + useState, +} from "react"; import { Dropdown, DropDownProps, MenuProps } from "antd"; -import { ButtonClass } from "../../constants/interfaces"; +import { ButtonClass, DropdownState } from "../../constants/interfaces"; +import { DROPDOWN_HOVER_DELAY } from "../../constants"; import NavButton from "../NavButton"; import styles from "./style.css"; @@ -22,16 +29,112 @@ const CustomDropdown: React.FC = ({ placement, disabled, }) => { + const [dropdownState, setDropdownState] = useState( + DropdownState.CLOSED + ); + + const triggerRef = useRef(null); + const dropdownRef = useRef(null); + const closeTimeoutRef = useRef(null); + + /** + * Prevents the menu wrapper from capturing focus, + * this prevents losing focus to the body + * when "Escape" is pressed. + */ + useEffect(() => { + const element = dropdownRef.current; + if (element) { + const menuElement = element.querySelector( + ".ant-dropdown-menu" + ) as HTMLElement; + if (menuElement) { + if (dropdownState === DropdownState.FORCED_OPEN) { + menuElement.setAttribute("tabIndex", "-1"); + } else if (dropdownState === DropdownState.CLOSED) { + menuElement.setAttribute("tabIndex", "0"); + } + } + } + }, [dropdownRef, dropdownState]); + + /** + * Manually handling keydown and hover behavior because + * our focus management overrides the defaults of the antd components. + */ + const openTriggers = new Set(["Enter", " ", "ArrowDown"]); + + const handleKeyDown: KeyboardEventHandler = (event) => { + if (event.key === "Escape") { + event.preventDefault(); + setDropdownState(DropdownState.CLOSED); + triggerRef.current?.focus(); + } + if ( + openTriggers.has(event.key) && + dropdownState !== DropdownState.FORCED_OPEN + ) { + event.preventDefault(); + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + } + setDropdownState(DropdownState.FORCED_OPEN); // Opened by keyboard + } + }; + + const handleMouseEnter = () => { + if (dropdownState === DropdownState.CLOSED) { + setDropdownState(DropdownState.OPEN); + } + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + } + }; + + const handleMouseLeaveWithDelay = () => { + if (dropdownState !== DropdownState.FORCED_OPEN) { + closeTimeoutRef.current = setTimeout(() => { + setDropdownState(DropdownState.CLOSED); + }, DROPDOWN_HOVER_DELAY); + } + }; + + const buttonClickHandler = () => { + setDropdownState( + dropdownState === DropdownState.CLOSED + ? DropdownState.OPEN + : DropdownState.CLOSED + ); + }; + return ( ( +
+ {menu} +
+ )} > } titleText={titleText} icon={icon} buttonType={buttonType} + clickHandler={buttonClickHandler} + onKeyDown={handleKeyDown} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeaveWithDelay} />
); diff --git a/src/components/NavButton/index.tsx b/src/components/NavButton/index.tsx index b6e7fe71..f989bbb2 100644 --- a/src/components/NavButton/index.tsx +++ b/src/components/NavButton/index.tsx @@ -1,7 +1,6 @@ -import React, { ReactNode } from "react"; +import React, { ReactNode, forwardRef } from "react"; import { Button, ButtonProps } from "antd"; import classNames from "classnames"; - import styles from "./style.css"; import { ButtonClass } from "../../constants/interfaces"; @@ -13,32 +12,39 @@ export interface NavButtonProps extends ButtonProps { isDisabled?: boolean; } -const NavButton: React.FC = ({ - className, - titleText, - buttonType = ButtonClass.Action, - icon, - clickHandler, - isDisabled, - ...props -}) => { - // NavButtons default to action button styling, provide secondary or primary to override - const buttonClassNames = classNames( - className, - styles.navButton, - styles[buttonType], - { [styles.disabled]: isDisabled } - ); +const NavButton = forwardRef( + ( + { + className, + titleText, + buttonType = ButtonClass.Action, + icon, + clickHandler, + isDisabled, + ...props + }, + ref + ) => { + const buttonClassNames = classNames( + className, + styles.navButton, + styles[buttonType], + { [styles.disabled]: isDisabled } + ); + + return ( + + ); + } +); - return ( - - ); -}; +NavButton.displayName = "NavButton"; export default NavButton; diff --git a/src/components/RecordMoviesComponent/index.tsx b/src/components/RecordMoviesComponent/index.tsx index 294e1141..056a5c8d 100644 --- a/src/components/RecordMoviesComponent/index.tsx +++ b/src/components/RecordMoviesComponent/index.tsx @@ -63,14 +63,14 @@ const RecordMovieComponent = (props: RecordMovieComponentProps) => { * In this icon we are stacking glyphs to create multicolor icons via icomoon */ const startRecordingIcon = ( - +
- +
); const activeRecordingIcon = ( @@ -81,7 +81,11 @@ const RecordMovieComponent = (props: RecordMovieComponentProps) => { if (!isRecording) { return startRecordingIcon; } else if (isHovering) { - return "stop-record-icon"; + return classNames( + styles.iconContainer, + "icon-moon", + "stop-record-icon" + ); } else return activeRecordingIcon; }; diff --git a/src/components/RecordMoviesComponent/style.css b/src/components/RecordMoviesComponent/style.css index 660501af..660d5161 100644 --- a/src/components/RecordMoviesComponent/style.css +++ b/src/components/RecordMoviesComponent/style.css @@ -5,6 +5,8 @@ } .icon-container { + display: flex; + align-items: center; height: 100%; width: 100%; } @@ -22,18 +24,18 @@ } @keyframes pulse-red { - 0% { - transform: scale(0.95); - box-shadow: 0 0 0 0 #FF5252B3; - } - 70% { - transform: scale(1); - box-shadow: 0 0 0 10px #FF525200; - } - 100% { - transform: scale(0.95); - box-shadow: 0 0 0 0 #FF525200; - } + 0% { + transform: scale(0.95); + box-shadow: 0 0 0 0 #ff5252b3; + } + 70% { + transform: scale(1); + box-shadow: 0 0 0 10px #ff525200; + } + 100% { + transform: scale(0.95); + box-shadow: 0 0 0 0 #ff525200; + } } .status-container { diff --git a/src/components/SideBarContents/style.css b/src/components/SideBarContents/style.css index 8f286278..62a43230 100644 --- a/src/components/SideBarContents/style.css +++ b/src/components/SideBarContents/style.css @@ -2,7 +2,6 @@ display: flex; flex-direction: column; height: 100%; - overflow-y: auto; } .container label { diff --git a/src/constants/index.ts b/src/constants/index.ts index 273bf714..9e57f406 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -69,3 +69,4 @@ export const MAX_CONVERSION_FILE_SIZE = 2e8; // 200 MB export const CONTROLS_MIN_WIDTH = 650; export const CONTROLS_MIN_HEIGHT = 320; export const SCALE_BAR_MIN_WIDTH = 550; +export const DROPDOWN_HOVER_DELAY = 300; diff --git a/src/constants/interfaces.ts b/src/constants/interfaces.ts index 1abd73b2..16e71b44 100644 --- a/src/constants/interfaces.ts +++ b/src/constants/interfaces.ts @@ -92,3 +92,9 @@ export interface ColorChange { agent: SelectionEntry; color: string; } + +export enum DropdownState { + OPEN = "open", + CLOSED = "closed", + FORCED_OPEN = "forced_open", +} diff --git a/src/util/index.ts b/src/util/index.ts index 7ef53da9..83a1f9b7 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -107,6 +107,14 @@ export const roundToTimeStepPrecision = ( return Math.round(input * multiplier) / multiplier; }; +/** +Compare two instaces of UIDisplayData to see if they have the same agents +and display states. +This data structure is used to store different color settings. +We don't want to ever try and apply the color settings from one trajectory +to another, even if by chance they shared the same file name, or other +metadata. +*/ export const isSameAgentTree = (a: UIDisplayData, b: UIDisplayData) => { if (a.length !== b.length) { return false;