Skip to content

feat(dropdowns): add navigation link support to Menu's Item component #2016

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/dropdowns/demo/stories/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
127 changes: 89 additions & 38 deletions packages/dropdowns/src/elements/menu/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <AddIcon />;
case 'next':
return <NextIcon />;
case 'previous':
return <PreviousIcon />;
default:
return <CheckedIcon />;
}
};

/**
* 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<HTMLLIElement, IItemProps>(
(
{
children,
value,
label = value,
href,
isSelected,
icon,
isDisabled,
isExternal,
type,
name,
onClick,
Expand All @@ -47,58 +82,72 @@ const ItemComponent = forwardRef<HTMLLIElement, IItemProps>(
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,
onKeyDown,
onMouseEnter
}) as LiHTMLAttributes<HTMLLIElement> & { ref: MutableRefObject<HTMLLIElement> };

const isActive = value === focusedValue;

const renderActionIcon = (iconType?: ItemType) => {
switch (iconType) {
case 'add':
return <AddIcon />;

case 'next':
return <NextIcon />;
const contextValue = useMemo(() => ({ isDisabled, type }), [isDisabled, type]);

case 'previous':
return <PreviousIcon />;
const itemChildren = (
<>
<StyledItemTypeIcon $isCompact={isCompact} $type={type}>
{renderActionIcon(type)}
</StyledItemTypeIcon>
{!!icon && (
<StyledItemIcon $isDisabled={isDisabled} $type={type}>
{icon}
</StyledItemIcon>
)}
<StyledItemContent>{children || label}</StyledItemContent>
</>
);

default:
return <CheckedIcon />;
}
const menuItemProps = {
$isCompact: isCompact,
$isActive: value === focusedValue,
$type: type,
...props,
...itemProps,
ref: mergeRefs([_itemRef, ref])
};

const contextValue = useMemo(() => ({ isDisabled, type }), [isDisabled, type]);

return (
<ItemContext.Provider value={contextValue}>
<StyledItem
$type={type}
$isCompact={isCompact}
$isActive={isActive}
{...props}
{...itemProps}
ref={mergeRefs([_itemRef, ref])}
>
<StyledItemTypeIcon $isCompact={isCompact} $type={type}>
{renderActionIcon(type)}
</StyledItemTypeIcon>
{!!icon && (
<StyledItemIcon $isDisabled={isDisabled} $type={type}>
{icon}
</StyledItemIcon>
)}
<StyledItemContent>{children || label}</StyledItemContent>
</StyledItem>
{hasAnchor ? (
<li role="none">
<StyledItemAnchor
{...(menuItemProps as AnchorHTMLAttributes<HTMLAnchorElement>)}
href={href}
target={isExternal ? '_blank' : undefined}
// legacy browsers safeguards
rel={isExternal ? 'noopener noreferrer' : undefined}
>
{itemChildren}
</StyledItemAnchor>
</li>
) : (
<StyledItem {...menuItemProps}>{itemChildren}</StyledItem>
)}
</ItemContext.Provider>
);
}
Expand All @@ -107,9 +156,11 @@ const ItemComponent = forwardRef<HTMLLIElement, IItemProps>(
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),
Expand Down
64 changes: 64 additions & 0 deletions packages/dropdowns/src/elements/menu/Menu.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<TestMenu defaultExpanded>
<Item value="item-01" href="https://example.com" isExternal data-test-id="item">
Example Link
</Item>
</TestMenu>
);
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(
<TestMenu defaultExpanded>
<Item value="item-01" href="https://example.com" isExternal={false} data-test-id="item">
Internal Link
</Item>
</TestMenu>
);
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(
<TestMenu defaultExpanded>
<ItemGroup type="checkbox" aria-label="Plants">
<Item value="Flower" href="https://example.com" />
</ItemGroup>
</TestMenu>
);
}).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(
<TestMenu defaultExpanded>
<Item value="item-01" href="https://example.com" type="add" />
</TestMenu>
);
}).toThrow(/can't use type/u);

consoleSpy.mockRestore();
});
});
});
2 changes: 2 additions & 0 deletions packages/dropdowns/src/elements/menu/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
6 changes: 5 additions & 1 deletion packages/dropdowns/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,10 +286,14 @@ export interface IItemProps extends Omit<LiHTMLAttributes<HTMLLIElement>, '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 */
Expand Down
1 change: 1 addition & 0 deletions packages/dropdowns/src/views/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
24 changes: 24 additions & 0 deletions packages/dropdowns/src/views/menu/StyledItemAnchor.ts
Original file line number Diff line number Diff line change
@@ -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};
`;