Skip to content

Commit 5ae8aba

Browse files
ze-flogeotrev
andauthored
feat(dropdowns): add navigation link support to Menu's Item component (#2021)
Co-authored-by: george treviranus <[email protected]>
1 parent e16818a commit 5ae8aba

File tree

11 files changed

+234
-46
lines changed

11 files changed

+234
-46
lines changed

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/dropdowns/demo/stories/data.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ export const ITEMS: Items = [
2323
value: 'separator',
2424
isSeparator: true
2525
},
26+
{
27+
value: 'item-anchor',
28+
label: 'Item link',
29+
href: 'https://garden.zendesk.com',
30+
isExternal: true
31+
},
2632
{
2733
value: 'item-meta',
2834
label: 'Item',

packages/dropdowns/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"dependencies": {
2424
"@floating-ui/react-dom": "^2.0.0",
2525
"@zendeskgarden/container-combobox": "^2.0.0",
26-
"@zendeskgarden/container-menu": "^0.5.1",
26+
"@zendeskgarden/container-menu": "^1.0.0",
2727
"@zendeskgarden/container-utilities": "^2.0.0",
2828
"@zendeskgarden/react-buttons": "^9.6.0",
2929
"@zendeskgarden/react-forms": "^9.6.0",

packages/dropdowns/src/context/useMenuContext.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const MenuContext = createContext<
1212
| {
1313
isCompact?: boolean;
1414
focusedValue?: string | null;
15+
getAnchorProps: IUseMenuReturnValue['getAnchorProps'];
1516
getItemGroupProps: IUseMenuReturnValue['getItemGroupProps'];
1617
getItemProps: IUseMenuReturnValue['getItemProps'];
1718
getSeparatorProps: IUseMenuReturnValue['getSeparatorProps'];

packages/dropdowns/src/elements/menu/Item.tsx

Lines changed: 80 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -12,93 +12,132 @@ import AddIcon from '@zendeskgarden/svg-icons/src/16/plus-stroke.svg';
1212
import NextIcon from '@zendeskgarden/svg-icons/src/16/chevron-right-stroke.svg';
1313
import PreviousIcon from '@zendeskgarden/svg-icons/src/16/chevron-left-stroke.svg';
1414
import CheckedIcon from '@zendeskgarden/svg-icons/src/16/check-lg-stroke.svg';
15-
import { IItemProps, OptionType as ItemType, OPTION_TYPE } from '../../types';
16-
import { StyledItem, StyledItemContent, StyledItemIcon, StyledItemTypeIcon } from '../../views';
15+
16+
import { IItemProps, OPTION_TYPE, OptionType } from '../../types';
17+
import {
18+
StyledItem,
19+
StyledItemAnchor,
20+
StyledItemContent,
21+
StyledItemIcon,
22+
StyledItemTypeIcon
23+
} from '../../views';
1724
import { ItemMeta } from './ItemMeta';
1825
import useMenuContext from '../../context/useMenuContext';
1926
import useItemGroupContext from '../../context/useItemGroupContext';
2027
import { ItemContext } from '../../context/useItemContext';
2128
import { toItem } from './utils';
2229

30+
const renderActionIcon = (itemType?: OptionType) => {
31+
switch (itemType) {
32+
case 'add':
33+
return <AddIcon />;
34+
case 'next':
35+
return <NextIcon />;
36+
case 'previous':
37+
return <PreviousIcon />;
38+
default:
39+
return <CheckedIcon />;
40+
}
41+
};
42+
43+
/**
44+
* 1. role='img' on `svg` is valid WAI-ARIA usage in this context.
45+
* https://dequeuniversity.com/rules/axe/4.2/svg-img-alt
46+
*/
47+
2348
const ItemComponent = forwardRef<HTMLLIElement, IItemProps>(
2449
(
2550
{
2651
children,
2752
value,
2853
label = value,
54+
href,
2955
isSelected,
3056
icon,
3157
isDisabled,
58+
isExternal,
3259
type,
3360
name,
3461
onClick,
3562
onKeyDown,
3663
onMouseEnter,
37-
...props
64+
...other
3865
},
3966
ref
4067
) => {
4168
const { type: selectionType } = useItemGroupContext();
42-
const { focusedValue, getItemProps, isCompact } = useMenuContext();
69+
const { focusedValue, getAnchorProps, getItemProps, isCompact } = useMenuContext();
4370
const item = {
4471
...toItem({
4572
value,
4673
label,
4774
name,
4875
type,
4976
isSelected,
50-
isDisabled
77+
isDisabled,
78+
href,
79+
isExternal
5180
}),
5281
type: selectionType
5382
};
5483

84+
const anchorProps = getAnchorProps({ item });
85+
86+
if (anchorProps && (type === 'add' || type === 'danger')) {
87+
throw new Error(`Menu link item '${value}' can't use type '${type}'`);
88+
}
89+
5590
const { ref: _itemRef, ...itemProps } = getItemProps({
5691
item,
5792
onClick,
5893
onKeyDown,
5994
onMouseEnter
6095
}) as LiHTMLAttributes<HTMLLIElement> & { ref: MutableRefObject<HTMLLIElement> };
6196

62-
const isActive = value === focusedValue;
63-
64-
const renderActionIcon = (iconType?: ItemType) => {
65-
switch (iconType) {
66-
case 'add':
67-
return <AddIcon />;
68-
69-
case 'next':
70-
return <NextIcon />;
97+
const contextValue = useMemo(() => ({ isDisabled, type }), [isDisabled, type]);
7198

72-
case 'previous':
73-
return <PreviousIcon />;
99+
const itemChildren = (
100+
<>
101+
<StyledItemTypeIcon $isCompact={isCompact} $type={type}>
102+
{renderActionIcon(type)}
103+
</StyledItemTypeIcon>
104+
{!!icon && (
105+
<StyledItemIcon $isDisabled={isDisabled} $type={type}>
106+
{icon}
107+
</StyledItemIcon>
108+
)}
109+
<StyledItemContent>{children || label}</StyledItemContent>
110+
</>
111+
);
74112

75-
default:
76-
return <CheckedIcon />;
77-
}
113+
const menuItemProps = {
114+
...other,
115+
...itemProps,
116+
ref: mergeRefs([_itemRef, ref])
78117
};
79118

80-
const contextValue = useMemo(() => ({ isDisabled, type }), [isDisabled, type]);
81-
82119
return (
83120
<ItemContext.Provider value={contextValue}>
84-
<StyledItem
85-
$type={type}
86-
$isCompact={isCompact}
87-
$isActive={isActive}
88-
{...props}
89-
{...itemProps}
90-
ref={mergeRefs([_itemRef, ref])}
91-
>
92-
<StyledItemTypeIcon $isCompact={isCompact} $type={type}>
93-
{renderActionIcon(type)}
94-
</StyledItemTypeIcon>
95-
{!!icon && (
96-
<StyledItemIcon $isDisabled={isDisabled} $type={type}>
97-
{icon}
98-
</StyledItemIcon>
99-
)}
100-
<StyledItemContent>{children || label}</StyledItemContent>
101-
</StyledItem>
121+
{anchorProps ? (
122+
<li {...menuItemProps}>
123+
<StyledItemAnchor
124+
$isCompact={isCompact}
125+
$isActive={value === focusedValue}
126+
{...anchorProps}
127+
>
128+
{itemChildren}
129+
</StyledItemAnchor>
130+
</li>
131+
) : (
132+
<StyledItem
133+
$isCompact={isCompact}
134+
$isActive={value === focusedValue}
135+
$type={type}
136+
{...menuItemProps}
137+
>
138+
{itemChildren}
139+
</StyledItem>
140+
)}
102141
</ItemContext.Provider>
103142
);
104143
}
@@ -107,9 +146,11 @@ const ItemComponent = forwardRef<HTMLLIElement, IItemProps>(
107146
ItemComponent.displayName = 'Item';
108147

109148
ItemComponent.propTypes = {
149+
href: PropTypes.string,
110150
icon: PropTypes.any,
111151
isDisabled: PropTypes.bool,
112152
isSelected: PropTypes.bool,
153+
isExternal: PropTypes.bool,
113154
label: PropTypes.string,
114155
name: PropTypes.string,
115156
type: PropTypes.oneOf(OPTION_TYPE),

packages/dropdowns/src/elements/menu/Menu.spec.tsx

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -686,4 +686,107 @@ describe('Menu', () => {
686686
expect(button).toHaveAttribute('data-garden-id', 'buttons.button');
687687
});
688688
});
689+
690+
describe('Item link behavior', () => {
691+
it('renders with href as anchor tag', async () => {
692+
const { getByTestId } = render(
693+
<TestMenu defaultExpanded>
694+
<Item value="item-01" href="https://example.com" isExternal data-test-id="item">
695+
Example Link
696+
</Item>
697+
</TestMenu>
698+
);
699+
await floating();
700+
const item = getByTestId('item');
701+
const link = item.firstChild!;
702+
703+
expect(item.nodeName).toBe('LI');
704+
expect(link.nodeName).toBe('A');
705+
expect(link).toHaveAttribute('href', 'https://example.com');
706+
expect(link).toHaveAttribute('target', '_blank');
707+
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
708+
});
709+
710+
it('renders with isExternal=false correctly', async () => {
711+
const { getByTestId } = render(
712+
<TestMenu defaultExpanded>
713+
<Item value="item-01" href="https://example.com" isExternal={false} data-test-id="item">
714+
Internal Link
715+
</Item>
716+
</TestMenu>
717+
);
718+
await floating();
719+
const item = getByTestId('item');
720+
const link = item.firstChild!;
721+
722+
expect(item.nodeName).toBe('LI');
723+
expect(link.nodeName).toBe('A');
724+
expect(link).toHaveAttribute('href', 'https://example.com');
725+
expect(link).not.toHaveAttribute('target');
726+
expect(link).not.toHaveAttribute('rel');
727+
});
728+
729+
it('renders with isSelected correctly', async () => {
730+
const { getByTestId } = render(
731+
<TestMenu defaultExpanded>
732+
<Item value="item-01" href="https://example.com" isSelected data-test-id="item">
733+
Example Link
734+
</Item>
735+
</TestMenu>
736+
);
737+
await floating();
738+
const link = getByTestId('item').firstChild!;
739+
740+
expect(link.nodeName).toBe('A');
741+
expect(link).toHaveAttribute('href', 'https://example.com');
742+
expect(link).toHaveAttribute('aria-current', 'page');
743+
});
744+
745+
it('renders with controlled selection correctly', async () => {
746+
const { getByTestId } = render(
747+
<TestMenu defaultExpanded selectedItems={[{ value: 'item-01' }]}>
748+
<Item value="item-01" href="#01" isSelected data-test-id="item-01">
749+
Link 1
750+
</Item>
751+
<Item value="item-02" href="#02" isSelected data-test-id="item-02">
752+
Link 2
753+
</Item>
754+
</TestMenu>
755+
);
756+
await floating();
757+
758+
expect(getByTestId('item-01').firstChild).toHaveAttribute('aria-current', 'page');
759+
expect(getByTestId('item-02').firstChild).not.toHaveAttribute('aria-current');
760+
});
761+
762+
it('throws error when href is used with a selection type', () => {
763+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
764+
765+
expect(() => {
766+
render(
767+
<TestMenu defaultExpanded>
768+
<ItemGroup type="checkbox" aria-label="Plants">
769+
<Item value="Flower" href="https://example.com" />
770+
</ItemGroup>
771+
</TestMenu>
772+
);
773+
}).toThrow("Error: expected useMenu anchor item 'Flower' to not use 'checkbox'");
774+
775+
consoleSpy.mockRestore();
776+
});
777+
778+
it('throws error when href is used with option type', () => {
779+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
780+
781+
expect(() => {
782+
render(
783+
<TestMenu defaultExpanded>
784+
<Item value="item-01" href="https://example.com" type="add" />
785+
</TestMenu>
786+
);
787+
}).toThrow(/can't use type/u);
788+
789+
consoleSpy.mockRestore();
790+
});
791+
});
689792
});

packages/dropdowns/src/elements/menu/Menu.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export const Menu = forwardRef<HTMLUListElement, IMenuProps>(
5252
focusedValue,
5353
getTriggerProps,
5454
getMenuProps,
55+
getAnchorProps,
5556
getItemProps,
5657
getItemGroupProps,
5758
getSeparatorProps
@@ -106,11 +107,12 @@ export const Menu = forwardRef<HTMLUListElement, IMenuProps>(
106107
() => ({
107108
isCompact,
108109
focusedValue,
110+
getAnchorProps,
109111
getItemProps,
110112
getItemGroupProps,
111113
getSeparatorProps
112114
}),
113-
[isCompact, focusedValue, getItemProps, getItemGroupProps, getSeparatorProps]
115+
[focusedValue, getAnchorProps, getItemGroupProps, getItemProps, getSeparatorProps, isCompact]
114116
);
115117

116118
return (

packages/dropdowns/src/elements/menu/utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ export const toItem = (
2222
value: props.value,
2323
label: props.label,
2424
...(props.name && { name: props.name }),
25+
...(props.href && { href: props.href }),
2526
...(props.isDisabled && { disabled: props.isDisabled }),
27+
...(props.isExternal && { external: props.isExternal }),
2628
...(props.isSelected && { selected: props.isSelected }),
2729
...(props.selectionType && { type: props.selectionType }),
2830
...(props.type === 'next' && { isNext: true }),

packages/dropdowns/src/types/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,10 +286,14 @@ export interface IItemProps extends Omit<LiHTMLAttributes<HTMLLIElement>, 'value
286286
icon?: ReactElement;
287287
/** Indicates that the item is not interactive */
288288
isDisabled?: boolean;
289+
/** Opens the `href` externally */
290+
isExternal?: boolean;
289291
/** Determines the initial selection state for the item */
290292
isSelected?: boolean;
291-
/** Sets the text label of the item (defaults to `value`) */
293+
/** Provides the text label of the item (defaults to `value`) */
292294
label?: string;
295+
/** Sets the item as an anchor */
296+
href?: string;
293297
/** Associates the item in a radio item group */
294298
name?: string;
295299
/** Determines the item type */

packages/dropdowns/src/views/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export * from './combobox/StyledValue';
3232
export * from './menu/StyledMenu';
3333
export * from './menu/StyledFloatingMenu';
3434
export * from './menu/StyledItem';
35+
export * from './menu/StyledItemAnchor';
3536
export * from './menu/StyledItemContent';
3637
export * from './menu/StyledItemGroup';
3738
export * from './menu/StyledItemIcon';

0 commit comments

Comments
 (0)