diff --git a/package-lock.json b/package-lock.json
index 2bc2f7e0712..121d465776b 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": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@zendeskgarden/container-menu/-/container-menu-1.0.0.tgz",
+ "integrity": "sha512-GnG3IqKBmuNkbnDp9bHnwHpGyy/4/MUe3RmaDllVQNyG+uP0BsGondCRWZQE1ULwjfbGBeadAawQhEJcdoSPSA==",
"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": "^1.0.0",
"@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/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..4f6dec8ac30 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": "^1.0.0",
"@zendeskgarden/container-utilities": "^2.0.0",
"@zendeskgarden/react-buttons": "^9.5.4",
"@zendeskgarden/react-forms": "^9.5.4",
diff --git a/packages/dropdowns/src/context/useMenuContext.ts b/packages/dropdowns/src/context/useMenuContext.ts
index f223c45f741..980734b33bc 100644
--- a/packages/dropdowns/src/context/useMenuContext.ts
+++ b/packages/dropdowns/src/context/useMenuContext.ts
@@ -12,6 +12,7 @@ export const MenuContext = createContext<
| {
isCompact?: boolean;
focusedValue?: string | null;
+ getAnchorProps: IUseMenuReturnValue['getAnchorProps'];
getItemGroupProps: IUseMenuReturnValue['getItemGroupProps'];
getItemProps: IUseMenuReturnValue['getItemProps'];
getSeparatorProps: IUseMenuReturnValue['getSeparatorProps'];
diff --git a/packages/dropdowns/src/elements/menu/Item.tsx b/packages/dropdowns/src/elements/menu/Item.tsx
index 5b97e97e028..c0a6c1923bd 100644
--- a/packages/dropdowns/src/elements/menu/Item.tsx
+++ b/packages/dropdowns/src/elements/menu/Item.tsx
@@ -12,34 +12,61 @@ 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 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,
onKeyDown,
onMouseEnter,
- ...props
+ ...other
},
ref
) => {
const { type: selectionType } = useItemGroupContext();
- const { focusedValue, getItemProps, isCompact } = useMenuContext();
+ const { focusedValue, getAnchorProps, getItemProps, isCompact } = useMenuContext();
const item = {
...toItem({
value,
@@ -47,11 +74,19 @@ const ItemComponent = forwardRef(
name,
type,
isSelected,
- isDisabled
+ isDisabled,
+ href,
+ isExternal
}),
type: selectionType
};
+ const anchorProps = getAnchorProps({ item });
+
+ if (anchorProps && (type === 'add' || type === 'danger')) {
+ throw new Error(`Menu link item '${value}' can't use type '${type}'`);
+ }
+
const { ref: _itemRef, ...itemProps } = getItemProps({
item,
onClick,
@@ -59,46 +94,50 @@ 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 = {
+ ...other,
+ ...itemProps,
+ ref: mergeRefs([_itemRef, ref])
};
- const contextValue = useMemo(() => ({ isDisabled, type }), [isDisabled, type]);
-
return (
-
-
- {renderActionIcon(type)}
-
- {!!icon && (
-
- {icon}
-
- )}
- {children || label}
-
+ {anchorProps ? (
+
+
+ {itemChildren}
+
+
+ ) : (
+
+ {itemChildren}
+
+ )}
);
}
@@ -107,9 +146,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..d3200efe6fd 100644
--- a/packages/dropdowns/src/elements/menu/Menu.spec.tsx
+++ b/packages/dropdowns/src/elements/menu/Menu.spec.tsx
@@ -686,4 +686,107 @@ 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');
+ const link = item.firstChild!;
+
+ expect(item.nodeName).toBe('LI');
+ expect(link.nodeName).toBe('A');
+ expect(link).toHaveAttribute('href', 'https://example.com');
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+ });
+
+ it('renders with isExternal=false correctly', async () => {
+ const { getByTestId } = render(
+
+ -
+ Internal Link
+
+
+ );
+ await floating();
+ const item = getByTestId('item');
+ const link = item.firstChild!;
+
+ expect(item.nodeName).toBe('LI');
+ expect(link.nodeName).toBe('A');
+ expect(link).toHaveAttribute('href', 'https://example.com');
+ expect(link).not.toHaveAttribute('target');
+ expect(link).not.toHaveAttribute('rel');
+ });
+
+ it('renders with isSelected correctly', async () => {
+ const { getByTestId } = render(
+
+ -
+ Example Link
+
+
+ );
+ await floating();
+ const link = getByTestId('item').firstChild!;
+
+ expect(link.nodeName).toBe('A');
+ expect(link).toHaveAttribute('href', 'https://example.com');
+ expect(link).toHaveAttribute('aria-current', 'page');
+ });
+
+ it('renders with controlled selection correctly', async () => {
+ const { getByTestId } = render(
+
+ -
+ Link 1
+
+ -
+ Link 2
+
+
+ );
+ await floating();
+
+ expect(getByTestId('item-01').firstChild).toHaveAttribute('aria-current', 'page');
+ expect(getByTestId('item-02').firstChild).not.toHaveAttribute('aria-current');
+ });
+
+ it('throws error when href is used with a selection type', () => {
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
+
+ expect(() => {
+ render(
+
+
+
+
+
+ );
+ }).toThrow("Error: expected useMenu anchor item 'Flower' to not use 'checkbox'");
+
+ 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..c0d6bdbc3d6 100644
--- a/packages/dropdowns/src/elements/menu/Menu.tsx
+++ b/packages/dropdowns/src/elements/menu/Menu.tsx
@@ -52,6 +52,7 @@ export const Menu = forwardRef(
focusedValue,
getTriggerProps,
getMenuProps,
+ getAnchorProps,
getItemProps,
getItemGroupProps,
getSeparatorProps
@@ -106,11 +107,12 @@ export const Menu = forwardRef(
() => ({
isCompact,
focusedValue,
+ getAnchorProps,
getItemProps,
getItemGroupProps,
getSeparatorProps
}),
- [isCompact, focusedValue, getItemProps, getItemGroupProps, getSeparatorProps]
+ [focusedValue, getAnchorProps, getItemGroupProps, getItemProps, getSeparatorProps, isCompact]
);
return (
diff --git a/packages/dropdowns/src/elements/menu/utils.ts b/packages/dropdowns/src/elements/menu/utils.ts
index 1f7c664a078..cb9d7990aaa 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 && { external: 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..f9871566cb1
--- /dev/null
+++ b/packages/dropdowns/src/views/menu/StyledItemAnchor.ts
@@ -0,0 +1,28 @@
+/**
+ * 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 { StyledItemTypeIcon } from './StyledItemTypeIcon';
+import { StyledOption } from '../combobox/StyledOption';
+
+const COMPONENT_ID = 'dropdowns.menu.item_anchor';
+
+export const StyledItemAnchor = styled(StyledOption).attrs({
+ 'data-garden-id': COMPONENT_ID,
+ 'data-garden-version': PACKAGE_VERSION,
+ as: 'a'
+})`
+ text-decoration: none;
+ color: unset;
+
+ &[aria-current='page'] > ${StyledItemTypeIcon} {
+ opacity: 1;
+ }
+
+ ${componentStyles};
+`;