diff --git a/src/components/Accordion/__snapshots__/index.spec.jsx.snap b/src/components/Accordion/__snapshots__/index.spec.jsx.snap index 980d9f230..598b8dee8 100644 --- a/src/components/Accordion/__snapshots__/index.spec.jsx.snap +++ b/src/components/Accordion/__snapshots__/index.spec.jsx.snap @@ -19,12 +19,12 @@ exports[` should have default props 1`] = ` data-test-selector="panel-1" data-testid="panel-wrapper" > -
Panel 1 -
+
{ isLoading?: boolean; color?: ButtonColor; variant?: ButtonVariant; size?: ButtonSize; + iconPosition?: ButtonIconPosition; /** * @deprecated * Please use the `color` prop instead. diff --git a/src/components/Button/index.jsx b/src/components/Button/index.jsx index 36aa242ac..49a82a064 100644 --- a/src/components/Button/index.jsx +++ b/src/components/Button/index.jsx @@ -17,7 +17,7 @@ export const buttonSharedClasses = ({ size, inverse, variant, fullWidth, round, disabled: disabled, }); -const Button = (props) => { +const Button = React.forwardRef((props, ref) => { const { color = 'default', size, @@ -30,6 +30,7 @@ const Button = (props) => { disabled, dts, isLoading, + iconPosition = 'left', inverse, // deprecated theme, // deprecated ...rest @@ -68,9 +69,12 @@ const Button = (props) => {
) : null; - + const renderIcon = () => ( + {icon} + ); return ( ); -}; +}); export const colors = ['default', 'primary', 'secondary', 'success', 'danger', 'warning', 'info']; export const variants = ['solid', 'borderless', 'inverse', 'link']; export const sizes = ['medium', 'large']; +export const positions = ['left', 'right']; Button.propTypes = { isLoading: PropTypes.bool, color: PropTypes.oneOf(colors), variant: PropTypes.oneOf(variants), size: PropTypes.oneOf(sizes), + iconPosition: PropTypes.oneOf(positions), /** * @deprecated * Please use the `color` prop instead. @@ -121,4 +126,6 @@ Button.propTypes = { children: PropTypes.node, }; +Button.displayName = 'Button'; + export default Button; diff --git a/src/components/Button/styles.css b/src/components/Button/styles.css index 855a00ebf..d92e4317d 100644 --- a/src/components/Button/styles.css +++ b/src/components/Button/styles.css @@ -95,7 +95,8 @@ $color-secondary-active: $color-teal-700; border-color: $color-primary-strong; } - &:active { + &:active, + &.active { color: $color-text-inverse; background-color: $color-primary-active; border-color: $color-primary-active; @@ -113,7 +114,8 @@ $color-secondary-active: $color-teal-700; border-color: $color-secondary-strong; } - &:active { + &:active, + &.active { color: $color-text-inverse; background-color: $color-secondary-active; border-color: $color-secondary-active; @@ -131,7 +133,8 @@ $color-secondary-active: $color-teal-700; border-color: $color-success-strong; } - &:active { + &:active, + &.active { color: $color-text-inverse; background-color: $color-success-active; border-color: $color-success-active; @@ -149,7 +152,8 @@ $color-secondary-active: $color-teal-700; border-color: $color-info-strong; } - &:active { + &:active, + &.active { color: $color-text-inverse; background-color: $color-info-active; border-color: $color-info-active; @@ -167,7 +171,8 @@ $color-secondary-active: $color-teal-700; border-color: $color-warning-strong; } - &:active { + &:active, + &.active { color: $color-text-inverse; background-color: $color-warning-active; border-color: $color-warning-active; @@ -185,7 +190,8 @@ $color-secondary-active: $color-teal-700; border-color: $color-danger-strong; } - &:active { + &:active, + &.active { color: $color-text-inverse; background-color: $color-danger-active; border-color: $color-danger-active; @@ -206,7 +212,8 @@ $color-secondary-active: $color-teal-700; border-color: $color-grey-500; } - &:active { + &:active, + &.active { color: $color-text-inverse; background-color: $color-grey-400; border-color: $color-grey-500; @@ -227,7 +234,8 @@ $color-secondary-active: $color-teal-700; border-color: $color-grey-500; } - &:active { + &:active, + &.active { background-color: $color-default-active; border-color: $color-grey-500; } diff --git a/src/components/ButtonGroup/index.jsx b/src/components/ButtonGroup/index.jsx index 002ffbb26..a015196e4 100644 --- a/src/components/ButtonGroup/index.jsx +++ b/src/components/ButtonGroup/index.jsx @@ -2,9 +2,12 @@ import _ from 'lodash'; import React from 'react'; import PropTypes from 'prop-types'; import Button from '../Button'; +import DropdownMenu from '../DropdownMenu'; import { expandDts } from '../../utils'; import './styles.css'; +const buttonNames = [Button.displayName, DropdownMenu.Trigger.displayName]; + class ButtonGroup extends React.PureComponent { injectProps(children) { return React.Children.map(children, (child) => { @@ -16,18 +19,18 @@ class ButtonGroup extends React.PureComponent { ...(this.props.size ? { size: this.props.size } : {}), }; - const childNodes = React.Children.map(child.props.children, (childNode) => - React.isValidElement(childNode) + const childNodes = React.Children.map(child.props.children, (childNode) => { + return React.isValidElement(childNode) ? React.cloneElement(childNode, { ...childNode.props, - ...(childNode.type.name === Button.name ? buttonProps : {}), + ...(buttonNames.includes(childNode.type.displayName) ? buttonProps : {}), }) - : childNode - ); + : childNode; + }); return React.cloneElement(child, { ...child.props, - ...(child.type.name === Button.name ? buttonProps : {}), + ...(buttonNames.includes(child.type.displayName) ? buttonProps : {}), ...(!_.isEmpty(childNodes) ? { children: childNodes.length === 1 ? childNodes[0] : childNodes } : {}), }); } diff --git a/src/components/ButtonGroup/index.spec.jsx b/src/components/ButtonGroup/index.spec.jsx index bdba8d293..ab4f971c6 100644 --- a/src/components/ButtonGroup/index.spec.jsx +++ b/src/components/ButtonGroup/index.spec.jsx @@ -42,11 +42,12 @@ describe('', () => { const { getByTestId } = render(
-
foo
+
foo
); + expect(getByTestId('foo')).toBeEnabled(); expect(getByTestId('button-wrapper')).toBeDisabled(); expect(getByTestId('button-wrapper')).toHaveClass('aui-large'); }); diff --git a/src/components/Checkbox/index.d.ts b/src/components/Checkbox/index.d.ts index 076fa4ed3..1228be437 100644 --- a/src/components/Checkbox/index.d.ts +++ b/src/components/Checkbox/index.d.ts @@ -1,37 +1,12 @@ import * as React from 'react'; +export type CheckboxValue = string | number; + export type CheckboxVariant = 'default' | 'box'; export type CheckboxChecked = boolean | 'partial'; -export type CheckboxValue = string | number; - export interface CheckboxProps { - /** - * name for the checkbox input - */ - name?: string; - variant?: CheckboxVariant; - /** - * @function onChange called when checkBox onChange event is fired - * @param {string|boolean} nextState - the checked state - * @param {string} name - the checkbox name - * @param {string|number} value - the checkbox value - */ - onChange?: (...args: any[]) => any; - onKeyDown?: (...args: any[]) => any; - /** - * checked status of the input checkBox: oneOf([true, false, 'partial'] - */ - checked?: CheckboxChecked; - /** - * @deprecated - */ - size?: number; - /** - * @deprecated - */ - inline?: boolean; /** * checkBox input value */ @@ -61,6 +36,31 @@ export interface CheckboxProps { * determines if the checkbox is disabled */ disabled?: boolean; + /** + * name for the checkbox input + */ + name?: string; + variant?: CheckboxVariant; + /** + * @function onChange called when checkBox onChange event is fired + * @param {string|boolean} nextState - the checked state + * @param {string} name - the checkbox name + * @param {string|number} value - the checkbox value + */ + onChange?: (...args: any[]) => any; + onKeyDown?: (...args: any[]) => any; + /** + * checked status of the input checkBox: oneOf([true, false, 'partial'] + */ + checked?: CheckboxChecked; + /** + * @deprecated + */ + size?: number; + /** + * @deprecated + */ + inline?: boolean; } declare const Checkbox: React.FC; diff --git a/src/components/Checkbox/index.jsx b/src/components/Checkbox/index.jsx index 0ead31813..e367dca29 100644 --- a/src/components/Checkbox/index.jsx +++ b/src/components/Checkbox/index.jsx @@ -49,9 +49,9 @@ const Checkbox = ({ return (
{ /> ); }; - -CheckboxGroupAll.propTypes = { +export const checkboxGroupAllPropTypes = { label: PropTypes.node, className: PropTypes.string, /** @@ -59,6 +58,10 @@ CheckboxGroupAll.propTypes = { values: PropTypes.array.isRequired, }; +CheckboxGroupAll.propTypes = { + ...checkboxGroupAllPropTypes, +}; + const CheckboxGroup = ({ name, value, @@ -98,9 +101,9 @@ const CheckboxGroup = ({ ); }; -CheckboxGroup.propTypes = { - value: PropTypes.array, - name: PropTypes.string, +export const checkboxGroupPropTypes = { + value: PropTypes.array.isRequired, + name: PropTypes.string.isRequired, /** * @function onChange * @param {array} newValue - the new checkboxGroup value @@ -131,5 +134,8 @@ CheckboxGroup.Item = CheckboxGroupItem; CheckboxGroup.All = CheckboxGroupAll; export { useCheckboxGroup } from './CheckboxGroupContext'; +CheckboxGroup.propTypes = { + ...checkboxGroupPropTypes, +}; export default CheckboxGroup; diff --git a/src/components/CheckboxGroup/index.spec.jsx b/src/components/CheckboxGroup/index.spec.jsx index 0e5e2c363..e2f59b847 100644 --- a/src/components/CheckboxGroup/index.spec.jsx +++ b/src/components/CheckboxGroup/index.spec.jsx @@ -200,7 +200,7 @@ describe('', () => { const onChange = jest.fn(); const { container } = render( - + diff --git a/src/components/DismissibleFocusTrap/index.d.ts b/src/components/DismissibleFocusTrap/index.d.ts index cc3a47cb1..499329606 100644 --- a/src/components/DismissibleFocusTrap/index.d.ts +++ b/src/components/DismissibleFocusTrap/index.d.ts @@ -15,6 +15,9 @@ export interface DismissibleFocusTrapProps { disabled?: boolean; onEscape?: (...args: any[]) => any; onClickOutside?: (...args: any[]) => any; + /** + * useful if a menu/popover should be closed after tabbing through it + */ onTabExit?: (...args: any[]) => any; onShiftTabExit?: (...args: any[]) => any; children?: React.ReactNode; diff --git a/src/components/DismissibleFocusTrap/index.jsx b/src/components/DismissibleFocusTrap/index.jsx index 34eca0bc4..11981b21f 100644 --- a/src/components/DismissibleFocusTrap/index.jsx +++ b/src/components/DismissibleFocusTrap/index.jsx @@ -148,6 +148,9 @@ DismissibleFocusTrap.propTypes = { disabled: PropTypes.bool, onEscape: PropTypes.func, onClickOutside: PropTypes.func, + /** + * useful if a menu/popover should be closed after tabbing through it + */ onTabExit: PropTypes.func, onShiftTabExit: PropTypes.func, children: PropTypes.node, diff --git a/src/components/DropdownMenu/index.d.ts b/src/components/DropdownMenu/index.d.ts new file mode 100644 index 000000000..dee26c2cb --- /dev/null +++ b/src/components/DropdownMenu/index.d.ts @@ -0,0 +1,333 @@ +import * as React from 'react'; + +export type DropdownMenuContentChildren = ((...args: any[]) => any) | React.ReactNode; + +export type DropdownMenuContentPlacement = + | 'auto' + | 'top' + | 'right' + | 'bottom' + | 'left' + | 'auto-start' + | 'top-start' + | 'right-start' + | 'bottom-start' + | 'left-start' + | 'auto-end' + | 'top-end' + | 'right-end' + | 'bottom-end' + | 'left-end'; + +export type DropdownMenuContentModifiers = Object | Object[]; + +export interface DropdownMenuContentProps { + children?: DropdownMenuContentChildren; + className?: string; + dts?: string; + id?: string; + placement?: DropdownMenuContentPlacement; + modifiers?: DropdownMenuContentModifiers; +} + +declare const DropdownMenuContent: React.FC; + +export type DropdownMenuTriggerColor = 'default' | 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info'; + +export type DropdownMenuTriggerVariant = 'solid' | 'borderless' | 'inverse' | 'link'; + +export type DropdownMenuTriggerSize = 'medium' | 'large'; + +export type DropdownMenuTriggerIconPosition = 'left' | 'right'; + +export interface DropdownMenuTriggerProps { + isLoading?: boolean; + color?: DropdownMenuTriggerColor; + variant?: DropdownMenuTriggerVariant; + size?: DropdownMenuTriggerSize; + iconPosition?: DropdownMenuTriggerIconPosition; + icon?: React.ReactNode; + fullWidth?: boolean; + className?: string; + dts?: string; + disabled?: boolean; + children?: React.ReactNode; +} + +declare const DropdownMenuTrigger: React.FC; + +export type DropdownMenuCheckboxGroupOrientation = 'vertical' | 'horizontal'; + +export type DropdownMenuCheckboxGroupVariant = 'default' | 'box'; + +export interface DropdownMenuCheckboxGroupProps { + value: any[]; + name: string; + /** + * @function onChange + * @param {array} newValue - the new checkboxGroup value + * @param {string} name - the checkbox name + * @param {string|number} value - the changed checkbox's value + */ + onChange?: (...args: any[]) => any; + /** + * @function getIsChecked overrides the default checked state behaviour + * @param {string|number} itemValue - the checkbox's value + * @param {array} value - the checkbox group's value + */ + getIsChecked?: (...args: any[]) => any; + orientation?: DropdownMenuCheckboxGroupOrientation; + children: React.ReactNode; + className?: string; + dts?: string; + variant?: DropdownMenuCheckboxGroupVariant; + id?: string; + indent?: boolean; + /** + * @deprecated use orientation="horizontal" instead + */ + inline?: boolean; +} + +declare const DropdownMenuCheckboxGroup: React.FC; + +export type DropdownMenuCheckboxVariant = 'default' | 'box'; + +export type DropdownMenuCheckboxChecked = boolean | 'partial'; + +export type DropdownMenuCheckboxValue = string | number; + +export interface DropdownMenuCheckboxProps { + /** + * name for the checkbox input + */ + name?: string; + variant?: DropdownMenuCheckboxVariant; + /** + * @function onChange called when checkBox onChange event is fired + * @param {string|boolean} nextState - the checked state + * @param {string} name - the checkbox name + * @param {string|number} value - the checkbox value + */ + onChange?: (...args: any[]) => any; + onKeyDown?: (...args: any[]) => any; + /** + * checked status of the input checkBox: oneOf([true, false, 'partial'] + */ + checked?: DropdownMenuCheckboxChecked; + /** + * @deprecated + */ + size?: number; + /** + * @deprecated + */ + inline?: boolean; + /** + * checkBox input value + */ + value?: DropdownMenuCheckboxValue; + /** + * id for the checkbox input + */ + id?: string; + className?: string; + /** + * checkBox label for the checkbox input + */ + label?: React.ReactNode; + /** + * additional text description to display below the label + */ + text?: React.ReactNode; + /** + * icon to display beside the label when parent group's `variant="box"` + */ + icon?: React.ReactNode; + /** + * data-test-selector for the checkbox component + */ + dts?: string; + /** + * determines if the checkbox is disabled + */ + disabled?: boolean; +} + +declare const DropdownMenuCheckbox: React.FC; + +export interface DropdownMenuCheckboxAllProps { + label?: React.ReactNode; + className?: string; + /** + * a array of values that the All option represent + */ + values: any[]; +} + +declare const DropdownMenuCheckboxAll: React.FC; + +export type DropdownMenuRadioGroupOrientation = 'vertical' | 'horizontal'; + +export type DropdownMenuRadioGroupVariant = 'default' | 'box'; + +export interface DropdownMenuRadioGroupProps { + value: string; + name: string; + onChange: (...args: any[]) => any; + orientation?: DropdownMenuRadioGroupOrientation; + children: React.ReactNode; + className?: string; + dts?: string; + variant?: DropdownMenuRadioGroupVariant; + id?: string; + /** + * @deprecated use orientation="horizontal" instead + */ + inline?: boolean; +} + +declare const DropdownMenuRadioGroup: React.FC; + +export type DropdownMenuRadioValue = string | number; + +export interface DropdownMenuRadioProps { + id?: string; + className?: string; + name?: string; + label?: React.ReactNode; + /** + * additional text description to display below the label + */ + text?: React.ReactNode; + /** + * icon to display beside the label when parent group's `variant="box"` + */ + icon?: React.ReactNode; + value?: DropdownMenuRadioValue; + dts?: string; + disabled?: boolean; + /** + * @function onChange called when radio onChange event is fired + * @param {string|number} value - the radio value + */ + onChange?: (...args: any[]) => any; + /** + * checked status of the radio input + */ + checked?: boolean; + /** + * @deprecated + */ + inline?: boolean; +} + +declare const DropdownMenuRadio: React.FC; + +export interface DropdownMenuItemProps { + children?: React.ReactNode; + disabled?: boolean; + icon?: React.ReactNode; + className?: string; + dts?: string; + onClick?: (...args: any[]) => any; +} + +declare const DropdownMenuItem: React.FC; + +export interface DropdownMenuLabelProps { + className?: string; + children?: React.ReactNode; +} + +declare const DropdownMenuLabel: React.FC; + +export interface DropdownMenuItemContainerProps { + className?: string; + children?: React.ReactNode; +} + +declare const DropdownMenuItemContainer: React.FC; + +export interface DropdownMenuDividerProps { + className?: string; +} + +declare const DropdownMenuDivider: React.FC; + +export interface DropdownMenuGroupProps { + className?: string; + children?: React.ReactNode; + id?: string; + /** + * Renders the group as a collapsible panel component + */ + collapsible?: boolean; + defaultCollapsed?: boolean; + /** + * The group's heading. + * Title must be used if collapsible is true + */ + title?: string; +} + +declare const DropdownMenuGroup: React.FC; + +export type DropdownMenuChildren = React.ReactNode | ((...args: any[]) => any); + +export interface DropdownMenuProps { + /** + * Initial open state. Can be used to toggle the open state programatically. + */ + defaultOpen?: boolean; + /** + * Closes the menu when an item with an onClick handler is clicked. + * Also applies to checkboxes and radios. + */ + closeOnItemClick?: boolean; + onOpen?: (...args: any[]) => any; + onClose?: (...args: any[]) => any; + /** + * opt-in to submenu behaviour for nested menus + */ + submenu?: boolean; + /** + * Optional ref to mount the dropdown to + * *Only use when using Dropdown.Trigger is not feasible* + */ + triggerRef?: Object; + /** + * A unique trigger id is required for accessiblilty purposes + */ + triggerId?: string; + /** + * A unique content id is required for accessiblilty purposes + */ + contentId?: string; + /** + * A render function may be used, which receives the dropdown context. + * Notably: `open` state, `closeMenu()` function, `triggerRef`, `contentRef`. + */ + children?: DropdownMenuChildren; +} + +declare const DropdownMenu: React.FC & { + Content: typeof DropdownMenuContent; + Trigger: typeof DropdownMenuTrigger; + Item: typeof DropdownMenuItem; + CheckboxGroup: typeof DropdownMenuCheckboxGroup; + Checkbox: typeof DropdownMenuCheckbox; + CheckboxAll: typeof DropdownMenuCheckboxAll; + RadioGroup: typeof DropdownMenuRadioGroup; + Radio: typeof DropdownMenuRadio; + Label: typeof DropdownMenuLabel; + ItemContainer: typeof DropdownMenuItemContainer; + Divider: typeof DropdownMenuDivider; + Group: typeof DropdownMenuGroup; +}; + +export default DropdownMenu; + +declare const DropdownMenuProvider: React.FC; + +declare const INNER_CONTENT: React.FC; diff --git a/src/components/DropdownMenu/index.jsx b/src/components/DropdownMenu/index.jsx new file mode 100644 index 000000000..1fc8eb5df --- /dev/null +++ b/src/components/DropdownMenu/index.jsx @@ -0,0 +1,603 @@ +import React from 'react'; +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import useArrowFocus from '../../hooks/useArrowFocus'; +import Popover from '../Popover'; +import { popoverPlacements } from '../Popover/constants'; +import Panel from '../Panel'; +import Button, { colors, variants, sizes, positions } from '../Button'; +import Checkbox, { checkboxPropTypes, shareCheckboxPropTypes } from '../Checkbox'; +import Radio, { radioPropTypes } from '../Radio'; +import RadioGroup, { radioGroupPropTypes } from '../RadioGroup'; +import CheckboxGroup, { checkboxGroupPropTypes, checkboxGroupAllPropTypes } from '../CheckboxGroup'; +import { useCheckboxGroup } from '../CheckboxGroup/CheckboxGroupContext'; +import { expandDts, invariant } from '../../utils'; +import { getFocusableNodes } from '../../utils/focus'; +import './styles.css'; + +const forbiddenChildren = [Checkbox.name, CheckboxGroup.name, Radio.name, RadioGroup.name]; + +const forbiddenChildrenInvariant = (children) => + invariant( + !React.Children.toArray(children).some((child) => { + if (!React.isValidElement(child) || !child.type) return false; + return forbiddenChildren.includes(child.type.name); + }), + 'DropdownMenu: all Radio and Checkbox components should come from DropdownMenu, e.g ' + ); + +const noRefInvariant = (triggerRef) => + invariant(triggerRef, 'DropdownMenu: Did you forget to wrap your menu with ?'); + +const DropdownMenuContext = React.createContext({}); + +const DropdownMenuProvider = ({ + children, + triggerRef, + contentRef, + triggerId, + contentId, + open, + closeParentMenu, + closeSubMenu, + onOpenChange, + closeOnItemClick, +}) => { + const closeMenu = React.useCallback(() => { + onOpenChange({}, false); + triggerRef.current?.focus(); + }, [onOpenChange, triggerRef]); + + const { closeParentMenu: isSubMenu, _menus = [] } = useDropdownMenu(); + + const state = React.useMemo( + () => ({ + open, + triggerRef, + contentRef, + onOpenChange, + isSubMenu, + closeSubMenu, + closeOnItemClick, + triggerId, + contentId, + closeMenu, + closeParentMenu: closeParentMenu ?? closeMenu, + _menus: [..._menus, { closeMenu }], + }), + [ + open, + triggerRef, + contentRef, + onOpenChange, + isSubMenu, + closeSubMenu, + closeOnItemClick, + triggerId, + contentId, + closeMenu, + closeParentMenu, + _menus, + ] + ); + return {children}; +}; + +/** + * @typedef {object} options options object + * @property {boolean} options.open open state + * @property {function} options.closeMenu close this menu + * @property {object} options.triggerRef + * @property {function} options.onOpenChange calls `onOpen` or `onClose` and sets the internal open state of the menu + * @property {boolean} options.closeOnItemClick + * @property {string} options.triggerId + * @property {string} options.contentId + * @property {function} options.closeParentMenu close the first menu + * @property {array} options._menus array of `{ closeMenu }` for each menu + * @returns {options} + **/ +const useDropdownMenu = () => React.useContext(DropdownMenuContext); + +const DropdownMenu = ({ + onOpen, + onClose, + closeOnItemClick, + triggerRef: triggerRefProp, + submenu, + triggerId, + contentId, + defaultOpen, + children, +}) => { + const triggerRef = React.useRef(null); + const contentRef = React.useRef(null); + + const { closeParentMenu, closeOnItemClick: closeOnItemClickCtx } = useDropdownMenu(); + const isSubMenu = submenu && !!closeParentMenu; + + const [open, setOpen] = React.useState(defaultOpen); + + React.useEffect(() => { + setOpen(defaultOpen); + }, [defaultOpen]); + + const onOpenChange = React.useCallback( + (event, nextOpenState) => { + nextOpenState ? onOpen?.(event, nextOpenState) : onClose?.(event, nextOpenState); + setOpen(nextOpenState); + }, + [setOpen, onOpen, onClose] + ); + + const propsToInherit = { + // inherit parent context if present + // otherwise default to true + ...(closeOnItemClick == null + ? { closeOnItemClick: typeof closeOnItemClickCtx !== 'undefined' ? closeOnItemClickCtx : true } + : {}), + }; + + return ( + + {typeof children === 'function' ? ( + {(value) => children(value)} + ) : ( + children + )} + + ); +}; + +const DropdownMenuTrigger = ({ disabled = false, className, children, icon, ...rest }) => { + const { triggerRef, triggerId, contentId, isSubMenu, open, onOpenChange } = useDropdownMenu(); + + const subMenuTriggerProps = { + variant: 'borderless', + fullWidth: true, + role: 'menuitem', + iconPosition: 'right', + icon: icon ?? ( + + + + ), + }; + + return ( + + ); +}; + +/** + * ths is a separate component to cater for Popover `popoverContent`'s + * render function and regular function component prop + */ +const INNER_CONTENT = ({ children, dts, className }) => { + const { contentRef, isSubMenu, closeMenu, triggerId, contentId, open, onOpenChange } = useDropdownMenu(); + + useArrowFocus({ + ref: contentRef, + selector: '.aui--dropdown-item', + }); + + React.useEffect(() => { + const onContentKeyDown = (event) => { + // allow submenus to close with left arrow + if (open && isSubMenu && event.key === 'ArrowLeft' && contentRef.current.contains(event.target)) { + event.preventDefault(); + closeMenu(); + } + }; + + document.addEventListener('keydown', onContentKeyDown); + return () => { + document.removeEventListener('keydown', onContentKeyDown); + }; + }, [closeMenu, contentRef, isSubMenu, onOpenChange, open]); + + return ( +
{ + if (event.target === contentRef.current) { + // focus the first/last menu item on initial arrow up/down + if (['ArrowDown', 'ArrowUp'].includes(event.key)) { + event.preventDefault(); + const nodes = getFocusableNodes(contentRef?.current, { + tabbable: true, + accept: (node) => node.classList.contains('aui--dropdown-item'), + }); + if (event.key === 'ArrowDown') { + nodes[0]?.focus({ preventScroll: true }); + } + if (event.key === 'ArrowUp') { + nodes[nodes.length - 1]?.focus({ preventScroll: true }); + } + } + } + }} + className={classNames('aui--dropdown-content', className)} + {...expandDts(dts)} + ref={contentRef} + id={contentId} + aria-labelledby={triggerId} + role="menu" + aria-orientation="vertical" + > + {children} +
+ ); +}; + +/** + * Dropdown Menu Content container + * Any menu items should be children of this component + */ +const DropdownMenuContent = ({ children, className, placement, modifiers, dts }) => { + const { _menus, triggerRef, isSubMenu, open, onOpenChange, closeParentMenu } = useDropdownMenu(); + forbiddenChildrenInvariant(children); + noRefInvariant(triggerRef); + + return ( + { + event.preventDefault(); + const menuEls = Array.from(document.querySelectorAll('.aui--dropdown-popover')); + if (menuEls.length > 0) { + const targetMenu = menuEls.findIndex((el) => el.contains(event.target)); + // If clickOutside is triggered by clicking on a menu, + // call `closeMenu` on the sibling menu after the target + if (menuEls.some((el) => el.contains(event.target))) return _menus[targetMenu + 1].closeMenu(); + } + closeParentMenu(); + }} + popoverClassNames={classNames('aui--dropdown-popover', { open })} + triggerRef={triggerRef} + placement={placement ? placement : isSubMenu ? 'right-start' : 'bottom-start'} + arrowStyles={{ display: 'none' }} + isOpen={open} + onOpenChange={(openState, event) => { + if (event.type === 'keydown' && event.key === 'Escape') { + return closeParentMenu(); + } + onOpenChange(event, openState); + }} + popoverContent={ + typeof children === 'function' ? ( + (...args) => ( + + {children(...args)} + + ) + ) : ( + + {children} + + ) + } + /> + ); +}; + +const DropdownMenuItem = ({ children, onClick, disabled, className, dts, ...rest }) => { + const { closeParentMenu, closeOnItemClick } = useDropdownMenu(); + return ( + + ); +}; + +const DropdownMenuCheckboxGroup = ({ children, onChange, className, ...rest }) => { + forbiddenChildrenInvariant(children); + const { closeParentMenu, closeOnItemClick } = useDropdownMenu(); + return ( + { + onChange(...args); + if (closeOnItemClick) { + closeParentMenu(); + } + }} + {...rest} + > + {children} + + ); +}; + +const DropdownMenuCheckbox = ({ children, className, ...rest }) => { + const parentCtx = useCheckboxGroup(); + const Component = _.isEmpty(parentCtx) ? Checkbox : CheckboxGroup.Item; + return ; +}; + +const DropdownMenuCheckboxAll = ({ children, className, ...rest }) => { + return ( + + ); +}; + +const DropdownMenuRadioGroup = ({ children, onChange, className, ...rest }) => { + forbiddenChildrenInvariant(children); + const { closeParentMenu, closeOnItemClick } = useDropdownMenu(); + return ( + { + onChange(...args); + if (closeOnItemClick) { + closeParentMenu(); + } + }} + {...rest} + > + {children} + + ); +}; + +const DropdownMenuRadio = ({ children, onClick, className, ...rest }) => { + return ; +}; + +const DropdownMenuLabel = ({ children, className, ...rest }) => { + return ( +
+ {children} +
+ ); +}; + +const DropdownMenuDivider = ({ className }) => { + return
; +}; + +/** + * + * a div Styled like a `DropdownMenu.Item` + */ +const DropdownMenuItemContainer = ({ children, className, ...rest }) => { + return ( +
+ {children} +
+ ); +}; + +const DropdownMenuGroup = ({ children, className, defaultCollapsed, collapsible, title, id, ...rest }) => { + forbiddenChildrenInvariant(children); + + const [isCollapsed, setIsCollapsed] = React.useState(defaultCollapsed); + const onPanelClick = () => setIsCollapsed(!isCollapsed); + + return collapsible ? ( + + {children} + + ) : ( +
+ {title && {title}} + {children} +
+ ); +}; + +DropdownMenu.Content = DropdownMenuContent; +DropdownMenuContent.propTypes = { + children: PropTypes.oneOfType([PropTypes.func, PropTypes.node]), + className: PropTypes.string, + dts: PropTypes.string, + id: PropTypes.string, + placement: PropTypes.oneOf(popoverPlacements), + modifiers: PropTypes.oneOfType([PropTypes.object, PropTypes.arrayOf(PropTypes.object)]), +}; + +DropdownMenu.Trigger = DropdownMenuTrigger; +DropdownMenuTrigger.displayName = 'DropdownMenuTrigger'; + +DropdownMenuTrigger.propTypes = { + isLoading: PropTypes.bool, + color: PropTypes.oneOf(colors, variants, sizes), + variant: PropTypes.oneOf(variants), + size: PropTypes.oneOf(sizes), + iconPosition: PropTypes.oneOf(positions), + icon: PropTypes.node, + fullWidth: PropTypes.bool, + className: PropTypes.string, + dts: PropTypes.string, + disabled: PropTypes.bool, + children: PropTypes.node, +}; + +DropdownMenu.Item = DropdownMenuItem; + +DropdownMenu.CheckboxGroup = DropdownMenuCheckboxGroup; +DropdownMenuCheckboxGroup.propTypes = { ...checkboxGroupPropTypes }; + +DropdownMenu.Checkbox = DropdownMenuCheckbox; +DropdownMenuCheckbox.propTypes = { ...checkboxPropTypes, ...shareCheckboxPropTypes }; + +DropdownMenu.CheckboxAll = DropdownMenuCheckboxAll; +DropdownMenuCheckboxAll.propTypes = { ...checkboxGroupAllPropTypes }; + +DropdownMenu.RadioGroup = DropdownMenuRadioGroup; +DropdownMenuRadioGroup.propTypes = { ...radioGroupPropTypes }; + +DropdownMenu.Radio = DropdownMenuRadio; +DropdownMenuRadio.propTypes = { ...radioPropTypes }; + +DropdownMenuItem.propTypes = { + children: PropTypes.node, + disabled: PropTypes.bool, + icon: PropTypes.node, + className: PropTypes.string, + dts: PropTypes.string, + onClick: PropTypes.func, +}; + +DropdownMenu.Label = DropdownMenuLabel; +DropdownMenuLabel.propTypes = { + className: PropTypes.string, + children: PropTypes.node, +}; + +DropdownMenu.ItemContainer = DropdownMenuItemContainer; +DropdownMenuItemContainer.propTypes = { + className: PropTypes.string, + children: PropTypes.node, +}; + +DropdownMenu.Divider = DropdownMenuDivider; +DropdownMenuDivider.propTypes = { + className: PropTypes.string, +}; + +DropdownMenu.Group = DropdownMenuGroup; +DropdownMenuGroup.propTypes = { + className: PropTypes.string, + children: PropTypes.node, + id: PropTypes.string, + /** + * Renders the group as a collapsible panel component + */ + collapsible: PropTypes.bool, + defaultCollapsed: PropTypes.bool, + /** + * The group's heading. + * Title must be used if collapsible is true + */ + title: PropTypes.string, +}; + +DropdownMenu.useDropdownMenu = useDropdownMenu; + +DropdownMenu.propTypes = { + /** + * Initial open state. Can be used to toggle the open state programatically. + */ + defaultOpen: PropTypes.bool, + /** + * Closes the menu when an item with an onClick handler is clicked. + * Also applies to checkboxes and radios. + */ + closeOnItemClick: PropTypes.bool, + onOpen: PropTypes.func, + onClose: PropTypes.func, + /** + * opt-in to submenu behaviour for nested menus + */ + submenu: PropTypes.bool, + /** + * Optional ref to mount the dropdown to + * + * *Only use when using Dropdown.Trigger is not feasible* + */ + triggerRef: PropTypes.object, + /** + * A unique trigger id is required for accessiblilty purposes + */ + triggerId: PropTypes.string, + /** + * A unique content id is required for accessiblilty purposes + */ + contentId: PropTypes.string, + /** + * A render function may be used, which receives the dropdown context. + * Notably: `open` state, `closeMenu()` function, `triggerRef`, `contentRef`. + */ + children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]), +}; + +export default DropdownMenu; diff --git a/src/components/DropdownMenu/index.spec.jsx b/src/components/DropdownMenu/index.spec.jsx new file mode 100644 index 000000000..5ce3e1335 --- /dev/null +++ b/src/components/DropdownMenu/index.spec.jsx @@ -0,0 +1,584 @@ +import React from 'react'; +import { render, cleanup, fireEvent, act, queryByAttribute, createEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import DropdownMenu from '.'; +const getByClass = queryByAttribute.bind(null, 'class'); + +afterEach(cleanup); + +describe('', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + afterEach(cleanup); + + it('should render with props', async () => { + const itemClickSpy = jest.fn(); + const onOpenSpy = jest.fn(); + + const { getAllByRole, getByTestId, getByRole } = render( + + Menu + + + Item 1 + Item 2 + Item 3 + + Label + + some content + some content + + + ); + + const trigger = getAllByRole('button')[0]; + expect(trigger).toBeInTheDocument(); + expect(trigger).toHaveAccessibleName('Menu'); + + const ev = createEvent.click(trigger); + + await act(async () => { + fireEvent(trigger, ev); + }); + + await act(async () => { + jest.runAllTimers(); + }); + + expect(onOpenSpy).toBeCalledTimes(1); + + expect(getAllByRole('button')[0]).toHaveAttribute('aria-expanded', 'true'); + + expect(getByTestId('panel-wrapper')).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(getByTestId('panel-header')); + }); + + expect(getByTestId('panel-wrapper')).toHaveClass('collapsed'); + expect(getByTestId('panel-content')).toBeInTheDocument(); + + expect(getByRole('menu')).toBeInTheDocument(); + expect(getByRole('menu')).toHaveAccessibleName('Menu'); + expect(getByRole('menu')).toHaveClass('aui--dropdown-content'); + + expect(getAllByRole('menuitem')).toHaveLength(3); + expect(getAllByRole('menuitem')[0]).toBeDisabled(); + expect(getAllByRole('menuitem')[0]).toHaveClass('aui--dropdown-item'); + + await act(async () => { + fireEvent.click(getAllByRole('menuitem')[2]); + }); + expect(trigger).not.toHaveAttribute('aria-expanded'); + expect(itemClickSpy).toHaveBeenCalledTimes(1); + }); + + it('should close menu on item click', () => { + const itemClickSpy = jest.fn(); + + const { getAllByRole } = render( + + Menu + + + Item 1 + Item 2 + Item 3 + + + + ); + + const trigger = getAllByRole('button')[0]; + expect(trigger).toBeInTheDocument(); + expect(trigger).toHaveAccessibleName('Menu'); + + act(() => { + fireEvent.click(trigger); + jest.runAllTimers(); + }); + + act(() => { + fireEvent.click(getAllByRole('menuitem')[1]); + }); + expect(itemClickSpy).toHaveBeenCalledTimes(0); + + act(() => { + fireEvent.click(getAllByRole('menuitem')[2]); + }); + + expect(itemClickSpy).toHaveBeenCalledTimes(1); + + expect(trigger).not.toHaveAttribute('aria-expanded'); + }); + + it('should work with render function content', () => { + const itemClickSpy = jest.fn(); + + const { getAllByRole } = render( + e.preventDefault()} contentId="menu" triggerId="trigger"> + Menu + + {() => ( + + Item 1 + Item 2 + Item 3 + + )} + + + ); + + const trigger = getAllByRole('button')[0]; + + act(() => { + fireEvent.click(trigger); + }); + + expect(getAllByRole('menuitem')).toHaveLength(3); + }); + + it('should close on click outside', () => { + const { getAllByRole } = render( + + Menu + + + Item 1 + Item 2 + Item 3 + + + + ); + + const trigger = getAllByRole('button')[0]; + expect(trigger).toBeInTheDocument(); + expect(trigger).toHaveAccessibleName('Menu'); + + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + + // click within menu should not close menu + act(() => { + fireEvent.click(getAllByRole('menuitem')[0]); + }); + + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + + act(() => { + fireEvent.mouseDown(document.body); + }); + + expect(trigger).not.toHaveAttribute('aria-expanded'); + }); + + it('should render function the context', () => { + const { getAllByRole, getByText } = render( + + {({ closeMenu }) => ( + <> + Menu + + + Item 1 + Item 2 + Item 3 + + + + + )} + + ); + + const trigger = getAllByRole('button')[0]; + + act(() => { + fireEvent.click(trigger); + }); + expect(getAllByRole('button')[0]).toHaveAttribute('aria-expanded', 'true'); + + act(() => { + fireEvent.click(getByText('Close')); + }); + expect(getAllByRole('button')[0]).not.toHaveAttribute('aria-expanded'); + }); + + it('should not trigger menu when trigger is disabled', () => { + const { getAllByRole } = render( + + Menu + + + Item 1 + Item 2 + Item 3 + + + + ); + + const trigger = getAllByRole('button')[0]; + expect(trigger).toBeDisabled(); + + act(() => { + fireEvent.keyDown(trigger, { key: 'Enter' }); + }); + + act(() => { + jest.runAllTimers(); + }); + + expect(trigger).not.toHaveAttribute('aria-expanded'); + }); + + it('should not close on item click if closeOnItemClick is false', () => { + const { getAllByRole, getAllByLabelText } = render( + + Menu + + + {}}>Item 1 + + {}}> + + + {}}> + + + + + ); + + const trigger = getAllByRole('button')[0]; + + act(() => { + fireEvent.click(trigger); + }); + act(() => { + fireEvent.click(getAllByRole('menuitem')[0]); + }); + expect(getAllByRole('menuitem')[0]).toBeInTheDocument(); + expect(trigger).toHaveAttribute('aria-expanded'); + + act(() => { + fireEvent.click(getAllByLabelText('checkbox')[0]); + }); + expect(getAllByRole('menuitemcheckbox')[0]).toBeInTheDocument(); + expect(trigger).toHaveAttribute('aria-expanded'); + + act(() => { + fireEvent.click(getAllByLabelText('radio')[0]); + }); + expect(getAllByRole('menuitemradio')[0]).toBeInTheDocument(); + expect(trigger).toHaveAttribute('aria-expanded'); + }); + + it('should open and close with keyboard', () => { + const { getAllByRole } = render( + + Menu + + + Item 1 + Item 2 + Item 3 + + + + ); + + const trigger = getAllByRole('button')[0]; + expect(trigger).toBeInTheDocument(); + expect(trigger).toHaveAccessibleName('Menu'); + + act(() => { + userEvent.tab(); + }); + expect(trigger).toHaveFocus(); + + act(() => { + userEvent.keyboard('[Enter]'); + }); + + act(() => { + jest.runAllTimers(); + }); + + act(() => { + userEvent.keyboard('[ArrowDown]'); + }); + + expect(getAllByRole('button')[0]).toHaveAttribute('aria-expanded', 'true'); + expect(getAllByRole('menuitem')[0]).toHaveFocus(); + + act(() => { + userEvent.keyboard('[Escape]'); + }); + act(() => { + jest.runAllTimers(); + }); + expect(trigger).not.toHaveAttribute('aria-expanded'); + }); + + it('should prevent default when activating the trigger with keyboard', () => { + const { getAllByRole } = render( + + Menu + + + Item 1 + Item 2 + Item 3 + + +
+ +
+
+ ); + + const trigger = getAllByRole('button')[0]; + + act(() => { + userEvent.tab(); + }); + + expect(trigger).toHaveFocus(); + + const ev = createEvent.keyDown(trigger, { key: 'ArrowUp' }); + + act(() => { + fireEvent(trigger, ev); + jest.runAllTimers(); + }); + expect(ev.defaultPrevented).toBeFalsy(); + expect(trigger).toHaveFocus(); + + const ev2 = createEvent.click(trigger, { key: ' ' }); + act(() => { + fireEvent(trigger, ev2); + jest.runAllTimers(); + }); + + expect(trigger).toHaveAttribute('aria-expanded'); + }); + + it('should handle nested submenus', async () => { + const { getAllByRole, getByText } = render( + + Menu + + + Item 1 + Item 2 + Item 3 + + + Sub Menu + + + Sub Item 1 + Sub Item 2 + Sub Item 3 + + + + + + ); + const trigger = getAllByRole('button')[0]; + + await act(async () => { + userEvent.tab(); + }); + + expect(trigger).toHaveFocus(); + + await act(async () => { + userEvent.keyboard('[Enter]'); + }); + + await act(async () => { + jest.runAllTimers(); + }); + + expect(getByClass(document, 'aui--popover-wrapper popover-light aui--dropdown-popover open')).toHaveAttribute( + 'placement', + 'bottom-end' + ); + + await act(async () => { + userEvent.keyboard('[ArrowUp]'); + }); + + const subMenuTrigger = getByText('Sub Menu').parentElement; + + expect(subMenuTrigger).toHaveFocus(); + + // sub menu shouldn't trigger with arrow down, it should loop to the first item + await act(async () => { + userEvent.keyboard('[ArrowDown]'); + }); + + expect(getAllByRole('menuitem')[0]).toHaveFocus(); + + // go back to the submenu trigger + await act(async () => { + userEvent.keyboard('[ArrowUp]'); + }); + + await act(async () => { + userEvent.keyboard('[ArrowRight]'); + }); + + await act(async () => { + jest.runAllTimers(); + }); + expect(subMenuTrigger).toHaveAttribute('aria-expanded'); + + expect(getAllByRole('menu')).toHaveLength(2); + + // exit submenu with left arrow + await act(async () => { + userEvent.keyboard('[ArrowLeft]'); + }); + + await act(async () => { + jest.runAllTimers(); + }); + + expect(getAllByRole('menu')[1]).toBeUndefined(); + + await act(async () => { + userEvent.keyboard('[ArrowRight]'); + }); + + await act(async () => { + jest.runAllTimers(); + }); + + await act(async () => { + userEvent.tab(); + userEvent.tab(); + userEvent.keyboard('[ArrowLeft]'); + }); + + await act(async () => { + await jest.runAllTimers(); + }); + + // closing submenu focuses its trigger + expect(subMenuTrigger).not.toHaveAttribute('aria-expanded'); + expect(subMenuTrigger).toHaveFocus(); + + // clicking on parent menu triggers clickOutside for submenu only + await act(async () => { + userEvent.keyboard('[ArrowRight]'); + }); + + await act(async () => { + jest.runAllTimers(); + }); + + expect(subMenuTrigger).toHaveAttribute('aria-expanded', 'true'); + + // asserts clicking on submenu item inherited `closeOnItemClick={false}` from parent + await act(async () => { + userEvent.keyboard('[Tab][Enter][ArrowDown][ArrowDown][ArrowDown][Enter][Enter]'); + }); + + expect(trigger).toHaveAttribute('aria-expanded'); + + // trigger click outside for submenu by clicking on parent menu, should close sub menu only + act(() => { + fireEvent.mouseDown(getAllByRole('menu')[0]); + }); + + expect(getAllByRole('menu')[1]).toBeUndefined(); + }); + + it('should render menu specific radio and checkbox components', () => { + const onChangeSpy = jest.fn(); + + const { getAllByRole } = render( + + Menu + + + + + + + + + + + {}} value="test" /> + +
+ +
+
+ ); + + act(() => { + userEvent.tab(); + }); + + act(() => { + userEvent.keyboard('[Enter]'); + }); + + act(() => { + jest.runAllTimers(); + }); + act(() => { + userEvent.keyboard('[ArrowDown]'); + }); + expect(getAllByRole('menuitemcheckbox')).toHaveLength(4); + expect(getAllByRole('menuitemradio')).toHaveLength(2); + expect(getAllByRole('menuitemcheckbox').at(0)).toHaveFocus(); + + // check 2nd checkbox + act(() => { + userEvent.keyboard('[ArrowDown][Space]'); + }); + act(() => { + jest.runAllTimers(); + }); + + expect(onChangeSpy).nthCalledWith(1, ['check'], 'test-1', 'check'); + + act(() => { + jest.runAllTimers(); + }); + + // re-open + act(() => { + userEvent.keyboard('[Enter]'); + }); + act(() => { + jest.runAllTimers(); + }); + act(() => { + userEvent.keyboard('[ArrowDown]'); + }); + + // check first radio + act(() => { + userEvent.keyboard('[ArrowDown][ArrowDown][ArrowDown]'); + expect(getAllByRole('menuitemradio').at(0)).toHaveFocus(); + userEvent.keyboard('[Space]'); + }); + expect(onChangeSpy).nthCalledWith(2, 'radio'); + }); +}); diff --git a/src/components/DropdownMenu/styles.css b/src/components/DropdownMenu/styles.css new file mode 100644 index 000000000..d08b4c151 --- /dev/null +++ b/src/components/DropdownMenu/styles.css @@ -0,0 +1,168 @@ +@import url('../../styles/variable.css'); + +$color-menu-text: $color-text-base; +$color-menu-active: $color-default-accent; +$color-menu-text-active: $color-text-base; + +$color-menu-background: $color-white; +$color-menu-border: $color-border-base; + +$menu-min-width: 150px; +$item-padding: 9px 18px; + +.aui--dropdown-item { + width: 100%; + background-color: $color-menu-background; + color: $color-menu-text; + display: flex; + align-items: center; + justify-content: space-between; + padding: $item-padding; + border: none; + text-align: left; + font-weight: normal; + border-radius: 0; + + &.aui--checkbox, + &.aui--radio { + padding: 0; + } + + &.aui--checkbox label, + &.aui--radio label { + width: 100%; + padding: $item-padding; + color: inherit; + } + + &.is-disabled { + color: $color-default-base; + } + + &:focus-visible, + &:hover, + &.active { + &:not(.is-disabled) { + outline: none; + box-shadow: none; + background-color: $color-menu-active; + color: $color-menu-text-active; + } + } +} + +.aui--dropdown-content { + color: $color-menu-text; + + & .aui--checkbox-group, + & .aui--radio-group { + gap: 0; + } + + & .aui--checkbox, + & .aui--radio { + width: 100%; + } +} + +.aui--dropdown-divider { + width: 100%; + height: 1px; + background: $color-menu-border; +} + +.aui--dropdown-label { + padding: $item-padding; + font-weight: bold; +} + +.aui--dropdown-container { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: $item-padding; +} + +.aui--dropdown-group { + border-bottom: 1px solid $color-menu-border; + margin-bottom: 6px; + padding-bottom: 6px; + + &.panel-component { + &.collapsed { + margin-bottom: 0; + padding-bottom: 0; + } + + & ~ .panel-component { + border-top: 0; + } + + border-radius: 0; + + & .panel-component-header { + padding: $item-padding; + border-bottom: 0; + position: sticky; + top: 0; + background: $color-menu-background; + + &::before { + width: 12px; + height: 12px; + margin-top: 6px; + } + } + + & .panel-component-content { + padding: 0; + } + } + + &:last-of-type { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: 0; + } +} + +.aui--dropdown-trigger:not(.aui-icon) { + justify-content: space-between; +} + +.aui--dropdown-popover { + padding: 0; + border-radius: 0; + box-shadow: none; + border: none; + background-color: transparent; + min-width: min-content; + + & .popover-content { + padding: 0; + background-color: transparent; + } + + & .aui--dropdown-content { + background-color: $color-menu-background; + border-radius: 3px; + border: 1px solid $color-menu-border; + box-shadow: 0 2px 9px rgba(0, 0, 0, 0.09); + min-width: $menu-min-width; + max-height: min(500px, 80vh); + overflow-y: auto; + opacity: 0; + transform: translate3d(0, -4px, 0); + transition: all 300ms ease; + + &:focus { + outline: none; + } + } + + &.open .aui--dropdown-content { + opacity: 1; + transform: translate3d(0, 0, 0); + } +} diff --git a/src/components/Panel/index.jsx b/src/components/Panel/index.jsx index f97c37b42..ddb6a0004 100644 --- a/src/components/Panel/index.jsx +++ b/src/components/Panel/index.jsx @@ -22,16 +22,11 @@ const Panel = ({ onClick, className, children, dts, icon, id, isCollapsed, title return (
-
+
-
+ +
{children}
diff --git a/src/components/Panel/styles.css b/src/components/Panel/styles.css index a47d7c358..253584153 100644 --- a/src/components/Panel/styles.css +++ b/src/components/Panel/styles.css @@ -18,7 +18,19 @@ cursor: pointer; font-weight: $font-weight-bold; border-bottom: 1px solid $color-border-base; + border: unset; + outline: none; + background-color: unset; + width: 100%; + text-align: left; + border-radius: inherit; line-height: 22px; + + &:focus-visible { + outline: none; + border-color: $color-white; + box-shadow: 0 0 0 2px $color-grey-700; + } } .panel-component-header::before { diff --git a/src/components/Radio/index.jsx b/src/components/Radio/index.jsx index f54f10689..396b318b5 100644 --- a/src/components/Radio/index.jsx +++ b/src/components/Radio/index.jsx @@ -42,9 +42,9 @@ const Radio = ({ return (
{ invariant(!inline, 'RadioGroup: the inline prop has been replaced by orientation="vertical"'); @@ -42,6 +47,7 @@ const RadioGroup = ({ useArrowFocus({ ref, + disabled: disableArrowKeys, onFocus: (el) => onChange(el.dataset.auiValue), selector: `.${itemClass}[role=radio]`, loop: true, @@ -68,7 +74,7 @@ const RadioGroup = ({ ); }; -RadioGroup.propTypes = { +export const radioGroupPropTypes = { value: PropTypes.string.isRequired, name: PropTypes.string.isRequired, onChange: PropTypes.func.isRequired, @@ -84,4 +90,8 @@ RadioGroup.propTypes = { inline: PropTypes.bool, }; +RadioGroup.propTypes = { + ...radioGroupPropTypes, +}; + export default RadioGroup; diff --git a/src/index.d.ts b/src/index.d.ts index 964ad3fda..d92ac0da7 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -19,12 +19,14 @@ export { default as Empty } from './components/Empty'; export { default as fastStatelessWrapper } from './components/fastStatelessWrapper'; export { default as FilePicker } from './components/FilePicker'; export { default as FlexibleSpacer } from './components/FlexibleSpacer'; +export { default as DismissableFocusTrap } from './components/DismissibleFocusTrap'; export { default as FormGroup } from './components/FormGroup'; export { default as Grid } from './components/Grid'; export { default as GridCell } from './components/Grid/Cell'; export { default as GridRow } from './components/Grid/Row'; export { default as HelpIconPopover } from './components/HelpIconPopover'; export { default as HoverDropdownMenu } from './components/HoverDropdownMenu'; +export { default as DropdownMenu } from './components/DropdownMenu'; export { default as ImageCropper } from './components/ImageCropper'; export { default as InformationBox } from './components/InformationBox'; export { default as ListPicker } from './components/ListPicker'; diff --git a/src/index.js b/src/index.js index cf64bfa0f..152f83dd7 100644 --- a/src/index.js +++ b/src/index.js @@ -23,12 +23,14 @@ import Empty from './components/Empty'; import fastStatelessWrapper from './components/fastStatelessWrapper'; import FilePicker from './components/FilePicker'; import FlexibleSpacer from './components/FlexibleSpacer'; +import DismissibleFocusTrap from './components/DismissibleFocusTrap'; import FormGroup from './components/FormGroup'; import Grid from './components/Grid'; import GridCell from './components/Grid/Cell'; import GridRow from './components/Grid/Row'; import HelpIconPopover from './components/HelpIconPopover'; import HoverDropdownMenu from './components/HoverDropdownMenu'; +import DropdownMenu from './components/DropdownMenu'; import ImageCropper from './components/ImageCropper'; import InformationBox from './components/InformationBox'; import ListPicker from './components/ListPicker'; @@ -41,7 +43,7 @@ import Pagination from './components/Pagination'; import Panel from './components/Panel'; import Paragraph from './components/Paragraph'; import Pill from './components/Pill'; -import Popover from './components/Popover'; +import Popover, { usePopover } from './components/Popover'; import PrettyDiff from './components/PrettyDiff'; import Radio from './components/Radio'; import RadioGroup from './components/RadioGroup'; @@ -97,6 +99,7 @@ export { fastStatelessWrapper, FilePicker, FlexibleSpacer, + DismissibleFocusTrap, FormGroup, Grid, GridCell, @@ -111,6 +114,7 @@ export { Pagination, Panel, Popover, + usePopover, PrettyDiff, Radio, RadioGroup, @@ -139,6 +143,7 @@ export { InformationBox, ImageCropper, HoverDropdownMenu, + DropdownMenu, OverlayLoader, VerticalNav, RichTextEditor, diff --git a/www/containers/props.json b/www/containers/props.json index e95a50347..0ed1ed839 100644 --- a/www/containers/props.json +++ b/www/containers/props.json @@ -793,6 +793,23 @@ "required": false, "description": "" }, + "iconPosition": { + "type": { + "name": "enum", + "value": [ + { + "value": "'left'", + "computed": false + }, + { + "value": "'right'", + "computed": false + } + ] + }, + "required": false, + "description": "" + }, "theme": { "type": { "name": "string" @@ -1447,14 +1464,14 @@ "type": { "name": "array" }, - "required": false, + "required": true, "description": "" }, "name": { "type": { "name": "string" }, - "required": false, + "required": true, "description": "" }, "onChange": { @@ -1770,7 +1787,7 @@ "name": "func" }, "required": false, - "description": "" + "description": "useful if a menu/popover should be closed after tabbing through it" }, "onShiftTabExit": { "type": { @@ -1789,6 +1806,634 @@ } } ], + "src/components/DropdownMenu/index.jsx": [ + { + "description": "", + "displayName": "DropdownMenuProvider", + "methods": [] + }, + { + "description": "", + "displayName": "DropdownMenu", + "methods": [ + { + "name": "Content", + "docblock": null, + "modifiers": [ + "static" + ], + "params": [ + { + "name": "{ children, className, placement, modifiers, dts }", + "type": null + } + ], + "returns": null + }, + { + "name": "Trigger", + "docblock": null, + "modifiers": [ + "static" + ], + "params": [ + { + "name": "{ disabled = false, className, children, icon, ...rest }", + "type": null + } + ], + "returns": null + }, + { + "name": "Item", + "docblock": null, + "modifiers": [ + "static" + ], + "params": [ + { + "name": "{ children, onClick, disabled, className, dts, ...rest }", + "type": null + } + ], + "returns": null + }, + { + "name": "CheckboxGroup", + "docblock": null, + "modifiers": [ + "static" + ], + "params": [ + { + "name": "{ children, onChange, className, ...rest }", + "type": null + } + ], + "returns": null + }, + { + "name": "Checkbox", + "docblock": null, + "modifiers": [ + "static" + ], + "params": [ + { + "name": "{ children, className, ...rest }", + "type": null + } + ], + "returns": null + }, + { + "name": "CheckboxAll", + "docblock": null, + "modifiers": [ + "static" + ], + "params": [ + { + "name": "{ children, className, ...rest }", + "type": null + } + ], + "returns": null + }, + { + "name": "RadioGroup", + "docblock": null, + "modifiers": [ + "static" + ], + "params": [ + { + "name": "{ children, onChange, className, ...rest }", + "type": null + } + ], + "returns": null + }, + { + "name": "Radio", + "docblock": null, + "modifiers": [ + "static" + ], + "params": [ + { + "name": "{ children, onClick, className, ...rest }", + "type": null + } + ], + "returns": null + }, + { + "name": "Label", + "docblock": null, + "modifiers": [ + "static" + ], + "params": [ + { + "name": "{ children, className, ...rest }", + "type": null + } + ], + "returns": null + }, + { + "name": "ItemContainer", + "docblock": null, + "modifiers": [ + "static" + ], + "params": [ + { + "name": "{ children, className, ...rest }", + "type": null + } + ], + "returns": null + }, + { + "name": "Divider", + "docblock": null, + "modifiers": [ + "static" + ], + "params": [ + { + "name": "{ className }", + "type": null + } + ], + "returns": null + }, + { + "name": "Group", + "docblock": null, + "modifiers": [ + "static" + ], + "params": [ + { + "name": "{ children, className, defaultCollapsed, collapsible, title, id, ...rest }", + "type": null + } + ], + "returns": null + }, + { + "name": "useDropdownMenu", + "docblock": null, + "modifiers": [ + "static" + ], + "params": [], + "returns": null + } + ], + "props": { + "defaultOpen": { + "type": { + "name": "bool" + }, + "required": false, + "description": "Initial open state. Can be used to toggle the open state programatically." + }, + "closeOnItemClick": { + "type": { + "name": "bool" + }, + "required": false, + "description": "Closes the menu when an item with an onClick handler is clicked.\nAlso applies to checkboxes and radios." + }, + "onOpen": { + "type": { + "name": "func" + }, + "required": false, + "description": "" + }, + "onClose": { + "type": { + "name": "func" + }, + "required": false, + "description": "" + }, + "submenu": { + "type": { + "name": "bool" + }, + "required": false, + "description": "opt-in to submenu behaviour for nested menus" + }, + "triggerRef": { + "type": { + "name": "object" + }, + "required": false, + "description": "Optional ref to mount the dropdown to\n\n*Only use when using Dropdown.Trigger is not feasible*" + }, + "triggerId": { + "type": { + "name": "string" + }, + "required": false, + "description": "A unique trigger id is required for accessiblilty purposes" + }, + "contentId": { + "type": { + "name": "string" + }, + "required": false, + "description": "A unique content id is required for accessiblilty purposes" + }, + "children": { + "type": { + "name": "union", + "value": [ + { + "name": "node" + }, + { + "name": "func" + } + ] + }, + "required": false, + "description": "A render function may be used, which receives the dropdown context.\nNotably: `open` state, `closeMenu()` function, `triggerRef`, `contentRef`." + } + } + }, + { + "description": "", + "displayName": "DropdownMenuTrigger", + "methods": [], + "props": { + "isLoading": { + "type": { + "name": "bool" + }, + "required": false, + "description": "" + }, + "color": { + "type": { + "name": "enum", + "computed": true, + "value": "colors" + }, + "required": false, + "description": "" + }, + "variant": { + "type": { + "name": "enum", + "computed": true, + "value": "variants" + }, + "required": false, + "description": "" + }, + "size": { + "type": { + "name": "enum", + "computed": true, + "value": "sizes" + }, + "required": false, + "description": "" + }, + "iconPosition": { + "type": { + "name": "enum", + "computed": true, + "value": "positions" + }, + "required": false, + "description": "" + }, + "icon": { + "type": { + "name": "node" + }, + "required": false, + "description": "" + }, + "fullWidth": { + "type": { + "name": "bool" + }, + "required": false, + "description": "" + }, + "className": { + "type": { + "name": "string" + }, + "required": false, + "description": "" + }, + "dts": { + "type": { + "name": "string" + }, + "required": false, + "description": "" + }, + "disabled": { + "type": { + "name": "bool" + }, + "required": false, + "description": "", + "defaultValue": { + "value": "false", + "computed": false + } + }, + "children": { + "type": { + "name": "node" + }, + "required": false, + "description": "" + } + } + }, + { + "description": "ths is a separate component to cater for Popover `popoverContent`'s\nrender function and regular function component prop", + "displayName": "INNER_CONTENT", + "methods": [] + }, + { + "description": "Dropdown Menu Content container\nAny menu items should be children of this component", + "displayName": "DropdownMenuContent", + "methods": [], + "props": { + "children": { + "type": { + "name": "union", + "value": [ + { + "name": "func" + }, + { + "name": "node" + } + ] + }, + "required": false, + "description": "" + }, + "className": { + "type": { + "name": "string" + }, + "required": false, + "description": "" + }, + "dts": { + "type": { + "name": "string" + }, + "required": false, + "description": "" + }, + "id": { + "type": { + "name": "string" + }, + "required": false, + "description": "" + }, + "placement": { + "type": { + "name": "enum", + "computed": true, + "value": "popoverPlacements" + }, + "required": false, + "description": "" + }, + "modifiers": { + "type": { + "name": "union", + "value": [ + { + "name": "object" + }, + { + "name": "arrayOf", + "value": { + "name": "object" + } + } + ] + }, + "required": false, + "description": "" + } + } + }, + { + "description": "", + "displayName": "DropdownMenuItem", + "methods": [], + "props": { + "children": { + "type": { + "name": "node" + }, + "required": false, + "description": "" + }, + "disabled": { + "type": { + "name": "bool" + }, + "required": false, + "description": "" + }, + "icon": { + "type": { + "name": "node" + }, + "required": false, + "description": "" + }, + "className": { + "type": { + "name": "string" + }, + "required": false, + "description": "" + }, + "dts": { + "type": { + "name": "string" + }, + "required": false, + "description": "" + }, + "onClick": { + "type": { + "name": "func" + }, + "required": false, + "description": "" + } + } + }, + { + "description": "", + "displayName": "DropdownMenuCheckboxGroup", + "methods": [], + "composes": [ + "../CheckboxGroup" + ] + }, + { + "description": "", + "displayName": "DropdownMenuCheckbox", + "methods": [], + "composes": [ + "../Checkbox" + ] + }, + { + "description": "", + "displayName": "DropdownMenuCheckboxAll", + "methods": [], + "composes": [ + "../CheckboxGroup" + ] + }, + { + "description": "", + "displayName": "DropdownMenuRadioGroup", + "methods": [], + "composes": [ + "../RadioGroup" + ] + }, + { + "description": "", + "displayName": "DropdownMenuRadio", + "methods": [], + "composes": [ + "../Radio" + ] + }, + { + "description": "", + "displayName": "DropdownMenuLabel", + "methods": [], + "props": { + "className": { + "type": { + "name": "string" + }, + "required": false, + "description": "" + }, + "children": { + "type": { + "name": "node" + }, + "required": false, + "description": "" + } + } + }, + { + "description": "", + "displayName": "DropdownMenuDivider", + "methods": [], + "props": { + "className": { + "type": { + "name": "string" + }, + "required": false, + "description": "" + } + } + }, + { + "description": "a div Styled like a `DropdownMenu.Item`", + "displayName": "DropdownMenuItemContainer", + "methods": [], + "props": { + "className": { + "type": { + "name": "string" + }, + "required": false, + "description": "" + }, + "children": { + "type": { + "name": "node" + }, + "required": false, + "description": "" + } + } + }, + { + "description": "", + "displayName": "DropdownMenuGroup", + "methods": [], + "props": { + "className": { + "type": { + "name": "string" + }, + "required": false, + "description": "" + }, + "children": { + "type": { + "name": "node" + }, + "required": false, + "description": "" + }, + "id": { + "type": { + "name": "string" + }, + "required": false, + "description": "" + }, + "collapsible": { + "type": { + "name": "bool" + }, + "required": false, + "description": "Renders the group as a collapsible panel component" + }, + "defaultCollapsed": { + "type": { + "name": "bool" + }, + "required": false, + "description": "" + }, + "title": { + "type": { + "name": "string" + }, + "required": false, + "description": "The group's heading.\nTitle must be used if collapsible is true" + } + } + } + ], "src/components/Empty/index.jsx": [ { "description": "", diff --git a/www/containers/routes.js b/www/containers/routes.js index 361f3e257..995ff203d 100644 --- a/www/containers/routes.js +++ b/www/containers/routes.js @@ -24,6 +24,7 @@ import GridExample from '../examples/Grid.mdx'; import HelpIconPopoverExample from '../examples/HelpIconPopover.mdx'; import PrettyDiffExample from '../examples/PrettyDiff.mdx'; import HoverDropdownMenuExample from '../examples/HoverDropdownMenu.mdx'; +import DropdownMenuExample from '../examples/DropdownMenu.mdx'; import InformationBoxExample from '../examples/InformationBox.mdx'; import ImageCropperExample from '../examples/ImageCropper.mdx'; import ListPickerExample from '../examples/ListPicker.mdx'; @@ -258,6 +259,12 @@ const routes = [ title: 'Hover Dropdown Menu', group: 'Components', }, + { + path: '/dropdown-menu', + component: DropdownMenuExample, + title: 'Dropdown Menu', + group: 'Components', + }, { path: '/information-box', component: InformationBoxExample, diff --git a/www/examples/ButtonGroup.mdx b/www/examples/ButtonGroup.mdx index 0a8cdfb9f..259736f35 100644 --- a/www/examples/ButtonGroup.mdx +++ b/www/examples/ButtonGroup.mdx @@ -11,13 +11,16 @@ const Example = () => { - - diff --git a/www/examples/DropdownMenu.mdx b/www/examples/DropdownMenu.mdx new file mode 100644 index 000000000..fbba8cabf --- /dev/null +++ b/www/examples/DropdownMenu.mdx @@ -0,0 +1,250 @@ +import Props from '../containers/Props.jsx'; +import DesignNotes from '../containers/DesignNotes.jsx'; + +## Dropdown Menu + +```jsx live=true +const UserMenuItem = ( + + John Smith +
+ +
+
+); +const Example = () => { + const [state, setState] = React.useState(); + return ( + <> + + Manage Users + + + Admins + {UserMenuItem} + {UserMenuItem} + + + Users + {UserMenuItem} + {UserMenuItem} + + + +

+ + Menu + + + setState('Item 1')}>Item 1 + setState('Item 2')}>Item 2 + setState('Item 3')}>Item 3 + + + + Sub Menu A + + setState('Sub Item A1')}>Sub Item A1 + + + Sub Menu B + + setState('Sub Item B1')}>Sub Item B1 + + + Sub Menu C + + setState('Sub Item C1')}>Sub Item C1 + + + + + + + + +

