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/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/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};
+`;