Skip to content

Commit cfa0aa1

Browse files
authored
feat(menu): adds support for link items (#648)
1 parent f20538e commit cfa0aa1

File tree

10 files changed

+170
-31
lines changed

10 files changed

+170
-31
lines changed

.storybook/preview.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const parameters = {
3131
};
3232

3333
const GlobalStyle = createGlobalStyle`
34-
:focus {
34+
:focus-visible {
3535
outline-color: ${p => getColor('primaryHue', 600, p.theme)};
3636
}
3737
`;

packages/menu/README.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ const Menu = () => {
6161
const items = [
6262
{ value: 'value-1', label: 'One' },
6363
{ value: 'value-2', label: 'Two' },
64-
{ value: 'value-3', label: 'Three' }
64+
{ value: 'value-3', label: 'Three', href: '#0' },
65+
{ value: 'value-4', label: 'Four' }
6566
];
6667

6768
return (
@@ -70,11 +71,17 @@ const Menu = () => {
7071
<>
7172
<button {...getTriggerProps()}>Menu</button>
7273
<ul {...getMenuProps()} style={{ visibility: isExpanded ? 'visible' : 'hidden' }}>
73-
{items.map(item => (
74-
<li key={item.value} {...getItemProps({ item })}>
75-
{item.label}
76-
</li>
77-
))}
74+
{items.map(item =>
75+
item.href ? (
76+
<li key={item.value} role="none">
77+
<a {...getItemProps({ item })}>{item.label}</a>
78+
</li>
79+
) : (
80+
<li key={item.value} {...getItemProps({ item })}>
81+
{item.label}
82+
</li>
83+
)
84+
)}
7885
</ul>
7986
</>
8087
)}

packages/menu/demo/stories/MenuStory.tsx

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* found at http://www.apache.org/licenses/LICENSE-2.0.
66
*/
77

8-
import React, { useRef } from 'react';
8+
import React, { AnchorHTMLAttributes, LiHTMLAttributes, useRef } from 'react';
99
import { StoryFn } from '@storybook/react';
1010
import classNames from 'classnames';
1111
import {
@@ -33,23 +33,49 @@ type MenuItemProps = {
3333
isSelected?: boolean;
3434
};
3535

36-
const Item = ({ item, getItemProps, focusedValue, isSelected }: MenuItemProps) => (
37-
<li
38-
className={classNames({
39-
'bg-blue-100': !item.disabled && focusedValue === item.value,
40-
'text-grey-400': item.disabled,
41-
'cursor-pointer': !item.disabled,
42-
'cursor-default': item.disabled
43-
})}
44-
{...getItemProps({ item })}
45-
>
46-
<span className="inline-flex justify-center items-center w-4">
47-
{item?.type === 'radio' && isSelected && '•'}
48-
{item?.type === 'checkbox' && isSelected && '✓'}
49-
</span>
50-
{item.label || item.value}
51-
</li>
52-
);
36+
const Item = ({ item, getItemProps, focusedValue, isSelected }: MenuItemProps) => {
37+
const itemProps = getItemProps({ item });
38+
39+
const itemChildren = (
40+
<>
41+
<span className="inline-flex justify-center items-center w-4">
42+
{item?.type === 'radio' && isSelected && '•'}
43+
{item?.type === 'checkbox' && isSelected && '✓'}
44+
</span>
45+
{item.label || item.value}
46+
</>
47+
);
48+
49+
return (
50+
<li
51+
className={classNames('flex', {
52+
'bg-blue-100': !item.disabled && focusedValue === item.value,
53+
'text-grey-400': item.disabled,
54+
'cursor-pointer': !item.disabled,
55+
'cursor-default': item.disabled
56+
})}
57+
role={itemProps.href ? 'none' : undefined}
58+
{...(!itemProps.href && (itemProps as LiHTMLAttributes<HTMLLIElement>))}
59+
>
60+
{itemProps.href ? (
61+
<a
62+
{...(itemProps as AnchorHTMLAttributes<HTMLAnchorElement>)}
63+
className="w-full rounded-sm outline-offset-0 transition-none border-width-none"
64+
>
65+
{itemChildren}
66+
{item.isExternal && (
67+
<>
68+
<span aria-hidden="true"></span>
69+
<span className="sr-only">(opens in new window)</span>
70+
</>
71+
)}
72+
</a>
73+
) : (
74+
itemChildren
75+
)}
76+
</li>
77+
);
78+
};
5379

5480
const Component = ({
5581
items,
@@ -66,7 +92,9 @@ const Component = ({
6692

6793
return (
6894
<div className="relative">
69-
<button {...getTriggerProps()}>Produce</button>
95+
<button className="px-2 py-1" type="button" {...getTriggerProps()}>
96+
Produce
97+
</button>
7098

7199
<ul
72100
className={classNames('border border-grey-400 border-solid w-32 absolute', {

packages/menu/demo/stories/data.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@ export const ITEMS: MenuItem[] = [
1212
{ value: 'plant-02', label: 'Hydrangea' },
1313
{ value: 'separator-01', separator: true },
1414
{ value: 'plant-03', label: 'Violet' },
15-
{ value: 'plant-04', label: 'Succulent' },
15+
{
16+
value: 'plant-04',
17+
label: 'Aloe Vera',
18+
href: 'https://en.wikipedia.org/wiki/Aloe_vera',
19+
isExternal: false
20+
},
21+
{ value: 'plant-05', label: 'Succulent' },
1622
{
1723
label: 'Choose favorites',
1824
items: [

packages/menu/src/MenuContainer.spec.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,16 @@ describe('MenuContainer', () => {
254254
expect(menu).not.toBeVisible();
255255
});
256256

257+
it('applies external anchor attributes', () => {
258+
const { getByTestId } = render(
259+
<TestMenu items={[{ value: 'item', href: '#0', isExternal: true }]} />
260+
);
261+
const menu = getByTestId('menu');
262+
263+
expect(menu.firstChild).toHaveAttribute('target', '_blank');
264+
expect(menu.firstChild).toHaveAttribute('rel', 'noopener noreferrer');
265+
});
266+
257267
describe('focus', () => {
258268
describe('trigger', () => {
259269
it('focuses first item on arrow keydown', async () => {
@@ -1187,4 +1197,22 @@ describe('MenuContainer', () => {
11871197
});
11881198
});
11891199
});
1200+
1201+
describe('error handling', () => {
1202+
it.each([
1203+
{ key: 'isNext', isNext: true },
1204+
{ key: 'isPrevious', isPrevious: true }
1205+
])("throws when anchor item uses '$key'", itemProps => {
1206+
expect(() =>
1207+
render(<TestMenu items={[{ value: 'test', href: '#0', ...itemProps }]} />)
1208+
).toThrow();
1209+
});
1210+
1211+
it.each<'radio' | 'checkbox'>(['radio', 'checkbox'])(
1212+
"throws when anchor item is '%s' type",
1213+
type => {
1214+
expect(() => render(<TestMenu items={[{ value: 'test', href: '#0', type }]} />)).toThrow();
1215+
}
1216+
);
1217+
});
11901218
});

packages/menu/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export interface ISelectedItem {
1313
name?: string;
1414
type?: 'radio' | 'checkbox';
1515
disabled?: boolean;
16+
href?: string;
17+
isExternal?: boolean;
1618
}
1719

1820
export interface IMenuItemBase extends ISelectedItem {

packages/menu/src/useMenu.ts

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ import {
2727
isValidItem,
2828
StateChangeTypes,
2929
stateReducer,
30-
toMenuItemKeyDownType
30+
toMenuItemKeyDownType,
31+
triggerLink
3132
} from './utils';
3233
import {
3334
MenuItem,
@@ -124,7 +125,8 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
124125
values,
125126
direction: 'vertical',
126127
selectedValue: focusedValue || uncontrolledFocusedValue,
127-
focusedValue: focusedValue || uncontrolledFocusedValue
128+
focusedValue: focusedValue || uncontrolledFocusedValue,
129+
allowDefaultOnSelect: true
128130
});
129131

130132
/**
@@ -242,6 +244,27 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
242244
[controlledSelectedItems]
243245
);
244246

247+
const anchorItemError = ({ isNext, isPrevious, type, value }: IMenuItemBase) => {
248+
let invariantKey: string;
249+
250+
if (isNext) {
251+
invariantKey = 'isNext';
252+
} else if (isPrevious) {
253+
invariantKey = 'isPrevious';
254+
} else {
255+
invariantKey = type!;
256+
}
257+
258+
const invariantType = {
259+
isNext: 'isNext',
260+
isPrevious: 'isPrevious',
261+
radio: 'radio',
262+
checkbox: 'checkbox'
263+
}[invariantKey];
264+
265+
throw new Error(`Error: expected useMenu anchor item '${value}' to not use '${invariantType}'`);
266+
};
267+
245268
// Event
246269

247270
const handleTriggerClick = useCallback(
@@ -425,6 +448,16 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
425448
...(nextSelection && { selectedItems: nextSelection })
426449
};
427450

451+
if (item.href) {
452+
if (key === KEYS.SPACE) {
453+
event.preventDefault();
454+
455+
triggerLink(event.target as HTMLAnchorElement, environment || window);
456+
}
457+
} else {
458+
event.preventDefault();
459+
}
460+
428461
if (!isTransitionItem) {
429462
focusTriggerRef.current = true;
430463
}
@@ -438,6 +471,8 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
438471
}
439472

440473
if (changeType) {
474+
event.preventDefault();
475+
441476
changes = { value: item.value };
442477
}
443478
} else if (key === KEYS.LEFT) {
@@ -450,9 +485,13 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
450485
}
451486

452487
if (changeType) {
488+
event.preventDefault();
489+
453490
changes = { value: item.value };
454491
}
455492
} else if (isVerticalArrowKeys || isJumpKey || isAlphanumericChar) {
493+
event.preventDefault();
494+
456495
changeType = isAlphanumericChar
457496
? StateChangeTypes.MenuItemKeyDown
458497
: StateChangeTypes[toMenuItemKeyDownType(key)];
@@ -470,7 +509,6 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
470509
}
471510

472511
if (changeType) {
473-
event.preventDefault();
474512
event.stopPropagation();
475513

476514
const transitionNext = changeType.includes('next');
@@ -497,6 +535,7 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
497535
isFocusedValueControlled,
498536
isSelectedItemsControlled,
499537
focusTriggerRef,
538+
environment,
500539
getNextFocusedValue,
501540
getSelectedItems,
502541
onChange
@@ -700,6 +739,8 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
700739
type,
701740
name,
702741
value,
742+
href,
743+
isExternal,
703744
isNext = false,
704745
isPrevious = false,
705746
label = value
@@ -729,12 +770,26 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
729770
'aria-checked': selected,
730771
'aria-disabled': itemDisabled,
731772
role: itemRole === null ? undefined : itemRole,
773+
href,
732774
onClick,
733775
onKeyDown,
734776
onMouseEnter,
735777
...other
736778
};
737779

780+
if (href && isExternal) {
781+
elementProps.target = '_blank';
782+
elementProps.rel = 'noopener noreferrer';
783+
}
784+
785+
/**
786+
* Validation
787+
*/
788+
789+
if (href && (isNext || isPrevious || type)) {
790+
anchorItemError(item);
791+
}
792+
738793
if (itemDisabled) {
739794
return elementProps;
740795
}

packages/menu/src/utils.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@ import { Reducer } from 'react';
99
import { KEYS } from '@zendeskgarden/container-utilities';
1010
import { MenuItem, IMenuItemBase, IMenuItemSeparator, ISelectedItem } from './types';
1111

12+
export const triggerLink = (element: HTMLAnchorElement, view: Window) => {
13+
const event = new MouseEvent('click', {
14+
bubbles: true,
15+
cancelable: true,
16+
view
17+
});
18+
19+
element.dispatchEvent(event);
20+
};
21+
1222
export const StateChangeTypes: Record<string, string> = {
1323
FnSetStateRefs: 'fn:setStateRefs',
1424
FnMenuTransitionFinish: 'fn:menuTransitionFinish',

packages/selection/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export interface IUseSelectionProps<Value> {
2222
selectedValue?: Value;
2323
/** Sets controlled value focus */
2424
focusedValue?: Value;
25+
/** Allows `preventDefault` to be called on selection */
26+
allowDefaultOnSelect?: boolean;
2527
/**
2628
* Handles controlled value selection
2729
*

packages/selection/src/useSelection.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const useSelection = <Value>({
2727
rtl,
2828
selectedValue,
2929
focusedValue,
30+
allowDefaultOnSelect = false,
3031
onSelect,
3132
onFocus
3233
}: IUseSelectionProps<Value>): IUseSelectionReturnValue<Value> => {
@@ -199,7 +200,7 @@ export const useSelection = <Value>({
199200
onSelect && onSelect(value);
200201
!isSelectedValueControlled && dispatch({ type: 'KEYBOARD_SELECT', payload: value });
201202

202-
event.preventDefault();
203+
!allowDefaultOnSelect && event.preventDefault();
203204
}
204205
}
205206
};

0 commit comments

Comments
 (0)