Skip to content
Merged
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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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
2 changes: 1 addition & 1 deletion packages/dropdowns/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/dropdowns/src/context/useMenuContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down
119 changes: 80 additions & 39 deletions packages/dropdowns/src/elements/menu/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,93 +12,132 @@ 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 <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,
onKeyDown,
onMouseEnter,
...props
...other
},
ref
) => {
const { type: selectionType } = useItemGroupContext();
const { focusedValue, getItemProps, isCompact } = useMenuContext();
const { focusedValue, getAnchorProps, getItemProps, isCompact } = useMenuContext();
const item = {
...toItem({
value,
label,
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,
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 = {
...other,
...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>
{anchorProps ? (
<li {...menuItemProps}>
<StyledItemAnchor
$isCompact={isCompact}
$isActive={value === focusedValue}
{...anchorProps}
>
{itemChildren}
</StyledItemAnchor>
</li>
) : (
<StyledItem
$isCompact={isCompact}
$isActive={value === focusedValue}
$type={type}
{...menuItemProps}
>
{itemChildren}
</StyledItem>
)}
</ItemContext.Provider>
);
}
Expand All @@ -107,9 +146,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
103 changes: 103 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,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(
<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');
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(
<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');
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(
<TestMenu defaultExpanded>
<Item value="item-01" href="https://example.com" isSelected data-test-id="item">
Example Link
</Item>
</TestMenu>
);
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(
<TestMenu defaultExpanded selectedItems={[{ value: 'item-01' }]}>
<Item value="item-01" href="#01" isSelected data-test-id="item-01">
Link 1
</Item>
<Item value="item-02" href="#02" isSelected data-test-id="item-02">
Link 2
</Item>
</TestMenu>
);
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(
<TestMenu defaultExpanded>
<ItemGroup type="checkbox" aria-label="Plants">
<Item value="Flower" href="https://example.com" />
</ItemGroup>
</TestMenu>
);
}).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(
<TestMenu defaultExpanded>
<Item value="item-01" href="https://example.com" type="add" />
</TestMenu>
);
}).toThrow(/can't use type/u);

consoleSpy.mockRestore();
});
});
});
4 changes: 3 additions & 1 deletion packages/dropdowns/src/elements/menu/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export const Menu = forwardRef<HTMLUListElement, IMenuProps>(
focusedValue,
getTriggerProps,
getMenuProps,
getAnchorProps,
getItemProps,
getItemGroupProps,
getSeparatorProps
Expand Down Expand Up @@ -106,11 +107,12 @@ export const Menu = forwardRef<HTMLUListElement, IMenuProps>(
() => ({
isCompact,
focusedValue,
getAnchorProps,
getItemProps,
getItemGroupProps,
getSeparatorProps
}),
[isCompact, focusedValue, getItemProps, getItemGroupProps, getSeparatorProps]
[focusedValue, getAnchorProps, getItemGroupProps, getItemProps, getSeparatorProps, isCompact]
);

return (
Expand Down
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 && { external: 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
Loading