Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: Make DropdownAPI consistent and fix keyboard handling #843

Merged
merged 3 commits into from Mar 1, 2021
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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();
Copy link
Member

Choose a reason for hiding this comment

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

question: do these get properly enqueued/deduped, or could we end up with a double-rerender?

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm fairly sure it does, maybe not a big deal either way idk

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