Skip to content

Commit

Permalink
fix: MenuList initial component
Browse files Browse the repository at this point in the history
  • Loading branch information
LexSwed committed Oct 25, 2020
1 parent 2a9c07f commit afd2cd0
Show file tree
Hide file tree
Showing 11 changed files with 193 additions and 18 deletions.
23 changes: 13 additions & 10 deletions src/lib/ListBox/ListBox.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FocusManagerOptions, FocusScope, useFocusManager } from '@react-aria/focus';
import { FocusScope, useFocusManager } from '@react-aria/focus';
import React from 'react';
import { styled } from '../stitches.config';
import { useKeyboardHandles } from '../utils';
Expand All @@ -8,16 +8,15 @@ const List = styled('ul', {
p: '$1',
overflowY: 'auto',
maxHeight: '240px',
$outline: -1,
});

const focusOptions: FocusManagerOptions = { wrap: true };

const ListInner: React.FC = (props) => {
const ListInner: React.FC<{ wrap?: boolean }> = ({ wrap, ...props }) => {
const { focusNext, focusPrevious } = useFocusManager();

const handleKeyDown = useKeyboardHandles({
ArrowDown: () => focusNext(focusOptions),
ArrowUp: () => focusPrevious(focusOptions),
ArrowDown: () => focusNext({ wrap }),
ArrowUp: () => focusPrevious({ wrap }),
});

return <div onKeyDown={handleKeyDown} {...props} />;
Expand All @@ -27,12 +26,16 @@ ListInner.displayName = 'ListBox.Inner';

const ListBox = React.forwardRef<
HTMLUListElement,
React.DetailedHTMLProps<React.HTMLAttributes<HTMLUListElement>, HTMLUListElement>
>(({ children, ...props }, ref) => {
React.DetailedHTMLProps<React.HTMLAttributes<HTMLUListElement>, HTMLUListElement> & {
restoreFocus?: boolean;
contain?: boolean;
wrap?: boolean;
}
>(({ children, restoreFocus, contain, wrap, ...props }, ref) => {
return (
<List role="listbox" tabIndex={-1} {...props} as="ul" ref={ref}>
<FocusScope contain restoreFocus>
<ListInner>{children}</ListInner>
<FocusScope contain={contain} restoreFocus={restoreFocus}>
<ListInner wrap={wrap}>{children}</ListInner>
</FocusScope>
</List>
);
Expand Down
8 changes: 4 additions & 4 deletions src/lib/ListItem/ListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const Item = styled(Flex as FlexType<HTMLLIElement>, {
bc: '$surfaceActive',
},

[`> ${Text}`]: {
[`& ${Text}`]: {
fontSize: 'inherit',
lineHeight: 'inherit',
},
Expand All @@ -32,7 +32,7 @@ const Item = styled(Flex as FlexType<HTMLLIElement>, {
type Props = { disabled?: boolean } & React.ComponentProps<typeof Item>;

const ListItem = React.forwardRef<HTMLLIElement, Props>(
({ flow = 'row', cross = 'center', space = '$2', disabled, ...props }, ref) => {
({ flow = 'row', cross = 'center', space = '$2', disabled, as = 'li', ...props }, ref) => {
const onMouseEnter = useAllHandlers(props.onMouseEnter, (e) => {
e.currentTarget.focus({
preventScroll: true,
Expand All @@ -49,13 +49,13 @@ const ListItem = React.forwardRef<HTMLLIElement, Props>(
<Item
role="option"
tabIndex={disabled ? undefined : -1}
{...props}
as={as}
flow={flow}
cross={cross}
space={space}
{...props}
aria-disabled={disabled}
ref={ref}
as="li"
onMouseEnter={onMouseEnter}
onKeyDown={handleKeyDown}
/>
Expand Down
13 changes: 12 additions & 1 deletion src/lib/Menu/MenuList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,18 @@ const MenuList: React.FC<UlListProps> = (props) => {

const listRef = useInitialFocus();

return <ListBox {...props} role={'menu'} id={seed('menu')} aria-labelledby={seed('button')} ref={listRef} />;
return (
<ListBox
{...props}
restoreFocus
contain
wrap
role={'menu'}
id={seed('menu')}
aria-labelledby={seed('button')}
ref={listRef}
/>
);
};

const MenuPopper: React.FC<
Expand Down
41 changes: 41 additions & 0 deletions src/lib/MenuList/Item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import React from 'react';
import ListItem from '../ListItem';
import { styled } from '../stitches.config';

const MenuItem = styled(ListItem, {
'position': 'relative',
'::after': {
position: 'absolute',
left: 0,
top: '$1',
bottom: '$1',
width: '2px',
content: `''`,
bc: 'transparent',
},
'variants': {
'aria-selected': {
true: {
'bc': '$surfaceActive',
'::after': {
bc: '$primaryActive',
},
},
false: {
'::after': {
bc: 'transparent',
},
},
},
},
});

type Props = React.ComponentProps<typeof MenuItem> & {
selected?: boolean;
};

const Item: React.FC<Props> = ({ selected, ...props }) => {
return <MenuItem {...props} role="treeitem" aria-selected={selected} tabIndex={selected ? 0 : -1} />;
};

export default Item;
54 changes: 54 additions & 0 deletions src/lib/MenuList/MenuList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React, { useCallback, useRef } from 'react';
import { getFocusableTreeWalker } from '@react-aria/focus';
import Item from './Item';
import { useAllHandlers, useKeyboardHandles } from '../utils';
import { styled } from '../stitches.config';
import Section from './Section';

const List = styled('ul', {
m: 0,
p: 0,
$outline: -1,
});

type Props = React.ComponentProps<typeof List> & {
label?: string;
};

const MenuList: React.FC<Props> & { Item: typeof Item } = ({ label, ...props }) => {
const ref = useRef<HTMLUListElement>(null);

const focusElement = useCallback((fn: (walker: TreeWalker) => Node | null) => {
let focusedElement = document.activeElement as HTMLElement;
if (!ref.current?.contains(focusedElement)) {
return;
}

// Create a DOM tree walker that matches all tabbable elements
let walker = getFocusableTreeWalker(document.body);

// Find the next tabbable element after the currently focused element
walker.currentNode = focusedElement;
let nextElement = fn(walker) as HTMLElement;
if (ref.current.contains(nextElement)) {
nextElement.focus();
}
}, []);

const handleNavigation = useKeyboardHandles({
ArrowUp: () => focusElement((walker) => walker.previousNode()),
ArrowDown: () => focusElement((walker) => walker.nextNode()),
});
const handleHeyDown = useAllHandlers(props.onKeyDown, handleNavigation);

const list = <List {...props} onKeyDown={handleHeyDown} ref={ref} />;

if (label) {
return <Section label={label}>{list}</Section>;
}
return list;
};

MenuList.Item = Item;

export default MenuList;
25 changes: 25 additions & 0 deletions src/lib/MenuList/Section.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React from 'react';
import { useUID } from 'react-uid';
import Flex from '../Flex';
import { styled } from '../stitches.config';
import Text from '../Text';
import MenuList from './index';

const Heading = styled(Text, {
pr: '$2',
textTransform: 'uppercase',
});

const Section: React.FC<{ label: string; children: React.ReactElement<typeof MenuList> }> = ({ label, children }) => {
const id = useUID();
return (
<Flex flow="column" space="xs">
<Heading id={id} font="mono" size="xs" tone="light">
{label}
</Heading>
{React.cloneElement(children, { 'aria-labelledby': id })}
</Flex>
);
};

export default Section;
1 change: 1 addition & 0 deletions src/lib/MenuList/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './MenuList';
2 changes: 1 addition & 1 deletion src/lib/Picker/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const List: React.FC<Props> = ({ triggerId, children, ...props }) => {
}, [value]);

return (
<ListBox id={`${triggerId}-listbox`} aria-labelledby={triggerId} {...props} ref={ref}>
<ListBox {...props} id={`${triggerId}-listbox`} aria-labelledby={triggerId} restoreFocus contain wrap ref={ref}>
{children}
</ListBox>
);
Expand Down
3 changes: 2 additions & 1 deletion src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ export { default as Flex } from './Flex';
export { default as Heading } from './Heading';
export { default as Icon } from './Icon';
export { default as Label } from './Label';
export { default as ListBox } from './ListBox';
export { default as MenuList } from './MenuList';
export { default as ListItem } from './ListItem';
export { default as Menu } from './Menu';
export { default as Picker } from './Picker';
export { default as Switch } from './Switch';
Expand Down
1 change: 0 additions & 1 deletion src/lib/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ export function useKeyboardHandles(handlers: KeyboardHandlers): KeyboardHandler
const handler = handlersRef.current[event.key];
if (typeof handler === 'function') {
event.preventDefault();
event.stopPropagation();
handler(event);
}
}, []);
Expand Down
40 changes: 40 additions & 0 deletions src/pages/components/MenuList.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
name: MenuList
---

import { Playground } from 'dokz';
import { Box, MenuList, ListItem, Icon, Text, Label } from '@fxtrot/ui';
import { HiOutlineUsers, HiOutlineAdjustments, HiOutlineGlobe } from 'react-icons/hi';

## MenuList

<Playground>
{() => {
const [selected, setSelected] = React.useState('Appearance');
return (
<Box width="$56" p="$2">
<MenuList label="Settings" role="navigation">
{[
{
icon: HiOutlineUsers,
label: 'Profiles',
},
{
icon: HiOutlineAdjustments,
label: 'Appearance',
},
{
icon: HiOutlineGlobe,
label: 'Language',
},
].map(({ icon, label }) => (
<MenuList.Item key={label} selected={selected === label} onClick={() => setSelected(label)}>
<Icon as={icon} />
<Text>{label}</Text>
</MenuList.Item>
))}
</MenuList>
</Box>
);
}}
</Playground>

1 comment on commit afd2cd0

@vercel
Copy link

@vercel vercel bot commented on afd2cd0 Oct 25, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.