Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
feat!: Make DropdownAPI consistent and fix keyboard handling (#843)
BREAKING CHANGE: Dropdown does not inject props or accept a children render function (it just works)
  • Loading branch information
jquense committed Mar 1, 2021
1 parent 4a2bc0c commit 3ed2d85
Show file tree
Hide file tree
Showing 7 changed files with 275 additions and 228 deletions.
71 changes: 41 additions & 30 deletions src/Dropdown.tsx
Expand Up @@ -4,8 +4,8 @@ import React, { useCallback, useRef, useEffect, useMemo } from 'react';
import PropTypes from 'prop-types';
import { useUncontrolledProp } from 'uncontrollable';
import usePrevious from '@restart/hooks/usePrevious';
import useCallbackRef from '@restart/hooks/useCallbackRef';
import useForceUpdate from '@restart/hooks/useForceUpdate';
import useGlobalListener from '@restart/hooks/useGlobalListener';
import useEventCallback from '@restart/hooks/useEventCallback';

import DropdownContext, { DropDirection } from './DropdownContext';
Expand All @@ -24,7 +24,7 @@ const propTypes = {
* },
* }) => React.Element}
*/
children: PropTypes.func.isRequired,
children: PropTypes.node,

/**
* Determines the direction and location of the Menu in relation to it's Toggle.
Expand Down Expand Up @@ -90,10 +90,24 @@ export interface DropdownProps {
alignEnd?: boolean;
defaultShow?: boolean;
show?: boolean;
onToggle: (nextShow: boolean, event?: React.SyntheticEvent) => void;
onToggle: (nextShow: boolean, event?: React.SyntheticEvent | Event) => void;
itemSelector?: string;
focusFirstItemOnShow?: false | true | 'keyboard';
children: (arg: { props: DropdownInjectedProps }) => React.ReactNode;
children: React.ReactNode;
}

function useRefWithUpdate() {
const forceUpdate = useForceUpdate();
const ref = useRef<HTMLElement | null>(null);
const attachRef = useCallback(
(element: null | HTMLElement) => {
ref.current = element;
// ensure that a menu set triggers an update for consumers
forceUpdate();
},
[forceUpdate],
);
return [ref, attachRef] as const;
}

/**
Expand All @@ -109,39 +123,30 @@ function Dropdown({
focusFirstItemOnShow,
children,
}: DropdownProps) {
const forceUpdate = useForceUpdate();
const [show, onToggle] = useUncontrolledProp(
rawShow,
defaultShow!,
rawOnToggle,
);

const [toggleElement, setToggle] = useCallbackRef<HTMLElement>();

// We use normal refs instead of useCallbackRef in order to populate the
// the value as quickly as possible, otherwise the effect to focus the element
// may run before the state value is set
const menuRef = useRef<HTMLElement | null>(null);
const [menuRef, setMenu] = useRefWithUpdate();
const menuElement = menuRef.current;

const setMenu = useCallback(
(ref: null | HTMLElement) => {
menuRef.current = ref;
// ensure that a menu set triggers an update for consumers
forceUpdate();
},
[forceUpdate],
);
const [toggleRef, setToggle] = useRefWithUpdate();
const toggleElement = toggleRef.current;

const lastShow = usePrevious(show);
const lastSourceEvent = useRef<string | null>(null);
const focusInDropdown = useRef(false);

const toggle = useCallback(
(event) => {
onToggle(!show, event);
(nextShow: boolean, event?: Event | React.SyntheticEvent) => {
onToggle(nextShow, event);
},
[onToggle, show],
[onToggle],
);

const context = useMemo(
Expand Down Expand Up @@ -223,20 +228,21 @@ function Dropdown({
return items[index];
};

const handleKeyDown = (event: React.KeyboardEvent) => {
useGlobalListener('keydown', (event: KeyboardEvent) => {
const { key } = event;
const target = event.target as HTMLElement;

const fromMenu = menuRef.current?.contains(target);
const fromToggle = toggleRef.current?.contains(target);

// Second only to https://github.com/twbs/bootstrap/blob/8cfbf6933b8a0146ac3fbc369f19e520bd1ebdac/js/src/dropdown.js#L400
// in inscrutability
const isInput = /input|textarea/i.test(target.tagName);
if (
isInput &&
(key === ' ' ||
(key !== 'Escape' &&
menuRef.current &&
menuRef.current.contains(target)))
) {
if (isInput && (key === ' ' || (key !== 'Escape' && fromMenu))) {
return;
}

if (!fromMenu && !fromToggle) {
return;
}

Expand All @@ -253,23 +259,28 @@ function Dropdown({
case 'ArrowDown':
event.preventDefault();
if (!show) {
toggle(event);
onToggle(true, event);
} else {
const next = getNextFocusedChild(target, 1);
if (next && next.focus) next.focus();
}
return;
case 'Escape':
case 'Tab':
if (key === 'Escape') {
event.preventDefault();
event.stopPropagation();
}

onToggle(false, event);
break;
default:
}
};
});

return (
<DropdownContext.Provider value={context}>
{children({ props: { onKeyDown: handleKeyDown } })}
{children}
</DropdownContext.Provider>
);
}
Expand Down
94 changes: 48 additions & 46 deletions src/DropdownMenu.tsx
@@ -1,37 +1,45 @@
import PropTypes from 'prop-types';
import React, { useContext, useRef } from 'react';
import useCallbackRef from '@restart/hooks/useCallbackRef';
import DropdownContext from './DropdownContext';
import usePopper, { UsePopperOptions, Placement, Offset } from './usePopper';
import DropdownContext, { DropdownContextValue } from './DropdownContext';
import usePopper, {
UsePopperOptions,
Placement,
Offset,
UsePopperState,
} from './usePopper';
import useRootClose, { RootCloseOptions } from './useRootClose';
import mergeOptionsWithPopperConfig from './mergeOptionsWithPopperConfig';

export interface UseDropdownMenuOptions {
flip?: boolean;
show?: boolean;
fixed?: boolean;
alignEnd?: boolean;
usePopper?: boolean;
offset?: Offset;
rootCloseEvent?: RootCloseOptions['clickTrigger'];
popperConfig?: Omit<UsePopperOptions, 'enabled' | 'placement'>;
}

export interface UseDropdownMenuValue {
export type UserDropdownMenuProps = Record<string, any> & {
ref: React.RefCallback<HTMLElement>;
style?: React.CSSProperties;
'aria-labelledby'?: string;
};

export type UserDropdownMenuArrowProps = Record<string, any> & {
ref: React.RefCallback<HTMLElement>;
style: React.CSSProperties;
};

export interface UseDropdownMenuMetadata {
show: boolean;
alignEnd?: boolean;
hasShown: boolean;
close: (e: Event) => void;
update: () => void;
forceUpdate: () => void;
props: Record<string, any> & {
ref: React.RefCallback<HTMLElement>;
style?: React.CSSProperties;
'aria-labelledby'?: string;
};
arrowProps: Record<string, any> & {
ref: React.RefCallback<HTMLElement>;
style: React.CSSProperties;
};
toggle?: DropdownContextValue['toggle'];
popper: UsePopperState | null;
arrowProps: Partial<UserDropdownMenuArrowProps>;
}

const noop: any = () => {};
Expand All @@ -57,11 +65,12 @@ export function useDropdownMenu(options: UseDropdownMenuOptions = {}) {
flip,
offset,
rootCloseEvent,
fixed = false,
popperConfig = {},
usePopper: shouldUsePopper = !!context,
} = options;

const show = context?.show == null ? options.show : context.show;
const show = context?.show == null ? !!options.show : context.show;
const alignEnd =
context?.alignEnd == null ? options.alignEnd : context.alignEnd;

Expand All @@ -80,7 +89,7 @@ export function useDropdownMenu(options: UseDropdownMenuOptions = {}) {
else if (drop === 'right') placement = alignEnd ? 'right-end' : 'right-start';
else if (drop === 'left') placement = alignEnd ? 'left-end' : 'left-start';

const { styles, attributes, ...popper } = usePopper(
const popper = usePopper(
toggleElement,
menuElement,
mergeOptionsWithPopperConfig({
Expand All @@ -89,50 +98,40 @@ export function useDropdownMenu(options: UseDropdownMenuOptions = {}) {
enableEvents: show,
offset,
flip,
fixed,
arrowElement,
popperConfig,
}),
);

let menu: Partial<UseDropdownMenuValue>;

const menuProps = {
const menuProps: UserDropdownMenuProps = {
ref: setMenu || noop,
'aria-labelledby': toggleElement?.id,
...popper.attributes.popper,
style: popper.styles.popper as any,
};

const childArgs = {
const metadata: UseDropdownMenuMetadata = {
show,
alignEnd,
hasShown: hasShownRef.current,
close: handleClose,
toggle: context?.toggle,
popper: shouldUsePopper ? popper : null,
arrowProps: shouldUsePopper
? {
ref: attachArrowRef,
...popper.attributes.arrow,
style: popper.styles.arrow as any,
}
: {},
};

if (!shouldUsePopper) {
menu = { ...childArgs, props: menuProps };
} else {
menu = {
...popper,
...childArgs,
props: {
...menuProps,
...attributes.popper,
style: styles.popper as any,
},
arrowProps: {
ref: attachArrowRef,
...attributes.arrow,
style: styles.arrow as any,
},
};
}

useRootClose(menuElement, handleClose, {
clickTrigger: rootCloseEvent,
disabled: !(menu && show),
disabled: !show,
});

return menu as UseDropdownMenuValue;
return [menuProps, metadata] as const;
}

const propTypes = {
Expand Down Expand Up @@ -199,7 +198,10 @@ const defaultProps = {
};

export interface DropdownMenuProps extends UseDropdownMenuOptions {
children: (args: UseDropdownMenuValue) => React.ReactNode;
children: (
props: UserDropdownMenuProps,
meta: UseDropdownMenuMetadata,
) => React.ReactNode;
}

/**
Expand All @@ -209,9 +211,9 @@ export interface DropdownMenuProps extends UseDropdownMenuOptions {
* @memberOf Dropdown
*/
function DropdownMenu({ children, ...options }: DropdownMenuProps) {
const args = useDropdownMenu(options);
const [props, meta] = useDropdownMenu(options);

return <>{args.hasShown ? children(args) : null}</>;
return <>{meta.hasShown ? children(props, meta) : null}</>;
}

