diff --git a/client/components/Dropdown/DropdownMenu.jsx b/client/components/Dropdown/DropdownMenu.jsx index 3371d1d6cc..6d5d3d362a 100644 --- a/client/components/Dropdown/DropdownMenu.jsx +++ b/client/components/Dropdown/DropdownMenu.jsx @@ -1,19 +1,61 @@ import PropTypes from 'prop-types'; -import React, { forwardRef, useCallback, useRef, useState } from 'react'; +import React, { + forwardRef, + useCallback, + useRef, + useState, + useEffect +} from 'react'; +import styled from 'styled-components'; import useModalClose from '../../common/useModalClose'; import DownArrowIcon from '../../images/down-filled-triangle.svg'; +// Import MenuItem directly instead of from Dropdown index +import MenuItem from './MenuItem'; import { DropdownWrapper } from '../Dropdown'; -// TODO: enable arrow keys to navigate options from list +// Now MenuItem is available for styling +const StyledMenuItem = styled(MenuItem)` + /* Remove ALL outlines and focus styles */ + outline: none !important; + &:focus { + outline: none !important; + box-shadow: none !important; + } + &:focus-visible { + outline: none !important; + } + + /* Single source of truth for selection styling */ + &[data-selected='true'] { + background-color: ${({ theme }) => theme.colors.golden}; + outline: 2px solid ${({ theme }) => theme.colors.primary} !important; + outline-offset: -2px; + position: relative; + z-index: 1; + } + + /* Only show hover effect when not selected */ + &:hover:not([data-selected='true']) { + background-color: ${({ theme }) => theme.colors.golden}; + } +`; + +const StyledButton = styled.button` + border: none; + background: none; + padding: 0; + cursor: pointer; + + &:focus { + outline: 2px solid ${({ theme }) => theme.colors.primary}; // Changed from button.active + } +`; const DropdownMenu = forwardRef( - ( - { children, anchor, 'aria-label': ariaLabel, align, className, classes }, - ref - ) => { - // Note: need to use a ref instead of a state to avoid stale closures. + ({ items, anchor, 'aria-label': ariaLabel, align, className }, ref) => { const focusedRef = useRef(false); - + const menuRef = useRef(null); + const [activeIndex, setActiveIndex] = useState(-1); const [isOpen, setIsOpen] = useState(false); const close = useCallback(() => setIsOpen(false), [setIsOpen]); @@ -37,10 +79,171 @@ const DropdownMenu = forwardRef( }, 200); }; + useEffect(() => { + if (isOpen) { + const menuItems = menuRef.current?.querySelectorAll( + '[role="menuitem"]' + ); + if (menuItems?.length) { + setActiveIndex(-1); // Start with no active item + } + } + }, [isOpen]); + + const handleItemAction = useCallback( + (itemData) => { + if (itemData.href) return; + + if (itemData.onClick) { + try { + itemData.onClick(); + } catch (err) { + console.error('Error executing onClick:', err); + } + } + close(); + }, + [close] + ); + + const focusItem = useCallback((idx) => { + const menuItems = menuRef.current?.querySelectorAll('[role="menuitem"]'); + if (!menuItems?.length) return; + + const item = menuItems[idx]; + if (item) { + // Set active first, then focus + setActiveIndex(idx); + requestAnimationFrame(() => { + item.focus({ preventScroll: true }); + }); + } + }, []); + + const handleKeyDown = useCallback( + (e) => { + if (!isOpen) return undefined; + + // Get only visible items (not hidden by hideIf) + const visibleItems = items.filter((item) => !item.hideIf); + const maxIndex = visibleItems.length - 1; + + const menuItems = menuRef.current?.querySelectorAll( + '[role="menuitem"]' + ); + if (!menuItems?.length) return undefined; + + switch (e.key) { + case 'ArrowDown': { + e.preventDefault(); + e.stopPropagation(); + // Only increment if we're not at the end + if (activeIndex === -1) { + focusItem(0); + } else if (activeIndex < maxIndex) { + focusItem(activeIndex + 1); + } + return undefined; + } + + case 'ArrowUp': { + e.preventDefault(); + e.stopPropagation(); + // Only decrement if we're not at the start + if (activeIndex === -1) { + focusItem(maxIndex); + } else if (activeIndex > 0) { + focusItem(activeIndex - 1); + } + return undefined; + } + + case 'Enter': + case ' ': { + e.preventDefault(); + e.stopPropagation(); + if (activeIndex >= 0 && activeIndex < items.length) { + const currentItem = items[activeIndex]; + if (!currentItem.hideIf) { + handleItemAction(currentItem); + } + } + return undefined; + } + + case 'Home': { + e.preventDefault(); + focusItem(0); + return undefined; + } + + case 'End': { + e.preventDefault(); + focusItem(menuItems.length - 1); + return undefined; + } + + case 'Escape': { + e.preventDefault(); + close(); + return undefined; + } + + default: + return undefined; + } + }, + [isOpen, close, activeIndex, handleItemAction, items, focusItem] + ); + + useEffect(() => { + console.log('activeIndex changed:', activeIndex); + }, [activeIndex]); + + useEffect(() => { + if (!isOpen) { + setActiveIndex(-1); + return; + } + + document.addEventListener('keydown', handleKeyDown, true); + + // eslint-disable-next-line consistent-return + return () => document.removeEventListener('keydown', handleKeyDown, true); + }, [isOpen, handleKeyDown]); + + const renderMenuItem = (item, index) => { + if (item.hideIf) return null; + + const itemProps = item.href + ? { + as: 'a', + href: item.href, + target: item.target, + rel: item.target === '_blank' ? 'noopener noreferrer' : undefined, + onClick: () => close() + } + : { + onClick: () => handleItemAction(item) + }; + + return ( + e.stopPropagation()} + role="menuitem" + tabIndex="-1" + data-selected={index === activeIndex} + > + {item.name} + + ); + }; + return (
- + {isOpen && ( { setTimeout(close, 0); @@ -60,7 +263,7 @@ const DropdownMenu = forwardRef( onBlur={handleBlur} onFocus={handleFocus} > - {children} + {items.map(renderMenuItem)} )}
@@ -69,29 +272,25 @@ const DropdownMenu = forwardRef( ); DropdownMenu.propTypes = { - /** - * Provide elements as children to control the contents of the menu. - */ - children: PropTypes.node.isRequired, - /** - * Can optionally override the contents of the button which opens the menu. - * Defaults to - */ + items: PropTypes.arrayOf( + PropTypes.shape({ + name: PropTypes.string.isRequired, + onClick: PropTypes.func, + href: PropTypes.string, + target: PropTypes.string, + hideIf: PropTypes.bool + }).isRequired + ).isRequired, anchor: PropTypes.node, 'aria-label': PropTypes.string.isRequired, align: PropTypes.oneOf(['left', 'right']), - className: PropTypes.string, - classes: PropTypes.shape({ - button: PropTypes.string, - list: PropTypes.string - }) + className: PropTypes.string }; DropdownMenu.defaultProps = { anchor: null, align: 'right', - className: '', - classes: {} + className: '' }; export default DropdownMenu; diff --git a/client/modules/IDE/components/AssetListRow.jsx b/client/modules/IDE/components/AssetListRow.jsx index 7e7af8f013..64ca706208 100644 --- a/client/modules/IDE/components/AssetListRow.jsx +++ b/client/modules/IDE/components/AssetListRow.jsx @@ -4,7 +4,6 @@ import { Link } from 'react-router-dom'; import { useDispatch } from 'react-redux'; import { useTranslation } from 'react-i18next'; import prettyBytes from 'pretty-bytes'; -import MenuItem from '../../../components/Dropdown/MenuItem'; import TableDropdown from '../../../components/Dropdown/TableDropdown'; import { deleteAssetRequest } from '../actions/assets'; @@ -19,13 +18,16 @@ const AssetMenu = ({ item: asset }) => { } }; + const items = [ + { name: t('AssetList.Delete'), onClick: handleAssetDelete }, + { name: t('AssetList.OpenNewTab'), href: asset.url, target: '_blank' } + ]; + return ( - - {t('AssetList.Delete')} - - {t('AssetList.OpenNewTab')} - - + ); }; diff --git a/client/modules/IDE/components/CollectionList/CollectionListRow.jsx b/client/modules/IDE/components/CollectionList/CollectionListRow.jsx index c646b2dcb7..16f0514657 100644 --- a/client/modules/IDE/components/CollectionList/CollectionListRow.jsx +++ b/client/modules/IDE/components/CollectionList/CollectionListRow.jsx @@ -5,7 +5,6 @@ import { Link } from 'react-router-dom'; import { bindActionCreators } from 'redux'; import { withTranslation } from 'react-i18next'; import styled from 'styled-components'; -import MenuItem from '../../../../components/Dropdown/MenuItem'; import TableDropdown from '../../../../components/Dropdown/TableDropdown'; import * as ProjectActions from '../../actions/project'; import * as CollectionsActions from '../../actions/collections'; @@ -157,20 +156,28 @@ const CollectionListRowBase = (props) => { const renderActions = () => { const userIsOwner = props.user.username === props.username; + const items = [ + { + name: props.t('CollectionListRow.AddSketch'), + onClick: handleAddSketches + }, + { + name: props.t('CollectionListRow.Delete'), + hideIf: !userIsOwner, + onClick: handleCollectionDelete + }, + { + name: props.t('CollectionListRow.Rename'), + hideIf: !userIsOwner, + onClick: handleRenameOpen + } + ]; + return ( - - {props.t('CollectionListRow.AddSketch')} - - - {props.t('CollectionListRow.Delete')} - - - {props.t('CollectionListRow.Rename')} - - + /> ); }; diff --git a/client/modules/IDE/components/SketchListRowBase.jsx b/client/modules/IDE/components/SketchListRowBase.jsx index 5c00015fdc..0f973d1371 100644 --- a/client/modules/IDE/components/SketchListRowBase.jsx +++ b/client/modules/IDE/components/SketchListRowBase.jsx @@ -7,7 +7,6 @@ import { connect } from 'react-redux'; import * as ProjectActions from '../actions/project'; import * as IdeActions from '../actions/ide'; import TableDropdown from '../../../components/Dropdown/TableDropdown'; -import MenuItem from '../../../components/Dropdown/MenuItem'; import dates from '../../../utils/formatDate'; import getConfig from '../../../utils/getConfig'; @@ -100,32 +99,43 @@ const SketchListRowBase = ({ ); + const items = [ + { + name: t('SketchList.DropdownRename'), + onClick: openRename, + hideIf: !userIsOwner + }, + { + name: t('SketchList.DropdownDownload'), + onClick: handleSketchDownload + }, + { + name: t('SketchList.DropdownDuplicate'), + onClick: handleSketchDuplicate, + hideIf: !user.authenticated + }, + { + name: t('SketchList.DropdownAddToCollection'), + onClick: onAddToCollection, + hideIf: !user.authenticated + }, + { + name: t('SketchList.DropdownDelete'), + onClick: handleSketchDelete, + hideIf: !userIsOwner + } + ]; + return ( {name} {formatDateCell(sketch.createdAt, mobile)} {formatDateCell(sketch.updatedAt, mobile)} - - - {t('SketchList.DropdownRename')} - - - {t('SketchList.DropdownDownload')} - - - {t('SketchList.DropdownDuplicate')} - - - {t('SketchList.DropdownAddToCollection')} - - - {t('SketchList.DropdownDelete')} - - + );