diff --git a/client/common/ButtonOrLink.tsx b/client/common/ButtonOrLink.tsx index 6ae5e2187c..ce1ac5f1ed 100644 --- a/client/common/ButtonOrLink.tsx +++ b/client/common/ButtonOrLink.tsx @@ -4,7 +4,7 @@ import { Link } from 'react-router-dom'; /** * Accepts all the props of an HTML or - {isOpen && ( - { - setTimeout(close, 0); - }} - onBlur={handleBlur} - onFocus={handleFocus} - style={maxHeight && { maxHeight, overflowY: 'auto' }} - > - {children} - - )} - - ); - } -); - -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 - */ - 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 - }), - maxHeight: PropTypes.string -}; - -DropdownMenu.defaultProps = { - anchor: null, - align: 'right', - className: '', - classes: {}, - maxHeight: undefined -}; - -export default DropdownMenu; diff --git a/client/components/Dropdown/DropdownMenu.tsx b/client/components/Dropdown/DropdownMenu.tsx new file mode 100644 index 0000000000..3d6e262e2a --- /dev/null +++ b/client/components/Dropdown/DropdownMenu.tsx @@ -0,0 +1,168 @@ +import React, { forwardRef, useCallback, useRef, useState } from 'react'; +import styled from 'styled-components'; +import { remSize, prop } from '../../theme'; +import { useModalClose } from '../../common/useModalClose'; +import DownArrowIcon from '../../images/down-filled-triangle.svg'; + +export enum DropdownMenuAlignment { + RIGHT = 'right', + LEFT = 'left' +} + +interface StyledDropdownMenuProps { + align: DropdownMenuAlignment; +} + +const DropdownWrapper = styled.ul` + background-color: ${prop('Modal.background')}; + border: 1px solid ${prop('Modal.border')}; + box-shadow: 0 0 18px 0 ${prop('shadowColor')}; + color: ${prop('primaryTextColor')}; + + position: absolute; + right: ${(props) => + props.align === DropdownMenuAlignment.RIGHT ? 0 : 'initial'}; + left: ${(props) => + props.align === DropdownMenuAlignment.LEFT ? 0 : 'initial'}; + + text-align: left; + width: ${remSize(180)}; + display: flex; + flex-direction: column; + height: auto; + z-index: 2; + border-radius: ${remSize(6)}; + + & li:first-child { + border-radius: ${remSize(5)} ${remSize(5)} 0 0; + } + & li:last-child { + border-radius: 0 0 ${remSize(5)} ${remSize(5)}; + } + + & li:hover { + background-color: ${prop('Button.primary.hover.background')}; + color: ${prop('Button.primary.hover.foreground')}; + + * { + color: ${prop('Button.primary.hover.foreground')}; + } + } + + li { + height: ${remSize(36)}; + cursor: pointer; + display: flex; + align-items: center; + + & button, + & button span, + & a { + padding: ${remSize(8)} ${remSize(16)}; + font-size: ${remSize(12)}; + } + + * { + text-align: left; + justify-content: left; + + color: ${prop('primaryTextColor')}; + width: 100%; + justify-content: flex-start; + } + + & button span { + padding: 0px; + } + } +`; + +export interface DropdownMenuProps extends StyledDropdownMenuProps { + /** + * Provide elements as children to control the contents of the menu. + */ + children: React.ReactNode; + /** + * Can optionally override the contents of the button which opens the menu. + * Defaults to + */ + anchor?: React.ReactNode; + 'aria-label': string; + className?: string; + classes?: { + button?: string; + list?: string; + }; + maxHeight?: string; +} + +export const DropdownMenu = forwardRef( + ( + { + children, + anchor, + 'aria-label': ariaLabel, + align = DropdownMenuAlignment.RIGHT, + className = '', + classes = {}, + maxHeight + }, + ref + ) => { + // Note: need to use a ref instead of a state to avoid stale closures. + const focusedRef = useRef(false); + + const [isOpen, setIsOpen] = useState(false); + + const close = useCallback(() => setIsOpen(false), [setIsOpen]); + + const anchorRef = useModalClose(close, ref); + + const toggle = useCallback(() => { + setIsOpen((prevState) => !prevState); + }, [setIsOpen]); + + const handleFocus = () => { + focusedRef.current = true; + }; + + const handleBlur = () => { + focusedRef.current = false; + setTimeout(() => { + if (!focusedRef.current) { + // close(); + } + }, 200); + }; + + return ( +
+ + {isOpen && ( + { + setTimeout(close, 0); + }} + onBlur={handleBlur} + onFocus={handleFocus} + style={maxHeight ? { maxHeight, overflowY: 'auto' } : undefined} + > + {children} + + )} +
+ ); + } +); diff --git a/client/components/Dropdown/MenuItem.jsx b/client/components/Dropdown/MenuItem.jsx deleted file mode 100644 index 79349c00cc..0000000000 --- a/client/components/Dropdown/MenuItem.jsx +++ /dev/null @@ -1,35 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { ButtonOrLink } from '../../common/ButtonOrLink'; - -// TODO: combine with NavMenuItem - -function MenuItem({ hideIf, ...rest }) { - if (hideIf) { - return null; - } - - return ( -
  • - -
  • - ); -} - -MenuItem.propTypes = { - ...ButtonOrLink.propTypes, - onClick: PropTypes.func, - value: PropTypes.string, - /** - * Provides a way to deal with optional items. - */ - hideIf: PropTypes.bool -}; - -MenuItem.defaultProps = { - onClick: null, - value: null, - hideIf: false -}; - -export default MenuItem; diff --git a/client/components/Dropdown/MenuItem.tsx b/client/components/Dropdown/MenuItem.tsx new file mode 100644 index 0000000000..401aae3361 --- /dev/null +++ b/client/components/Dropdown/MenuItem.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { ButtonOrLink, ButtonOrLinkProps } from '../../common/ButtonOrLink'; + +// TODO: combine with NavMenuItem + +export interface MenuItemProps extends ButtonOrLinkProps { + /** + * Provides a way to deal with optional items. + */ + hideIf?: boolean; + value?: string; +} + +export function MenuItem({ hideIf = false, ...rest }: MenuItemProps) { + if (hideIf) { + return null; + } + + return ( +
  • + +
  • + ); +} diff --git a/client/components/Dropdown/TableDropdown.jsx b/client/components/Dropdown/TableDropdown.tsx similarity index 77% rename from client/components/Dropdown/TableDropdown.jsx rename to client/components/Dropdown/TableDropdown.tsx index 44f4f27fd6..e9408dd6b9 100644 --- a/client/components/Dropdown/TableDropdown.jsx +++ b/client/components/Dropdown/TableDropdown.tsx @@ -1,7 +1,11 @@ import React from 'react'; import styled from 'styled-components'; import { prop, remSize } from '../../theme'; -import DropdownMenu from './DropdownMenu'; +import { + DropdownMenu, + DropdownMenuProps, + DropdownMenuAlignment +} from './DropdownMenu'; import DownFilledTriangleIcon from '../../images/down-filled-triangle.svg'; import MoreIconSvg from '../../images/more.svg'; @@ -20,8 +24,10 @@ const TableDropdownIcon = () => { ); }; -const TableDropdown = styled(DropdownMenu).attrs({ - align: 'right', +export interface TableDropdownProps extends DropdownMenuProps {} + +export const TableDropdown = styled(DropdownMenu).attrs({ + align: DropdownMenuAlignment.RIGHT, anchor: })` & > button { @@ -42,5 +48,3 @@ const TableDropdown = styled(DropdownMenu).attrs({ right: calc(100% - 26px); } `; - -export default TableDropdown; diff --git a/client/components/Menubar/Menubar.test.jsx b/client/components/Menubar/Menubar.test.tsx similarity index 98% rename from client/components/Menubar/Menubar.test.jsx rename to client/components/Menubar/Menubar.test.tsx index 0f78d2f547..3f5b3871fc 100644 --- a/client/components/Menubar/Menubar.test.jsx +++ b/client/components/Menubar/Menubar.test.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { render, screen, fireEvent } from '../../test-utils'; -import Menubar from './Menubar'; +import { Menubar } from './Menubar'; import MenubarSubmenu from './MenubarSubmenu'; -import MenubarItem from './MenubarItem'; +import { MenubarItem } from './MenubarItem'; describe('Menubar', () => { const renderMenubar = () => { diff --git a/client/components/Menubar/Menubar.jsx b/client/components/Menubar/Menubar.tsx similarity index 84% rename from client/components/Menubar/Menubar.jsx rename to client/components/Menubar/Menubar.tsx index 3cceea48bb..f2244ad4f9 100644 --- a/client/components/Menubar/Menubar.jsx +++ b/client/components/Menubar/Menubar.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React, { useCallback, useMemo, @@ -14,10 +13,6 @@ import { usePrevious } from '../../common/usePrevious'; * Menubar manages a collection of menu items and their submenus. It provides keyboard navigation, * focus and state management, and other accessibility features for the menu items and submenus. * - * @param {React.ReactNode} props.children - Menu items that will be rendered in the menubar - * @param {string} [props.className='nav__menubar'] - CSS class name to apply to the menubar - * @returns {JSX.Element} - * * @example * * @@ -26,16 +21,26 @@ import { usePrevious } from '../../common/usePrevious'; * */ -function Menubar({ children, className }) { - const [menuOpen, setMenuOpen] = useState('none'); - const [activeIndex, setActiveIndex] = useState(0); - const prevIndex = usePrevious(activeIndex); - const [hasFocus, setHasFocus] = useState(false); +export interface MenubarProps { + /** Menu items that will be rendered in the menubar */ + children?: React.ReactNode; + /** CSS class name to apply to the menubar */ + className?: string; +} + +export function Menubar({ + children, + className = 'nav__menubar' +}: MenubarProps) { + const [menuOpen, setMenuOpen] = useState('none'); + const [activeIndex, setActiveIndex] = useState(0); + const prevIndex = usePrevious(activeIndex); + const [hasFocus, setHasFocus] = useState(false); - const menuItems = useRef(new Set()).current; + const menuItems = useRef>(new Set()).current; const menuItemToId = useRef(new Map()).current; - const timerRef = useRef(null); + const timerRef = useRef | null>(null); const getMenuId = useCallback( (index) => { @@ -85,7 +90,7 @@ function Menubar({ children, className }) { const toggleMenuOpen = useCallback((id) => { setMenuOpen((prevState) => (prevState === id ? 'none' : id)); - }); + }, []); const registerTopLevelItem = useCallback( (ref, submenuId) => { @@ -105,7 +110,7 @@ function Menubar({ children, className }) { ); const clearHideTimeout = useCallback(() => { - if (timerRef.current) { + if (timerRef.current !== null) { clearTimeout(timerRef.current); timerRef.current = null; } @@ -116,7 +121,7 @@ function Menubar({ children, className }) { setMenuOpen('none'); }, [setMenuOpen]); - const nodeRef = useModalClose(handleClose); + const nodeRef = useModalClose(handleClose); const handleFocus = useCallback(() => { setHasFocus(true); @@ -138,7 +143,7 @@ function Menubar({ children, className }) { [nodeRef] ); - const keyHandlers = { + const keyHandlers: Record void> = { ArrowLeft: (e) => { e.preventDefault(); e.stopPropagation(); @@ -173,8 +178,11 @@ function Menubar({ children, className }) { useEffect(() => { if (activeIndex !== prevIndex) { const items = Array.from(menuItems); + const prevNode = + prevIndex != null /** check against undefined or null */ + ? items[prevIndex] + : undefined; const activeNode = items[activeIndex]; - const prevNode = items[prevIndex]; prevNode?.setAttribute('tabindex', '-1'); activeNode?.setAttribute('tabindex', '0'); @@ -191,7 +199,7 @@ function Menubar({ children, className }) { const contextValue = useMemo( () => ({ - createMenuHandlers: (menu) => ({ + createMenuHandlers: (menu: string) => ({ onMouseOver: () => { setMenuOpen((prevState) => (prevState === 'none' ? 'none' : menu)); }, @@ -210,8 +218,8 @@ function Menubar({ children, className }) { onBlur: handleBlur, onFocus: clearHideTimeout }), - createMenuItemHandlers: (menu) => ({ - onMouseUp: (e) => { + createMenuItemHandlers: (menu: string) => ({ + onMouseUp: (e: React.MouseEvent) => { if (e.button === 2) { return; } @@ -278,15 +286,3 @@ function Menubar({ children, className }) { ); } - -Menubar.propTypes = { - children: PropTypes.node, - className: PropTypes.string -}; - -Menubar.defaultProps = { - children: null, - className: 'nav__menubar' -}; - -export default Menubar; diff --git a/client/components/Menubar/MenubarItem.jsx b/client/components/Menubar/MenubarItem.tsx similarity index 82% rename from client/components/Menubar/MenubarItem.jsx rename to client/components/Menubar/MenubarItem.tsx index 27e5b1f7c2..d2b3a1b1a3 100644 --- a/client/components/Menubar/MenubarItem.jsx +++ b/client/components/Menubar/MenubarItem.tsx @@ -1,7 +1,19 @@ -import PropTypes from 'prop-types'; import React, { useEffect, useContext, useRef } from 'react'; import { MenubarContext, SubmenuContext, ParentMenuContext } from './contexts'; -import { ButtonOrLink } from '../../common/ButtonOrLink'; +import { ButtonOrLink, ButtonOrLinkProps } from '../../common/ButtonOrLink'; + +export enum MenubarItemRole { + MENU_ITEM = 'menuitem', + OPTION = 'option' +} + +export interface MenubarItemProps extends Omit { + /** + * Provides a way to deal with optional items. + */ + role?: MenubarItemRole; + selected?: boolean; +} /** * MenubarItem wraps a button or link in an accessible list item that @@ -36,14 +48,14 @@ import { ButtonOrLink } from '../../common/ButtonOrLink'; * */ -function MenubarItem({ - className, +export function MenubarItem({ + className = 'nav__dropdown-item', id, - role: customRole, - isDisabled, - selected, + role: customRole = MenubarItemRole.MENU_ITEM, + isDisabled = false, + selected = false, ...rest -}) { +}: MenubarItemProps) { const { createMenuItemHandlers, hasFocus } = useContext(MenubarContext); const { setSubmenuActiveIndex, @@ -94,25 +106,3 @@ function MenubarItem({ ); } - -MenubarItem.propTypes = { - ...ButtonOrLink.propTypes, - className: PropTypes.string, - id: PropTypes.string, - /** - * Provides a way to deal with optional items. - */ - role: PropTypes.oneOf(['menuitem', 'option']), - isDisabled: PropTypes.bool, - selected: PropTypes.bool -}; - -MenubarItem.defaultProps = { - className: 'nav__dropdown-item', - id: undefined, - role: 'menuitem', - isDisabled: false, - selected: false -}; - -export default MenubarItem; diff --git a/client/components/Menubar/contexts.jsx b/client/components/Menubar/contexts.jsx deleted file mode 100644 index 18cad6c36b..0000000000 --- a/client/components/Menubar/contexts.jsx +++ /dev/null @@ -1,13 +0,0 @@ -import { createContext } from 'react'; - -export const ParentMenuContext = createContext('none'); - -export const MenuOpenContext = createContext('none'); - -export const MenubarContext = createContext({ - createMenuHandlers: () => ({}), - createMenuItemHandlers: () => ({}), - toggleMenuOpen: () => {} -}); - -export const SubmenuContext = createContext({}); diff --git a/client/components/Menubar/contexts.tsx b/client/components/Menubar/contexts.tsx new file mode 100644 index 0000000000..eb080976ba --- /dev/null +++ b/client/components/Menubar/contexts.tsx @@ -0,0 +1,44 @@ +import React, { createContext, RefObject } from 'react'; + +export const ParentMenuContext = createContext('none'); + +export const MenuOpenContext = createContext('none'); + +interface MenubarContextType { + createMenuHandlers: ( + id: string + ) => Partial<{ + onMouseOver: (e: React.MouseEvent) => void; + onClick: (e: React.MouseEvent) => void; + onBlur: (e: React.FocusEvent) => void; + onFocus: (e: React.FocusEvent) => void; + }>; + createMenuItemHandlers: ( + id: string + ) => Partial<{ + onMouseUp: (e: React.MouseEvent) => void; + onBlur: (e: React.FocusEvent) => void; + onFocus: (e: React.FocusEvent) => void; + }>; + toggleMenuOpen: (id: string) => void; + hasFocus: boolean; +} + +export const MenubarContext = createContext({ + createMenuHandlers: () => ({}), + createMenuItemHandlers: () => ({}), + toggleMenuOpen: () => {}, + hasFocus: false +}); + +export interface SubmenuContextType { + submenuItems: Set>; + setSubmenuActiveIndex: (index: number) => void; + registerSubmenuItem: (ref: RefObject) => () => void; +} + +export const SubmenuContext = createContext({ + submenuItems: new Set(), + setSubmenuActiveIndex: () => {}, + registerSubmenuItem: () => () => {} +}); diff --git a/client/components/PreviewNav.test.tsx b/client/components/PreviewNav.test.tsx new file mode 100644 index 0000000000..f5566cfa8c --- /dev/null +++ b/client/components/PreviewNav.test.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { render, screen } from '../test-utils'; +import { PreviewNav } from './PreviewNav'; + +describe('PreviewNav', () => { + const owner = { username: 'alice' }; + const project = { id: '123', name: 'My Project' }; + + test('renders with correct links and icons using provided data-testid attributes', () => { + render(); + + // Logo link to /alice/sketches + const iconLinkUserSketches = screen.getByTestId('icon-link_user-sketches'); + expect(iconLinkUserSketches).toHaveAttribute( + 'href', + `/${owner.username}/sketches` + ); + + // p5js logo icon presence + const iconP5Logo = screen.getByTestId('icon_p5-logo'); + expect(iconP5Logo).toBeInTheDocument(); + + // Current project link to /alice/sketches/123 + const linkCurrentProject = screen.getByTestId('link_current-project'); + expect(linkCurrentProject).toHaveAttribute( + 'href', + `/${owner.username}/sketches/${project.id}` + ); + expect(linkCurrentProject).toHaveTextContent(project.name); + + // Owner username link to /alice/sketches + const linkUserSketches = screen.getByTestId('link_user-sketches'); + expect(linkUserSketches).toHaveAttribute( + 'href', + `/${owner.username}/sketches` + ); + expect(linkUserSketches).toHaveTextContent(owner.username); + + // Edit project code link to /alice/sketches/123 + const linkProjectCode = screen.getByTestId('link_project-code'); + expect(linkProjectCode).toHaveAttribute( + 'href', + `/${owner.username}/sketches/${project.id}` + ); + + // Code icon presence + const iconCode = screen.getByTestId('icon_code'); + expect(iconCode).toBeInTheDocument(); + + // Check nav container presence + const nav = screen.getByTestId('preview-nav'); + expect(nav).toBeInTheDocument(); + }); +}); diff --git a/client/components/PreviewNav.jsx b/client/components/PreviewNav.tsx similarity index 55% rename from client/components/PreviewNav.jsx rename to client/components/PreviewNav.tsx index f66476d92b..65f429db49 100644 --- a/client/components/PreviewNav.jsx +++ b/client/components/PreviewNav.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React from 'react'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -6,56 +5,60 @@ import { useTranslation } from 'react-i18next'; import LogoIcon from '../images/p5js-logo-small.svg'; import CodeIcon from '../images/code.svg'; -const PreviewNav = ({ owner, project }) => { +interface PreviewNavProps { + owner: { username: string }; + project: { name: string; id: string }; +} + +export const PreviewNav = ({ owner, project }: PreviewNavProps) => { const { t } = useTranslation(); return ( -