diff --git a/.eslintrc b/.eslintrc index e9539865dd..137d893825 100644 --- a/.eslintrc +++ b/.eslintrc @@ -136,6 +136,9 @@ "no-unused-vars": "off", "import/no-default-export": "warn", "no-underscore-dangle": "warn", + "react/require-default-props": "off", + "no-shadow": "off", + "@typescript-eslint/no-shadow": "error" } }, { diff --git a/client/common/Button.stories.jsx b/client/common/Button.stories.jsx index d11634ae28..0a0150a5b6 100644 --- a/client/common/Button.stories.jsx +++ b/client/common/Button.stories.jsx @@ -1,7 +1,7 @@ import React from 'react'; import { action } from '@storybook/addon-actions'; -import Button from './Button'; +import { Button, ButtonDisplays, ButtonKinds, ButtonTypes } from './Button'; import { GithubIcon, DropdownArrowIcon, PlusIcon } from './icons'; export default { @@ -15,13 +15,13 @@ export default { }; export const AllFeatures = (args) => ( - ); export const SubmitButton = () => ( - ); @@ -59,7 +59,7 @@ export const ButtonWithIconAfter = () => ( ); export const InlineButtonWithIconAfter = () => ( - ); @@ -68,6 +68,6 @@ export const InlineIconOnlyButton = () => ( ); + const anchor = screen.getByRole('link'); + expect(anchor.tagName.toLowerCase()).toBe('a'); + expect(anchor).toHaveAttribute('href', 'https://example.com'); + }); + + it('renders as a React Router when `to` is provided', () => { + render(); + const link = screen.getByRole('link'); + expect(link.tagName.toLowerCase()).toBe('a'); // Link renders as + expect(link).toHaveAttribute('href', '/dashboard'); + }); + + it('renders as a ); + const el = screen.getByRole('button'); + expect(el.tagName.toLowerCase()).toBe('button'); + expect(el).toHaveAttribute('type', 'button'); + }); + + // Children & Icons + it('renders children', () => { + render(); + expect(screen.getByText('Click Me')).toBeInTheDocument(); + }); + + it('renders an iconBefore and button text', () => { + render( + + ); + expect(screen.getByLabelText('iconbefore')).toBeInTheDocument(); + expect(screen.getByRole('button')).toHaveTextContent( + 'This has a before icon' + ); + }); + + it('renders with iconAfter', () => { + render( + + ); + expect(screen.getByLabelText('iconafter')).toBeInTheDocument(); + expect(screen.getByRole('button')).toHaveTextContent( + 'This has an after icon' + ); + }); + + it('renders only the icon if iconOnly', () => { + render( + + ); + expect(screen.getByLabelText('iconafter')).toBeInTheDocument(); + expect(screen.getByRole('button')).not.toHaveTextContent( + 'This has an after icon' + ); + }); + + // HTML attributes + it('calls onClick handler when clicked', () => { + const handleClick = jest.fn(); + render(); + fireEvent.click(screen.getByText('Click')); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('renders disabled state', () => { + render(); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('uses aria-label when provided', () => { + render( - {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..258f681832 --- /dev/null +++ b/client/components/Dropdown/DropdownMenu.tsx @@ -0,0 +1,160 @@ +import React, { forwardRef, useCallback, useRef, useState } from 'react'; +import styled from 'styled-components'; +import { useModalClose } from '../../common/useModalClose'; +import DownArrowIcon from '../../images/down-filled-triangle.svg'; +import { remSize, prop } from '../../theme'; + +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; +} + +interface StyledDropdownMenuProps { + align: 'right' | 'left'; +} + +const StyledDropdownMenu = 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; + ${(props) => (props.align === 'right' ? 'right: 0;' : 'left: 0;')} + + 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 const DropdownMenu = forwardRef( + ( + { + children, + anchor, + 'aria-label': ariaLabel, + align = '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 a7908e487d..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 82% rename from client/components/Dropdown/TableDropdown.jsx rename to client/components/Dropdown/TableDropdown.tsx index 44f4f27fd6..32351cadf1 100644 --- a/client/components/Dropdown/TableDropdown.jsx +++ b/client/components/Dropdown/TableDropdown.tsx @@ -1,7 +1,7 @@ import React from 'react'; import styled from 'styled-components'; import { prop, remSize } from '../../theme'; -import DropdownMenu from './DropdownMenu'; +import { DropdownMenu, DropdownMenuProps } from './DropdownMenu'; import DownFilledTriangleIcon from '../../images/down-filled-triangle.svg'; import MoreIconSvg from '../../images/more.svg'; @@ -20,7 +20,9 @@ const TableDropdownIcon = () => { ); }; -const TableDropdown = styled(DropdownMenu).attrs({ +export interface TableDropdownProps extends DropdownMenuProps {} + +export const TableDropdown = styled(DropdownMenu).attrs({ align: 'right', anchor: })` @@ -42,5 +44,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 97% rename from client/components/Menubar/Menubar.test.jsx rename to client/components/Menubar/Menubar.test.tsx index 0f78d2f547..ade21a4138 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 MenubarSubmenu from './MenubarSubmenu'; -import MenubarItem from './MenubarItem'; +import { Menubar } from './Menubar'; +import { MenubarSubmenu } from './MenubarSubmenu'; +import { MenubarItem } from './MenubarItem'; describe('Menubar', () => { const renderMenubar = () => { diff --git a/client/components/Menubar/Menubar.jsx b/client/components/Menubar/Menubar.tsx similarity index 80% rename from client/components/Menubar/Menubar.jsx rename to client/components/Menubar/Menubar.tsx index 8a358fceb6..46cacdcdb9 100644 --- a/client/components/Menubar/Menubar.jsx +++ b/client/components/Menubar/Menubar.tsx @@ -1,23 +1,27 @@ -import PropTypes from 'prop-types'; import React, { useCallback, useMemo, useRef, useState, - useEffect + useEffect, + MouseEvent } from 'react'; -import useModalClose from '../../common/useModalClose'; +import { useModalClose } from '../../common/useModalClose'; import { MenuOpenContext, MenubarContext } from './contexts'; -import usePrevious from '../../common/usePrevious'; +import { usePrevious } from '../../common/usePrevious'; +import { KeydownHandlerMap } from '../../common/useKeyDownHandlers'; + +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; +} /** * 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 * * @@ -25,20 +29,22 @@ import usePrevious from '../../common/usePrevious'; * * */ +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); -function Menubar({ children, className }) { - 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 menuItemToId = useRef>(new Map()).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) => { + (index: number): string | undefined => { const items = Array.from(menuItems); const itemNode = items[index]; return menuItemToId.get(itemNode); @@ -52,7 +58,7 @@ function Menubar({ children, className }) { if (menuOpen !== 'none') { const newMenuId = getMenuId(newIndex); - setMenuOpen(newMenuId); + if (newMenuId) setMenuOpen(newMenuId); } }, [activeIndex, menuItems, menuOpen, getMenuId]); @@ -62,7 +68,7 @@ function Menubar({ children, className }) { if (menuOpen !== 'none') { const newMenuId = getMenuId(newIndex); - setMenuOpen(newMenuId); + if (newMenuId) setMenuOpen(newMenuId); } }, [activeIndex, menuItems, menuOpen, getMenuId]); @@ -83,9 +89,9 @@ function Menubar({ children, className }) { activeNode.focus(); }, [activeIndex, menuItems, menuOpen]); - const toggleMenuOpen = useCallback((id) => { + const toggleMenuOpen = useCallback((id: string) => { setMenuOpen((prevState) => (prevState === id ? 'none' : id)); - }); + }, []); const registerTopLevelItem = useCallback( (ref, submenuId) => { @@ -116,7 +122,7 @@ function Menubar({ children, className }) { setMenuOpen('none'); }, [setMenuOpen]); - const nodeRef = useModalClose(handleClose); + const nodeRef = useModalClose(handleClose); const handleFocus = useCallback(() => { setHasFocus(true); @@ -138,7 +144,7 @@ function Menubar({ children, className }) { [nodeRef] ); - const keyHandlers = { + const keyHandlers: KeydownHandlerMap = { ArrowLeft: (e) => { e.preventDefault(); e.stopPropagation(); @@ -174,7 +180,7 @@ function Menubar({ children, className }) { if (activeIndex !== prevIndex) { const items = Array.from(menuItems); const activeNode = items[activeIndex]; - const prevNode = items[prevIndex]; + const prevNode = prevIndex ? items[prevIndex] : undefined; prevNode?.setAttribute('tabindex', '-1'); activeNode?.setAttribute('tabindex', '0'); @@ -191,7 +197,7 @@ function Menubar({ children, className }) { const contextValue = useMemo( () => ({ - createMenuHandlers: (menu) => ({ + createMenuHandlers: (menu: string) => ({ onMouseOver: () => { setMenuOpen((prevState) => (prevState === 'none' ? 'none' : menu)); }, @@ -210,8 +216,8 @@ function Menubar({ children, className }) { onBlur: handleBlur, onFocus: clearHideTimeout }), - createMenuItemHandlers: (menu) => ({ - onMouseUp: (e) => { + createMenuItemHandlers: (menu: string) => ({ + onMouseUp: (e: MouseEvent) => { if (e.button === 2) { return; } @@ -278,15 +284,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 80% rename from client/components/Menubar/MenubarItem.jsx rename to client/components/Menubar/MenubarItem.tsx index ab3b741e34..438d06a68e 100644 --- a/client/components/Menubar/MenubarItem.jsx +++ b/client/components/Menubar/MenubarItem.tsx @@ -1,13 +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 interface MenubarItemProps extends ButtonOrLinkProps { + selected?: boolean; + /** + * Provides a way to deal with optional items. + */ + role?: 'menuitem' | 'option'; +} /** * MenubarItem wraps a button or link in an accessible list item that * integrates with keyboard navigation and other submenu behaviors. * - * TO DO: how to document props passed through spread operator? * @component * @param {object} props * @param {string} [props.className='nav__dropdown-item'] - CSS class name to apply to the list item @@ -35,15 +41,14 @@ import ButtonOrLink from '../../common/ButtonOrLink'; * {languageKeyToLabel(key)} * */ - -function MenubarItem({ - className, +export function MenubarItem({ + className = 'nav__dropdown-item', id, - role: customRole, - isDisabled, - selected, + role: customRole = 'menuitem', + isDisabled = false, + selected = false, ...rest -}) { +}: MenubarItemProps) { const { createMenuItemHandlers, hasFocus } = useContext(MenubarContext); const { setSubmenuActiveIndex, @@ -94,25 +99,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/MenubarSubmenu.jsx b/client/components/Menubar/MenubarSubmenu.tsx similarity index 72% rename from client/components/Menubar/MenubarSubmenu.jsx rename to client/components/Menubar/MenubarSubmenu.tsx index 38683f2310..ca159742d3 100644 --- a/client/components/Menubar/MenubarSubmenu.jsx +++ b/client/components/Menubar/MenubarSubmenu.tsx @@ -1,7 +1,6 @@ // https://blog.logrocket.com/building-accessible-menubar-component-react import classNames from 'classnames'; -import PropTypes from 'prop-types'; import React, { useState, useEffect, @@ -18,11 +17,12 @@ import { } from './contexts'; import TriangleIcon from '../../images/down-filled-triangle.svg'; -export function useMenuProps(id) { +/* ------------------------------------------------------------------------------------------------- + * useMenuProps + * -----------------------------------------------------------------------------------------------*/ +export function useMenuProps(id: string) { const activeMenu = useContext(MenuOpenContext); - const isOpen = id === activeMenu; - const { createMenuHandlers } = useContext(MenubarContext); const handlers = useMemo(() => createMenuHandlers(id), [ @@ -36,7 +36,11 @@ export function useMenuProps(id) { /* ------------------------------------------------------------------------------------------------- * MenubarTrigger * -----------------------------------------------------------------------------------------------*/ - +interface MenubarTriggerProps + extends React.ButtonHTMLAttributes { + role?: string; + hasPopup?: 'menu' | 'listbox' | 'true'; +} /** * MenubarTrigger renders a button that toggles a submenu. It handles keyboard navigation and supports * screen readers. It needs to be within a submenu context. @@ -63,111 +67,111 @@ export function useMenuProps(id) { * */ -const MenubarTrigger = React.forwardRef(({ role, hasPopup, ...props }, ref) => { - const { - setActiveIndex, - menuItems, - registerTopLevelItem, - hasFocus - } = useContext(MenubarContext); - const { id, title, first, last } = useContext(SubmenuContext); - const { isOpen, handlers } = useMenuProps(id); - - const handleMouseEnter = () => { - if (hasFocus) { - const items = Array.from(menuItems); - const index = items.findIndex((item) => item === ref.current); - - if (index !== -1) { - setActiveIndex(index); - } - } - }; +const MenubarTrigger = React.forwardRef( + ( + { role = 'menuitem', hasPopup = 'menu', ...props }: MenubarTriggerProps, + ref + ) => { + const { + setActiveIndex, + menuItems, + registerTopLevelItem, + hasFocus + } = useContext(MenubarContext); + const { id, title, first, last } = useContext(SubmenuContext); + const { isOpen, handlers } = useMenuProps(id); + + const handleMouseEnter = () => { + if (hasFocus) { + const items = Array.from(menuItems); + const index = items.findIndex( + (item) => + item === + (ref as React.MutableRefObject).current + ); - const handleKeyDown = (e) => { - switch (e.key) { - case 'ArrowDown': - if (!isOpen) { - e.preventDefault(); - e.stopPropagation(); - first(); - } - break; - case 'ArrowUp': - if (!isOpen) { - e.preventDefault(); - e.stopPropagation(); - last(); + if (index !== -1) { + setActiveIndex(index); } - break; - case 'Enter': - case ' ': - if (!isOpen) { - e.preventDefault(); - e.stopPropagation(); - first(); - } - break; - default: - break; - } - }; - - useEffect(() => { - const unregister = registerTopLevelItem(ref, id); - return unregister; - }, [menuItems, registerTopLevelItem]); - - return ( - - ); -}); + } + }; -MenubarTrigger.propTypes = { - role: PropTypes.string, - hasPopup: PropTypes.oneOf(['menu', 'listbox', 'true']) -}; + const handleKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + if (!isOpen) { + e.preventDefault(); + e.stopPropagation(); + first(); + } + break; + case 'ArrowUp': + if (!isOpen) { + e.preventDefault(); + e.stopPropagation(); + last(); + } + break; + case 'Enter': + case ' ': + if (!isOpen) { + e.preventDefault(); + e.stopPropagation(); + first(); + } + break; + default: + break; + } + }; -MenubarTrigger.defaultProps = { - role: 'menuitem', - hasPopup: 'menu' -}; + useEffect(() => { + const unregister = registerTopLevelItem(ref, id); + return unregister; + }, [menuItems, registerTopLevelItem]); + + return ( + + ); + } +); /* ------------------------------------------------------------------------------------------------- * MenubarList * -----------------------------------------------------------------------------------------------*/ +interface MenubarListProps { + // MenubarItems that should be rendered in the list + children?: React.ReactNode; + // The ARIA role of the list element + role?: 'menu' | 'listbox'; +} + /** * MenubarList renders the container for menu items in a submenu. It provides context and handles ARIA roles. * - * @param {Object} props - * @param {React.ReactNode} props.children - MenubarItems that should be rendered in the list - * @param {string} [props.role='menu'] - The ARIA role of the list element - * @returns {JSX.Element} - * * @example * * ... elements * */ - -function MenubarList({ children, role, ...props }) { +function MenubarList({ children, role = 'menu', ...props }: MenubarListProps) { const { id, title } = useContext(SubmenuContext); return ( @@ -184,20 +188,18 @@ function MenubarList({ children, role, ...props }) { ); } -MenubarList.propTypes = { - children: PropTypes.node, - role: PropTypes.oneOf(['menu', 'listbox']) -}; - -MenubarList.defaultProps = { - children: null, - role: 'menu' -}; - /* ------------------------------------------------------------------------------------------------- * MenubarSubmenu * -----------------------------------------------------------------------------------------------*/ +export interface MenubarSubmenuProps { + id: string; + children?: React.ReactNode; + title: string; + triggerRole?: string; + listRole?: 'menu' | 'listbox'; +} + /** * MenubarSubmenu manages a triggerable submenu within a menubar. It is a compound component * that manages the state of the submenu and its items. It also provides keyboard navigation @@ -219,19 +221,18 @@ MenubarList.defaultProps = { * * */ - -function MenubarSubmenu({ +export function MenubarSubmenu({ children, id, title, - triggerRole: customTriggerRole, - listRole: customListRole, + triggerRole: customTriggerRole = 'menuItem', + listRole: customListRole = 'menu', ...props -}) { +}: MenubarSubmenuProps) { const { isOpen, handlers } = useMenuProps(id); const [submenuActiveIndex, setSubmenuActiveIndex] = useState(0); const { setMenuOpen, toggleMenuOpen } = useContext(MenubarContext); - const submenuItems = useRef(new Set()).current; + const submenuItems = useRef>(new Set()).current; const buttonRef = useRef(null); const listItemRef = useRef(null); @@ -275,6 +276,8 @@ function MenubarSubmenu({ if (activeItem) { const activeItemNode = activeItem.firstChild; + if (!activeItemNode) return; + const isDisabled = activeItemNode.getAttribute('aria-disabled') === 'true'; @@ -442,19 +445,3 @@ function MenubarSubmenu({ ); } - -MenubarSubmenu.propTypes = { - id: PropTypes.string.isRequired, - children: PropTypes.node, - title: PropTypes.node.isRequired, - triggerRole: PropTypes.string, - listRole: PropTypes.string -}; - -MenubarSubmenu.defaultProps = { - children: null, - triggerRole: 'menuitem', - listRole: 'menu' -}; - -export default MenubarSubmenu; 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..5369b1f497 --- /dev/null +++ b/client/components/Menubar/contexts.tsx @@ -0,0 +1,55 @@ +import React, { createContext } from 'react'; + +export const ParentMenuContext = createContext('none'); + +export const MenuOpenContext = createContext('none'); + +interface MenubarContextType { + // Menubar + createMenuHandlers: (id: string) => Record; + toggleMenuOpen: (id: string) => void; + setMenuOpen: (id: string) => void; + + // MenubarItem + createMenuItemHandlers: (id: string) => Record; + hasFocus: boolean; + + // MenubarSubmenu + setActiveIndex: (index: number) => void; + menuItems: Set; + registerTopLevelItem: ( + ref: React.Ref, + id: string + ) => () => void; // returns unregister fn +} + +export const MenubarContext = createContext({ + createMenuHandlers: () => ({}), + createMenuItemHandlers: () => ({}), + toggleMenuOpen: () => {}, + setMenuOpen: () => {}, + setActiveIndex: () => {}, + hasFocus: false, + menuItems: new Set(), + registerTopLevelItem: () => () => {} +}); + +interface SubmenuContextType { + submenuItems: Set; + setSubmenuActiveIndex: (index: number) => void; + registerSubmenuItem: (ref: React.Ref) => () => void; + id: string; + title: string; + first: () => void; + last: () => void; +} + +export const SubmenuContext = createContext({ + submenuItems: new Set(), + setSubmenuActiveIndex: () => {}, + registerSubmenuItem: () => () => {}, + id: '', + title: '', + first: () => {}, + last: () => {} +}); 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 ( -