You chose: {state}

+ + ); +}; + +render(Example); +``` + +## Dropdown Menu Checkbox and Radio groups + +This example shows how to use Checkbox and Radio groups within a menu + +```jsx live=true +const Example = () => { + const [radio, setRadio] = React.useState('block'); + const [checkbox, setCheckbox] = React.useState([]); + const [closeOnClick, setCloseOnClick] = React.useState(false); + + return ( + <> +
Close after selection
+ setCloseOnClick(v)} /> +
+

+ Radio: {radio} +
+ Checkbox: {checkbox.map((v) => v).join(', ')} +

+ + {({ closeMenu }) => ( + <> + + Menu + + + Elements + + + + + + + + Display + + + + + + + + + + + + )} + + + ); +}; + +render(Example); +``` + +## Dropdown Menu Collapsible Groups + +This example shows the menu with a lot of items causing scroll overflow, as well as the sticky group headers + +```jsx live=true +const Example = () => { + return ( + <> + + Menu + + + Item 1 + Item 2 + Item 3 + Item 1 + Item 2 + Item 3 + + + Item 2 + Item 3 + Item 2 + Item 3 + Item 2 + Item 3 + Item 2 + Item 3 + Item 2 + Item 3 + Item 2 + Item 3 + Item 2 + Item 3 + Item 2 + Item 3 + Item 2 + Item 3 + + + + + ); +}; + +render(Example); +``` + +### Design Notes + + +
+

Dropdown menu aids in helping the user discover selectable options.

+ +
+
+