DropdownMenu.displayName = 'ReactOverlaysDropdownMenu';
Expand Down
30 changes: 16 additions & 14 deletions src/DropdownToggle.tsx
@@ -1,14 +1,15 @@
import PropTypes from 'prop-types';
import React, { useContext } from 'react';
import React, { useContext, useCallback } from 'react';
import DropdownContext, { DropdownContextValue } from './DropdownContext';

export interface UseDropdownToggleProps {
ref: DropdownContextValue['setToggle'];
onClick: React.MouseEventHandler;
'aria-haspopup': boolean;
'aria-expanded': boolean;
}

export interface UseDropdownToggleHelpers {
export interface UseDropdownToggleMetadata {
show: DropdownContextValue['show'];
toggle: DropdownContextValue['toggle'];
}
Expand All @@ -23,13 +24,21 @@ const noop = () => {};
*/
export function useDropdownToggle(): [
UseDropdownToggleProps,
UseDropdownToggleHelpers,
UseDropdownToggleMetadata,
] {
const { show = false, toggle = noop, setToggle } =
useContext(DropdownContext) || {};
const handleClick = useCallback(
(e) => {
toggle(!show, e);
},
[show, toggle],
);

return [
{
ref: setToggle || noop,
onClick: handleClick,
'aria-haspopup': true,
'aria-expanded': !!show,
},
Expand Down Expand Up @@ -58,7 +67,8 @@ const propTypes = {

export interface DropdownToggleProps {
children: (
args: UseDropdownToggleHelpers & { props: UseDropdownToggleProps },
props: UseDropdownToggleProps,
meta: UseDropdownToggleMetadata,
) => React.ReactNode;
}

Expand All @@ -69,17 +79,9 @@ export interface DropdownToggleProps {
* @memberOf Dropdown
*/
function DropdownToggle({ children }: DropdownToggleProps) {
const [props, { show, toggle }] = useDropdownToggle();
const [props, meta] = useDropdownToggle();

return (
<>
{children({
show,
toggle,
props,
})}
</>
);
return <>{children(props, meta)}</>;
}

DropdownToggle.displayName = 'ReactOverlaysDropdownToggle';
Expand Down

0 comments on commit 3ed2d85

Please sign in to comment.