diff --git a/package-lock.json b/package-lock.json index 2bc2f7e0712..a6bb282a2af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12197,9 +12197,9 @@ } }, "node_modules/@zendeskgarden/container-menu": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/@zendeskgarden/container-menu/-/container-menu-0.5.1.tgz", - "integrity": "sha512-ctbuQGHSjmsGqKmJ9uyk1TvUjA4Im6xF64VpLwJhYwsmQV4ddp3/tMxu3t2gaB+cG9GsjJLzGVOuTfjwhVQlJg==", + "version": "0.6.0", + "resolved": "file:zendeskgarden-container-menu-0.6.0.tgz", + "integrity": "sha512-L8kESo082EAm6phyie45ygGElBOlyE7ZJpkqzDdrhkOkx8o+5QAn91tIJkdQtiWd7C+o94nJ4y+XCYrpcJWPLA==", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.8.4", @@ -51904,7 +51904,7 @@ "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@zendeskgarden/container-combobox": "^2.0.0", - "@zendeskgarden/container-menu": "^0.5.1", + "@zendeskgarden/container-menu": "file:../../zendeskgarden-container-menu-0.6.0.tgz", "@zendeskgarden/container-utilities": "^2.0.0", "@zendeskgarden/react-buttons": "^9.5.4", "@zendeskgarden/react-forms": "^9.5.4", diff --git a/packages/dropdowns/demo/stories/MenuStory.tsx b/packages/dropdowns/demo/stories/MenuStory.tsx index 9059d8564ee..154114aa158 100644 --- a/packages/dropdowns/demo/stories/MenuStory.tsx +++ b/packages/dropdowns/demo/stories/MenuStory.tsx @@ -6,11 +6,15 @@ */ import React from 'react'; +import { createPortal } from 'react-dom'; +import { StyleSheetManager } from 'styled-components'; + import { StoryFn } from '@storybook/react'; import LeafIcon from '@zendeskgarden/svg-icons/src/16/leaf-stroke.svg'; import CartIcon from '@zendeskgarden/svg-icons/src/16/shopping-cart-stroke.svg'; import { Grid } from '@zendeskgarden/react-grid'; import { IMenuProps, Item, ItemGroup, Separator, Menu } from '@zendeskgarden/react-dropdowns'; +import { IGardenTheme, ThemeProvider } from '@zendeskgarden/react-theming'; import { IconButton } from '@zendeskgarden/react-buttons'; import { ButtonType, IItem, Items } from './types'; @@ -29,50 +33,77 @@ interface IArgs extends IMenuProps { label: string; } -export const MenuStory: StoryFn = ({ button, items, label, ...args }) => ( - - - -
- ( - - - - ) - } - > - {items.map(item => { - if ('items' in item) { - return ( - : undefined} - > - {item.items.map(groupItem => ( - - ))} - - ); - } +const Portal = ({ children, target }: any) => { + if (!target) return null; + + return createPortal(children, target); +}; + +export const MenuStory: StoryFn = ({ button, items, label, ...args }) => { + const ref = React.useRef(null); + const [document, setDocument] = React.useState(undefined); + + React.useEffect(() => { + if (ref.current && !ref.current.shadowRoot) { + const shadowRoot = ref.current?.attachShadow({ mode: 'open' }); - if ('isSeparator' in item) { - return ; - } + setDocument(shadowRoot); + } + }, []); - return ; - })} - -
-
-
-
-); + return ( +
+ + + ({ ...theme, document })}> + + + +
+ ( + + + + ) + } + > + {items.map(item => { + if ('items' in item) { + return ( + : undefined} + > + {item.items.map(groupItem => ( + + ))} + + ); + } + + if ('isSeparator' in item) { + return ; + } + + return ; + })} + +
+
+
+
+
+
+
+
+ ); +}; diff --git a/packages/dropdowns/demo/stories/data.ts b/packages/dropdowns/demo/stories/data.ts index 9f6d5a5f418..800bdfbb1e7 100644 --- a/packages/dropdowns/demo/stories/data.ts +++ b/packages/dropdowns/demo/stories/data.ts @@ -23,6 +23,12 @@ export const ITEMS: Items = [ value: 'separator', isSeparator: true }, + { + value: 'item-anchor', + label: 'Item link', + href: 'https://garden.zendesk.com', + isExternal: true + }, { value: 'item-meta', label: 'Item', diff --git a/packages/dropdowns/package.json b/packages/dropdowns/package.json index 7f4d60dc9ee..1846d4e6cac 100644 --- a/packages/dropdowns/package.json +++ b/packages/dropdowns/package.json @@ -23,7 +23,7 @@ "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@zendeskgarden/container-combobox": "^2.0.0", - "@zendeskgarden/container-menu": "^0.5.1", + "@zendeskgarden/container-menu": "file:../../zendeskgarden-container-menu-0.6.0.tgz", "@zendeskgarden/container-utilities": "^2.0.0", "@zendeskgarden/react-buttons": "^9.5.4", "@zendeskgarden/react-forms": "^9.5.4", diff --git a/packages/dropdowns/src/elements/menu/Item.tsx b/packages/dropdowns/src/elements/menu/Item.tsx index 5b97e97e028..f85d07c8469 100644 --- a/packages/dropdowns/src/elements/menu/Item.tsx +++ b/packages/dropdowns/src/elements/menu/Item.tsx @@ -5,30 +5,65 @@ * found at http://www.apache.org/licenses/LICENSE-2.0. */ -import React, { LiHTMLAttributes, MutableRefObject, forwardRef, useMemo } from 'react'; +import React, { + AnchorHTMLAttributes, + LiHTMLAttributes, + MutableRefObject, + forwardRef, + useMemo +} from 'react'; import PropTypes from 'prop-types'; import { mergeRefs } from 'react-merge-refs'; import AddIcon from '@zendeskgarden/svg-icons/src/16/plus-stroke.svg'; import NextIcon from '@zendeskgarden/svg-icons/src/16/chevron-right-stroke.svg'; import PreviousIcon from '@zendeskgarden/svg-icons/src/16/chevron-left-stroke.svg'; import CheckedIcon from '@zendeskgarden/svg-icons/src/16/check-lg-stroke.svg'; -import { IItemProps, OptionType as ItemType, OPTION_TYPE } from '../../types'; -import { StyledItem, StyledItemContent, StyledItemIcon, StyledItemTypeIcon } from '../../views'; + +import { IItemProps, OPTION_TYPE, OptionType } from '../../types'; +import { + StyledItem, + StyledItemAnchor, + StyledItemContent, + StyledItemIcon, + StyledItemTypeIcon +} from '../../views'; import { ItemMeta } from './ItemMeta'; import useMenuContext from '../../context/useMenuContext'; import useItemGroupContext from '../../context/useItemGroupContext'; import { ItemContext } from '../../context/useItemContext'; import { toItem } from './utils'; +const optionType = new Set(OPTION_TYPE); + +const renderActionIcon = (itemType?: OptionType) => { + switch (itemType) { + case 'add': + return ; + case 'next': + return ; + case 'previous': + return ; + default: + return ; + } +}; + +/** + * 1. role='img' on `svg` is valid WAI-ARIA usage in this context. + * https://dequeuniversity.com/rules/axe/4.2/svg-img-alt + */ + const ItemComponent = forwardRef( ( { children, value, label = value, + href, isSelected, icon, isDisabled, + isExternal, type, name, onClick, @@ -47,11 +82,23 @@ const ItemComponent = forwardRef( name, type, isSelected, - isDisabled + isDisabled, + href, + isExternal }), type: selectionType }; + const hasAnchor = !!href; + + if (hasAnchor) { + if (type && optionType.has(type)) { + throw new Error(`Menu item '${value}' can't use type '${type}'`); + } else if (selectionType) { + throw new Error(`Menu item '${value}' can't use selection type '${selectionType}'`); + } + } + const { ref: _itemRef, ...itemProps } = getItemProps({ item, onClick, @@ -59,46 +106,48 @@ const ItemComponent = forwardRef( onMouseEnter }) as LiHTMLAttributes & { ref: MutableRefObject }; - const isActive = value === focusedValue; - - const renderActionIcon = (iconType?: ItemType) => { - switch (iconType) { - case 'add': - return ; - - case 'next': - return ; + const contextValue = useMemo(() => ({ isDisabled, type }), [isDisabled, type]); - case 'previous': - return ; + const itemChildren = ( + <> + + {renderActionIcon(type)} + + {!!icon && ( + + {icon} + + )} + {children || label} + + ); - default: - return ; - } + const menuItemProps = { + $isCompact: isCompact, + $isActive: value === focusedValue, + $type: type, + ...props, + ...itemProps, + ref: mergeRefs([_itemRef, ref]) }; - const contextValue = useMemo(() => ({ isDisabled, type }), [isDisabled, type]); - return ( - - - {renderActionIcon(type)} - - {!!icon && ( - - {icon} - - )} - {children || label} - + {hasAnchor ? ( +
  • + )} + href={href} + target={isExternal ? '_blank' : undefined} + // legacy browsers safeguards + rel={isExternal ? 'noopener noreferrer' : undefined} + > + {itemChildren} + +
  • + ) : ( + {itemChildren} + )}
    ); } @@ -107,9 +156,11 @@ const ItemComponent = forwardRef( ItemComponent.displayName = 'Item'; ItemComponent.propTypes = { + href: PropTypes.string, icon: PropTypes.any, isDisabled: PropTypes.bool, isSelected: PropTypes.bool, + isExternal: PropTypes.bool, label: PropTypes.string, name: PropTypes.string, type: PropTypes.oneOf(OPTION_TYPE), diff --git a/packages/dropdowns/src/elements/menu/Menu.spec.tsx b/packages/dropdowns/src/elements/menu/Menu.spec.tsx index 9166c4470ba..093c0576773 100644 --- a/packages/dropdowns/src/elements/menu/Menu.spec.tsx +++ b/packages/dropdowns/src/elements/menu/Menu.spec.tsx @@ -686,4 +686,68 @@ describe('Menu', () => { expect(button).toHaveAttribute('data-garden-id', 'buttons.button'); }); }); + + describe('Item link behavior', () => { + it('renders with href as anchor tag', async () => { + const { getByTestId } = render( + + + Example Link + + + ); + await floating(); + const item = getByTestId('item'); + expect(item.tagName).toBe('A'); + expect(item).toHaveAttribute('href', 'https://example.com'); + expect(item).toHaveAttribute('target', '_blank'); + expect(item).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('renders with isExternal=false correctly', async () => { + const { getByTestId } = render( + + + Internal Link + + + ); + await floating(); + const item = getByTestId('item'); + expect(item.tagName).toBe('A'); + expect(item).toHaveAttribute('href', 'https://example.com'); + expect(item).not.toHaveAttribute('target'); + expect(item).not.toHaveAttribute('rel'); + }); + + it('throws error when href is used with a selection type', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + expect(() => { + render( + + + + + + ); + }).toThrow(/can't use selection type/u); + + consoleSpy.mockRestore(); + }); + + it('throws error when href is used with option type', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + + expect(() => { + render( + + + + ); + }).toThrow(/can't use type/u); + + consoleSpy.mockRestore(); + }); + }); }); diff --git a/packages/dropdowns/src/elements/menu/Menu.tsx b/packages/dropdowns/src/elements/menu/Menu.tsx index 5381ebb3f58..88dcfb0ce51 100644 --- a/packages/dropdowns/src/elements/menu/Menu.tsx +++ b/packages/dropdowns/src/elements/menu/Menu.tsx @@ -10,7 +10,7 @@ import PropTypes from 'prop-types'; import { mergeRefs } from 'react-merge-refs'; import { ThemeContext } from 'styled-components'; import { useMenu } from '@zendeskgarden/container-menu'; -import { DEFAULT_THEME, useWindow } from '@zendeskgarden/react-theming'; +import { DEFAULT_THEME, useDocument, useWindow } from '@zendeskgarden/react-theming'; import { Button, IButtonProps } from '@zendeskgarden/react-buttons'; import { IMenuProps, PLACEMENT } from '../../types'; import { MenuContext } from '../../context/useMenuContext'; @@ -45,7 +45,8 @@ export const Menu = forwardRef( const items = toItems(children); /* istanbul ignore next */ const theme = useContext(ThemeContext) || DEFAULT_THEME; - const environment = useWindow(theme); + const _window = useWindow(theme); + const document = useDocument(theme); const { isExpanded, @@ -56,8 +57,9 @@ export const Menu = forwardRef( getItemGroupProps, getSeparatorProps } = useMenu({ + document, rtl: theme.rtl, - environment, + window: _window, defaultFocusedValue, focusedValue: _focusedValue, defaultExpanded, diff --git a/packages/dropdowns/src/elements/menu/utils.ts b/packages/dropdowns/src/elements/menu/utils.ts index 1f7c664a078..544bb2ecc5a 100644 --- a/packages/dropdowns/src/elements/menu/utils.ts +++ b/packages/dropdowns/src/elements/menu/utils.ts @@ -22,7 +22,9 @@ export const toItem = ( value: props.value, label: props.label, ...(props.name && { name: props.name }), + ...(props.href && { href: props.href }), ...(props.isDisabled && { disabled: props.isDisabled }), + ...(props.isExternal && { isExternal: props.isExternal }), ...(props.isSelected && { selected: props.isSelected }), ...(props.selectionType && { type: props.selectionType }), ...(props.type === 'next' && { isNext: true }), diff --git a/packages/dropdowns/src/types/index.ts b/packages/dropdowns/src/types/index.ts index d0aae90cbe8..c6e21dd8917 100644 --- a/packages/dropdowns/src/types/index.ts +++ b/packages/dropdowns/src/types/index.ts @@ -286,10 +286,14 @@ export interface IItemProps extends Omit, 'value icon?: ReactElement; /** Indicates that the item is not interactive */ isDisabled?: boolean; + /** Opens the `href` externally */ + isExternal?: boolean; /** Determines the initial selection state for the item */ isSelected?: boolean; - /** Sets the text label of the item (defaults to `value`) */ + /** Provides the text label of the item (defaults to `value`) */ label?: string; + /** Sets the item as an anchor */ + href?: string; /** Associates the item in a radio item group */ name?: string; /** Determines the item type */ diff --git a/packages/dropdowns/src/views/index.ts b/packages/dropdowns/src/views/index.ts index 7a4755f2c97..5b4e1cfc944 100644 --- a/packages/dropdowns/src/views/index.ts +++ b/packages/dropdowns/src/views/index.ts @@ -32,6 +32,7 @@ export * from './combobox/StyledValue'; export * from './menu/StyledMenu'; export * from './menu/StyledFloatingMenu'; export * from './menu/StyledItem'; +export * from './menu/StyledItemAnchor'; export * from './menu/StyledItemContent'; export * from './menu/StyledItemGroup'; export * from './menu/StyledItemIcon'; diff --git a/packages/dropdowns/src/views/menu/StyledItemAnchor.ts b/packages/dropdowns/src/views/menu/StyledItemAnchor.ts new file mode 100644 index 00000000000..e88625c8c7c --- /dev/null +++ b/packages/dropdowns/src/views/menu/StyledItemAnchor.ts @@ -0,0 +1,24 @@ +/** + * Copyright Zendesk, Inc. + * + * Use of this source code is governed under the Apache License, Version 2.0 + * found at http://www.apache.org/licenses/LICENSE-2.0. + */ + +import styled from 'styled-components'; +import { componentStyles } from '@zendeskgarden/react-theming'; +import { StyledOption } from '../combobox/StyledOption'; + +const COMPONENT_ID = 'dropdowns.menu.item_anchor'; + +export const StyledItemAnchor = styled(StyledOption as 'a').attrs({ + 'data-garden-id': COMPONENT_ID, + 'data-garden-version': PACKAGE_VERSION, + as: 'a' +})` + direction: ${props => props.theme.rtl && 'rtl'}; + text-decoration: none; + color: unset; + + ${componentStyles}; +`; diff --git a/zendeskgarden-container-menu-0.6.0.tgz b/zendeskgarden-container-menu-0.6.0.tgz new file mode 100644 index 00000000000..6d056ecaf3b Binary files /dev/null and b/zendeskgarden-container-menu-0.6.0.tgz differ