diff --git a/packages/clay-drop-down/src/DropDown.tsx b/packages/clay-drop-down/src/DropDown.tsx index 6184a58280..c525612a8a 100644 --- a/packages/clay-drop-down/src/DropDown.tsx +++ b/packages/clay-drop-down/src/DropDown.tsx @@ -21,6 +21,10 @@ import Section from './Section'; interface IProps extends React.HTMLAttributes { /** * Flag to indicate if the DropDown menu is active or not. + * + * This API is generally used in conjunction with `closeOnClickOutside=true` + * since often we are controlling the active state by clicking another element + * within the document. */ active: boolean; @@ -34,6 +38,10 @@ interface IProps extends React.HTMLAttributes { */ containerElement?: React.JSXElementConstructor | 'div' | 'li'; + closeOnClickOutside?: React.ComponentProps< + typeof Menu + >['closeOnClickOutside']; + /** * Flag to indicate if menu contains icon symbols on the right side. */ @@ -55,6 +63,10 @@ interface IProps extends React.HTMLAttributes { /** * Callback for when the active state changes. + * + * This API is generally used in conjunction with `closeOnClickOutside=true` + * since often we are controlling the active state by clicking another element + * within the document. */ onActiveChange: (val: boolean) => void; @@ -87,6 +99,7 @@ const ClayDropDown: React.FunctionComponent & { alignmentPosition, children, className, + closeOnClickOutside, containerElement: ContainerElement = 'div', hasLeftSymbols, hasRightSymbols, @@ -156,6 +169,7 @@ const ClayDropDown: React.FunctionComponent & { active={active} alignElementRef={triggerElementRef} alignmentPosition={alignmentPosition} + closeOnClickOutside={closeOnClickOutside} hasLeftSymbols={hasLeftSymbols} hasRightSymbols={hasRightSymbols} height={menuHeight} diff --git a/packages/clay-drop-down/src/DropDownWithItems.tsx b/packages/clay-drop-down/src/DropDownWithItems.tsx index eabc0cd655..8636da8f2d 100644 --- a/packages/clay-drop-down/src/DropDownWithItems.tsx +++ b/packages/clay-drop-down/src/DropDownWithItems.tsx @@ -4,6 +4,7 @@ */ import {ClayCheckbox, ClayRadio} from '@clayui/form'; +import {useInternalState} from '@clayui/shared'; import React from 'react'; import warning from 'warning'; @@ -46,6 +47,8 @@ interface IDropDownContentProps { } export interface IProps extends IDropDownContentProps { + active?: React.ComponentProps['active']; + /** * Default position of menu element. Values come from `./Menu`. */ @@ -60,6 +63,10 @@ export interface IProps extends IDropDownContentProps { className?: string; + closeOnClickOutside?: React.ComponentProps< + typeof ClayDropDown + >['closeOnClickOutside']; + /** * HTML element tag that the container should render. */ @@ -99,6 +106,10 @@ export interface IProps extends IDropDownContentProps { */ offsetFn?: React.ComponentProps['offsetFn']; + onActiveChange?: React.ComponentProps< + typeof ClayDropDown + >['onActiveChange']; + /** * Callback will always be called when the user is interacting with search. */ @@ -291,9 +302,11 @@ const findNested = < }); export const ClayDropDownWithItems: React.FunctionComponent = ({ + active, alignmentPosition, caption, className, + closeOnClickOutside, containerElement, footerContent, helpText, @@ -302,6 +315,7 @@ export const ClayDropDownWithItems: React.FunctionComponent = ({ menuHeight, menuWidth, offsetFn, + onActiveChange, onSearchValueChange = () => {}, searchable, searchProps, @@ -309,7 +323,12 @@ export const ClayDropDownWithItems: React.FunctionComponent = ({ spritemap, trigger, }: IProps) => { - const [active, setActive] = React.useState(false); + const [internalActive, setInternalActive] = useInternalState({ + initialValue: false, + onChange: onActiveChange, + value: active, + }); + const hasRightSymbols = React.useMemo( () => !!findNested(items, 'symbolRight'), [items] @@ -323,9 +342,10 @@ export const ClayDropDownWithItems: React.FunctionComponent = ({ return ( = ({ menuHeight={menuHeight} menuWidth={menuWidth} offsetFn={offsetFn} - onActiveChange={setActive} + onActiveChange={ + setInternalActive as React.ComponentProps< + typeof ClayDropDown + >['onActiveChange'] + } trigger={trigger} > setActive(false)}} + value={{close: () => setInternalActive(false)}} > {helpText && {helpText}} diff --git a/packages/clay-drop-down/src/Menu.tsx b/packages/clay-drop-down/src/Menu.tsx index e812278a8c..7eafba6e1c 100644 --- a/packages/clay-drop-down/src/Menu.tsx +++ b/packages/clay-drop-down/src/Menu.tsx @@ -120,6 +120,11 @@ interface IProps extends React.HTMLAttributes { */ alignmentPosition?: number | TPointOptions; + /** + * Flag to indicate if clicking outside of the menu should automatically close it. + */ + closeOnClickOutside?: boolean; + /** * Flag to indicate if menu is displaying a clay-icon on the left. */ @@ -165,6 +170,7 @@ const ClayDropDownMenu = React.forwardRef( autoBestAlign = true, children, className, + closeOnClickOutside = true, hasLeftSymbols, hasRightSymbols, height, @@ -186,31 +192,33 @@ const ClayDropDownMenu = React.forwardRef( const subPortalRef = useRef(null); useEffect(() => { - const handleClick = (event: MouseEvent) => { - const nodeRefs = [alignElementRef, subPortalRef]; - const nodes: Array = (Array.isArray(nodeRefs) - ? nodeRefs - : [nodeRefs] - ) - .filter((ref) => ref.current) - .map((ref) => ref.current!); - - if ( - event.target instanceof Node && - !nodes.find((element) => - element.contains(event.target as Node) + if (closeOnClickOutside) { + const handleClick = (event: MouseEvent) => { + const nodeRefs = [alignElementRef, subPortalRef]; + const nodes: Array = (Array.isArray(nodeRefs) + ? nodeRefs + : [nodeRefs] ) - ) { - onSetActive(false); - } - }; + .filter((ref) => ref.current) + .map((ref) => ref.current!); + + if ( + event.target instanceof Node && + !nodes.find((element) => + element.contains(event.target as Node) + ) + ) { + onSetActive(false); + } + }; - window.addEventListener('mousedown', handleClick); + window.addEventListener('mousedown', handleClick); - return () => { - window.removeEventListener('mousedown', handleClick); - }; - }, []); + return () => { + window.removeEventListener('mousedown', handleClick); + }; + } + }, [closeOnClickOutside]); useEffect(() => { const handleEsc = (event: KeyboardEvent) => { diff --git a/packages/clay-drop-down/stories/index.tsx b/packages/clay-drop-down/stories/index.tsx index 66d8ffcec4..bc588ab07a 100644 --- a/packages/clay-drop-down/stories/index.tsx +++ b/packages/clay-drop-down/stories/index.tsx @@ -17,6 +17,58 @@ import ClayDropDown, { ClayDropDownWithItems, } from '../src'; +const ITEMS = [ + { + label: 'clickable', + onClick: () => { + alert('you clicked!'); + }, + }, + { + type: 'divider' as const, + }, + { + items: [ + { + label: 'one', + type: 'radio' as const, + value: 'one', + }, + { + label: 'two', + type: 'radio' as const, + value: 'two', + }, + ], + label: 'radio', + name: 'radio', + onChange: (value: string) => alert(`New Radio checked ${value}`), + type: 'radiogroup' as const, + }, + { + items: [ + { + checked: true, + label: 'checkbox', + onChange: () => alert('checkbox changed'), + type: 'checkbox' as const, + }, + { + checked: true, + label: 'checkbox 1', + onChange: () => alert('checkbox changed'), + type: 'checkbox' as const, + }, + ], + label: 'checkbox', + type: 'group' as const, + }, + { + href: '#', + label: 'linkable', + }, +]; + const DropDownWithState: React.FunctionComponent = ({ children, ...others @@ -187,91 +239,6 @@ storiesOf('Components|ClayDropDown', module) )) - .add('w/ ClayDropDownWithItems', () => { - const [value, setValue] = React.useState(''); - - const items = [ - { - label: 'clickable', - onClick: () => { - alert('you clicked!'); - }, - }, - { - type: 'divider' as const, - }, - { - items: [ - { - label: 'one', - type: 'radio' as const, - value: 'one', - }, - { - label: 'two', - type: 'radio' as const, - value: 'two', - }, - ], - label: 'radio', - name: 'radio', - onChange: (value: string) => - alert(`New Radio checked ${value}`), - type: 'radiogroup' as const, - }, - { - items: [ - { - checked: true, - label: 'checkbox', - onChange: () => alert('checkbox changed'), - type: 'checkbox' as const, - }, - { - checked: true, - label: 'checkbox 1', - onChange: () => alert('checkbox changed'), - type: 'checkbox' as const, - }, - ], - label: 'checkbox', - type: 'group' as const, - }, - { - href: '#', - label: 'linkable', - }, - ]; - - return ( - - - {'Cancel'} - - {'Done'} - - } - helpText="You can customize this menu or see all you have by pressing 'more'." - items={items} - onSearchValueChange={setValue} - searchProps={{ - formProps: { - onSubmit: (e) => { - e.preventDefault(); - alert('Submitted!'); - }, - }, - }} - searchValue={value} - searchable={boolean('Searchable', true)} - spritemap={spritemap} - trigger={{'Click Me'}} - /> - ); - }) .add('w/ custom offset', () => ( [20, 20]}> @@ -329,4 +296,81 @@ storiesOf('Components|ClayDropDown', module) spritemap={spritemap} trigger={{'Click Me'}} /> - )); + )) + .add('ClayDropDownWithItems', () => { + const [value, setValue] = React.useState(''); + + return ( + + + {'Cancel'} + + {'Done'} + + } + helpText="You can customize this menu or see all you have by pressing 'more'." + items={ITEMS} + onSearchValueChange={setValue} + searchProps={{ + formProps: { + onSubmit: (e) => { + e.preventDefault(); + alert('Submitted!'); + }, + }, + }} + searchValue={value} + searchable={boolean('Searchable', true)} + spritemap={spritemap} + trigger={{'Click Me'}} + /> + ); + }) + .add('ClayDropDownWithItems w/ custom active', () => { + const [value, setValue] = React.useState(''); + const [active, setActive] = React.useState(false); + + return ( + <> + + + {'Cancel'} + + {'Done'} + + } + helpText="You can customize this menu or see all you have by pressing 'more'." + items={ITEMS} + onActiveChange={setActive} + onSearchValueChange={setValue} + searchProps={{ + formProps: { + onSubmit: (e) => { + e.preventDefault(); + alert('Submitted!'); + }, + }, + }} + searchValue={value} + searchable={boolean('Searchable', true)} + spritemap={spritemap} + trigger={{'Click Me'}} + /> + + + + ); + }); diff --git a/packages/clay-shared/src/useInternalState.ts b/packages/clay-shared/src/useInternalState.ts index e19e2dbcf9..b1e964a7f4 100644 --- a/packages/clay-shared/src/useInternalState.ts +++ b/packages/clay-shared/src/useInternalState.ts @@ -6,6 +6,7 @@ import React from 'react'; type TOnChange = + | ((val: T) => void) | ((val?: T) => void) | React.Dispatch>;