diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fdb3b08e22..27bfb029118 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## [`master`](https://github.com/elastic/eui/tree/master) -No public interface changes since `22.5.0`. +- Converted `NavDrawer`, `NavDrawerGroup`, and `NavDrawerFlyout` to TypeScript ([#3268](https://github.com/elastic/eui/pull/3268)) ## [`22.5.0`](https://github.com/elastic/eui/tree/v22.5.0) diff --git a/src/components/list_group/list_group_item.tsx b/src/components/list_group/list_group_item.tsx index 6261018edaa..d790f3ca7df 100644 --- a/src/components/list_group/list_group_item.tsx +++ b/src/components/list_group/list_group_item.tsx @@ -119,7 +119,7 @@ export type EuiListGroupItemProps = CommonProps & * Pass-through ref reference specifically for targeting * instances where the item content is rendered as a `button` */ - buttonRef?: React.RefObject; + buttonRef?: React.Ref; }; export const EuiListGroupItem: FunctionComponent = ({ diff --git a/src/components/nav_drawer/__snapshots__/nav_drawer.test.js.snap b/src/components/nav_drawer/__snapshots__/nav_drawer.test.tsx.snap similarity index 100% rename from src/components/nav_drawer/__snapshots__/nav_drawer.test.js.snap rename to src/components/nav_drawer/__snapshots__/nav_drawer.test.tsx.snap diff --git a/src/components/nav_drawer/index.js b/src/components/nav_drawer/index.js deleted file mode 100644 index 76cfa898453..00000000000 --- a/src/components/nav_drawer/index.js +++ /dev/null @@ -1,5 +0,0 @@ -export { EuiNavDrawer } from './nav_drawer'; - -export { EuiNavDrawerGroup } from './nav_drawer_group'; - -export { EuiNavDrawerFlyout } from './nav_drawer_flyout'; diff --git a/src/components/nav_drawer/index.ts b/src/components/nav_drawer/index.ts new file mode 100644 index 00000000000..3cfe826ce5e --- /dev/null +++ b/src/components/nav_drawer/index.ts @@ -0,0 +1,6 @@ +export { EuiNavDrawer, EuiNavDrawerProps } from './nav_drawer'; +export { EuiNavDrawerGroup, EuiNavDrawerGroupProps } from './nav_drawer_group'; +export { + EuiNavDrawerFlyout, + EuiNavDrawerFlyoutProps, +} from './nav_drawer_flyout'; diff --git a/src/components/nav_drawer/nav_drawer.test.js b/src/components/nav_drawer/nav_drawer.test.tsx similarity index 92% rename from src/components/nav_drawer/nav_drawer.test.js rename to src/components/nav_drawer/nav_drawer.test.tsx index afa761a6511..f4d16a1263f 100644 --- a/src/components/nav_drawer/nav_drawer.test.js +++ b/src/components/nav_drawer/nav_drawer.test.tsx @@ -2,15 +2,16 @@ import React from 'react'; import { render } from 'enzyme'; import { EuiNavDrawer } from './nav_drawer'; -import { EuiNavDrawerGroup } from './nav_drawer_group'; +import { EuiNavDrawerGroup, FlyoutMenuItem } from './nav_drawer_group'; +import { EuiListGroupItemProps } from '../list_group'; -const extraAction = { +const extraAction: EuiListGroupItemProps['extraAction'] = { color: 'subdued', iconType: 'pin', iconSize: 's', }; -const topLinks = [ +const topLinks: FlyoutMenuItem[] = [ { label: 'Recently viewed', iconType: 'clock', @@ -74,7 +75,7 @@ const topLinks = [ }, ]; -const exploreLinks = [ +const exploreLinks: FlyoutMenuItem[] = [ { label: 'Canvas', href: '#', diff --git a/src/components/nav_drawer/nav_drawer.js b/src/components/nav_drawer/nav_drawer.tsx similarity index 74% rename from src/components/nav_drawer/nav_drawer.js rename to src/components/nav_drawer/nav_drawer.tsx index a3b093a4303..7fbac921cd0 100644 --- a/src/components/nav_drawer/nav_drawer.js +++ b/src/components/nav_drawer/nav_drawer.tsx @@ -1,31 +1,87 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; +import React, { + Component, + ReactNode, + createRef, + MouseEventHandler, + isValidElement, + HTMLAttributes, +} from 'react'; import classNames from 'classnames'; import { EuiListGroup, EuiListGroupItem } from '../list_group'; import { EuiNavDrawerFlyout } from './nav_drawer_flyout'; -import { EuiNavDrawerGroup, ATTR_SELECTOR } from './nav_drawer_group'; +import { + EuiNavDrawerGroup, + ATTR_SELECTOR, + FlyoutMenuItem, +} from './nav_drawer_group'; import { EuiOutsideClickDetector } from '../outside_click_detector'; import { EuiI18n } from '../i18n'; import { EuiFlexItem, EuiFlexGroup } from '../flex'; import { throttle } from '../color_picker/utils'; +import { CommonProps } from '../common'; const MENU_ELEMENT_ID = 'navDrawerMenu'; -export class EuiNavDrawer extends Component { - constructor(props) { - super(props); - this.expandButtonRef; +export interface EuiNavDrawerProps + extends CommonProps, + HTMLAttributes { + children?: ReactNode | ReactNode[]; - this.state = { - isLocked: props.isLocked, - isCollapsed: !props.isLocked, - flyoutIsCollapsed: true, - outsideClickDisabled: true, - isManagingFocus: false, - toolTipsEnabled: true, - focusReturnRef: null, - }; - } + /** + * Keep drawer locked open by default + */ + isLocked?: boolean; + + /** + * Returns the current state of isLocked + */ + onIsLockedUpdate?: (isLocked: boolean) => void; + + /** + * Adds fixed toggle button to bottom of menu area + */ + showExpandButton?: boolean; + + /** + * Display tooltips on side nav items + */ + showToolTips?: boolean; +} + +interface EuiNavDrawerState { + flyoutIsCollapsed: boolean; + flyoutListItems: FlyoutMenuItem[] | null; + focusReturnRef: ReactNode | null; + isCollapsed: boolean; + isLocked: boolean; + isManagingFocus: boolean; + navFlyoutTitle: string | undefined; + outsideClickDisabled: boolean; + toolTipsEnabled: boolean; +} + +export class EuiNavDrawer extends Component< + EuiNavDrawerProps, + EuiNavDrawerState +> { + static defaultProps = { + showExpandButton: true, + showToolTips: true, + }; + + state: EuiNavDrawerState = { + flyoutIsCollapsed: true, + flyoutListItems: null, + focusReturnRef: null, + isCollapsed: !this.props.isLocked, + isLocked: Boolean(this.props.isLocked), + isManagingFocus: false, + navFlyoutTitle: undefined, + outsideClickDisabled: true, + toolTipsEnabled: true, + }; + + expandButtonRef = createRef(); componentDidMount() { if (this.props.isLocked) { @@ -37,7 +93,7 @@ export class EuiNavDrawer extends Component { window.removeEventListener('resize', this.functionToCallOnWindowResize); } - returnOnIsLockedUpdate = isLockedState => { + returnOnIsLockedUpdate = (isLockedState: EuiNavDrawerState['isLocked']) => { if (this.props.onIsLockedUpdate) { this.props.onIsLockedUpdate(isLockedState); } @@ -52,16 +108,18 @@ export class EuiNavDrawer extends Component { }, 50); sideNavLockClicked = () => { - if (this.state.isLocked) { + const { isLocked } = this.state; + + if (isLocked) { window.removeEventListener('resize', this.functionToCallOnWindowResize); } else { window.addEventListener('resize', this.functionToCallOnWindowResize); } - this.returnOnIsLockedUpdate(!this.state.isLocked); + this.returnOnIsLockedUpdate(!isLocked); this.setState({ - isLocked: !this.state.isLocked, + isLocked: !isLocked, isCollapsed: false, outsideClickDisabled: true, }); @@ -93,8 +151,8 @@ export class EuiNavDrawer extends Component { this.collapseFlyout(); requestAnimationFrame(() => { - if (this.expandButtonRef) { - this.expandButtonRef.focus(); + if (this.expandButtonRef.current) { + this.expandButtonRef.current.focus(); } }); }; @@ -124,31 +182,34 @@ export class EuiNavDrawer extends Component { // Scrolls the menu and flyout back to top when the nav drawer collapses setTimeout(() => { - document.getElementById('navDrawerMenu').scrollTop = 0; + const element = document.getElementById('navDrawerMenu'); + if (element) { + element.scrollTop = 0; + } }, 50); // In case it was locked before, remove the window resize listener window.removeEventListener('resize', this.functionToCallOnWindowResize); }; - expandFlyout = (links, title, item) => { - const content = links; - + expandFlyout = ( + links: FlyoutMenuItem[], + title: string, + item: FlyoutMenuItem + ) => { if (this.state.navFlyoutTitle === title) { this.collapseFlyout(); } else { this.setState( - ({ isLocked }) => { - return { - flyoutIsCollapsed: false, - navFlyoutTitle: title, - navFlyoutContent: content, - isCollapsed: isLocked ? false : true, - toolTipsEnabled: false, - outsideClickDisabled: false, - focusReturnRef: item.label, - }; - }, + ({ isLocked }) => ({ + flyoutIsCollapsed: false, + flyoutListItems: links, + focusReturnRef: item.label, + isCollapsed: isLocked ? false : true, + navFlyoutTitle: title, + outsideClickDisabled: false, + toolTipsEnabled: false, + }), () => { // Ideally this uses React `ref` instead of `querySelector`, but the menu composition // does not allow for deep `ref` element management at present @@ -163,12 +224,12 @@ export class EuiNavDrawer extends Component { }; collapseFlyout = (shouldReturnFocus = true) => { - const focusReturn = this.state.focusReturnRef; + const { focusReturnRef: focusReturn } = this.state; this.setState( { flyoutIsCollapsed: true, - navFlyoutTitle: null, - navFlyoutContent: null, + navFlyoutTitle: undefined, + flyoutListItems: null, toolTipsEnabled: this.state.isLocked ? false : true, focusReturnRef: null, }, @@ -177,8 +238,10 @@ export class EuiNavDrawer extends Component { // does not allow for deep `ref` element management at present const element = document.querySelector( `#${MENU_ELEMENT_ID} [${ATTR_SELECTOR}='${focusReturn}']` - ); - if (!element) return; + ) as HTMLElement; + if (!element) { + return; + } requestAnimationFrame(() => { element.setAttribute('aria-expanded', 'false'); }); @@ -195,14 +258,14 @@ export class EuiNavDrawer extends Component { this.collapseFlyout(false); }; - handleDrawerMenuClick = e => { + handleDrawerMenuClick: MouseEventHandler = event => { // walk up e.target until either: // 1. a[href] - close the menu // 2. document.body - do nothing - let element = e.target; + let element = event.target as HTMLElement | null; while ( - element !== undefined && + element !== null && element !== document.body && (element.tagName !== 'A' || element.getAttribute('href') === undefined) ) { @@ -215,23 +278,27 @@ export class EuiNavDrawer extends Component { } }; - modifyChildren = children => { + modifyChildren = (children: ReactNode | ReactNode[]): ReactNode => { // Loop through the EuiNavDrawer children (EuiListGroup, EuiHorizontalRules, etc) // Filter out falsy items const filteredChildren = React.Children.toArray(children); return React.Children.map(filteredChildren, child => { - // Allow for Fragments by recursive modification - if (child.type === React.Fragment) { - return this.modifyChildren(child.props.children); - } else if (child.type === EuiNavDrawerGroup) { + if (isValidElement(child)) { + // Allow for Fragments by recursive modification + if (child.type === React.Fragment) { + return this.modifyChildren(child.props.children); + } + // Check if child is an EuiNavDrawerGroup and if it does have a flyout, add the expand function - return React.cloneElement(child, { - flyoutMenuButtonClick: this.expandFlyout, - showToolTips: this.state.toolTipsEnabled && this.props.showToolTips, - }); - } else { - return child; + if (child.type === EuiNavDrawerGroup) { + return React.cloneElement(child, { + flyoutMenuButtonClick: this.expandFlyout, + showToolTips: this.state.toolTipsEnabled && this.props.showToolTips, + }); + } } + + return child; }); }; @@ -241,7 +308,6 @@ export class EuiNavDrawer extends Component { className, showExpandButton, showToolTips, - isCollapsed, isLocked, onIsLockedUpdate, ...rest @@ -284,36 +350,36 @@ export class EuiNavDrawer extends Component { sideNavLockAriaLabel, sideNavLockExpanded, sideNavLockCollapsed, - ]) => ( + ]: string[]) => ( (this.expandButtonRef = node)} - label={this.state.isCollapsed ? sideNavExpand : sideNavCollapse} - iconType={this.state.isCollapsed ? 'menuRight' : 'menuLeft'} - size="s" + buttonRef={this.expandButtonRef} className={ this.state.isCollapsed ? 'navDrawerExpandButton-isCollapsed' : 'navDrawerExpandButton-isExpanded' } - showToolTip={this.state.isCollapsed} + data-test-subj={ + this.state.isCollapsed + ? 'navDrawerExpandButton-isCollapsed' + : 'navDrawerExpandButton-isExpanded' + } extraAction={{ + 'aria-label': sideNavLockAriaLabel, + 'aria-pressed': this.state.isLocked ? true : false, className: 'euiNavDrawer__expandButtonLockAction', color: 'text', - onClick: this.sideNavLockClicked, iconType: this.state.isLocked ? 'lock' : 'lockOpen', iconSize: 's', - 'aria-label': sideNavLockAriaLabel, + onClick: this.sideNavLockClicked, title: this.state.isLocked ? sideNavLockExpanded : sideNavLockCollapsed, - 'aria-pressed': this.state.isLocked ? true : false, }} + iconType={this.state.isCollapsed ? 'menuRight' : 'menuLeft'} + label={this.state.isCollapsed ? sideNavExpand : sideNavCollapse} onClick={this.collapseButtonClick} - data-test-subj={ - this.state.isCollapsed - ? 'navDrawerExpandButton-isCollapsed' - : 'navDrawerExpandButton-isExpanded' - } + showToolTip={this.state.isCollapsed} + size="s" /> )} @@ -324,16 +390,15 @@ export class EuiNavDrawer extends Component { const flyoutContent = ( ); - // Add an onClick that expands the flyout sub menu for any list items (links) - // that have a flyoutMenu prop (sub links) + // Add an onClick that expands the flyout sub menu for any list items (links) that have a flyoutMenu prop (sub links) let modifiedChildren = children; modifiedChildren = this.modifyChildren(this.props.children); @@ -365,33 +430,3 @@ export class EuiNavDrawer extends Component { ); } } - -EuiNavDrawer.propTypes = { - children: PropTypes.node, - className: PropTypes.string, - - /** - * Adds fixed toggle button to bottom of menu area - */ - showExpandButton: PropTypes.bool, - - /** - * Display tooltips on side nav items - */ - showToolTips: PropTypes.bool, - - /** - * Keep drawer locked open by default - */ - isLocked: PropTypes.bool, - - /** - * Returns the current state of isLocked - */ - onIsLockedUpdate: PropTypes.func, -}; - -EuiNavDrawer.defaultProps = { - showExpandButton: true, - showToolTips: true, -}; diff --git a/src/components/nav_drawer/nav_drawer_flyout.js b/src/components/nav_drawer/nav_drawer_flyout.js deleted file mode 100644 index 043043d4ea6..00000000000 --- a/src/components/nav_drawer/nav_drawer_flyout.js +++ /dev/null @@ -1,106 +0,0 @@ -import React, { useState } from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import tabbable from 'tabbable'; - -import { keyCodes } from '../../services'; - -import { EuiTitle } from '../title'; -import { EuiNavDrawerGroup } from './nav_drawer_group'; -import { EuiListGroup } from '../list_group/list_group'; -import { EuiFocusTrap } from '../focus_trap'; - -export const EuiNavDrawerFlyout = ({ - className, - title, - isCollapsed, - listItems, - wrapText, - onClose, - ...rest -}) => { - const [menuEl, setMenuEl] = useState(); - const [tabbables, setTabbables] = useState(); - const LABEL = 'navDrawerFlyoutTitle'; - const classes = classNames( - 'euiNavDrawerFlyout', - { - 'euiNavDrawerFlyout-isCollapsed': isCollapsed, - 'euiNavDrawerFlyout-isExpanded': !isCollapsed, - }, - className - ); - - const handleKeyDown = e => { - if (e.keyCode === keyCodes.ESCAPE) { - handleClose(); - } else if (e.keyCode === keyCodes.TAB) { - let tabs = tabbables; - if (!tabs) { - tabs = tabbable(menuEl).filter(el => el.tagName !== 'DIV'); - setTabbables(tabs); - } - if ( - (!e.shiftKey && document.activeElement === tabs[tabs.length - 1]) || - (e.shiftKey && document.activeElement === tabs[0]) - ) { - handleClose(); - } - } - }; - - const handleClose = (shouldReturnFocus = true) => { - setTabbables(null); - onClose(shouldReturnFocus); - }; - - return ( -
- -
{title}
-
- {listItems ? ( - - handleClose(false)} - /> - - ) : null} -
- ); -}; - -EuiNavDrawerFlyout.propTypes = { - className: PropTypes.string, - listItems: EuiListGroup.propTypes.listItems, - wrapText: EuiListGroup.propTypes.wrapText, - - /** - * Display a title atop the flyout - */ - title: PropTypes.string, - - /** - * Toggle the nav drawer between collapsed and expanded - */ - isCollapsed: PropTypes.bool, - - /** - * Passthrough function to be called when the flyout is closing - * See ./nav_drawer.js - */ - onClose: PropTypes.func, -}; - -EuiNavDrawerFlyout.defaultProps = { - isCollapsed: true, -}; diff --git a/src/components/nav_drawer/nav_drawer_flyout.tsx b/src/components/nav_drawer/nav_drawer_flyout.tsx new file mode 100644 index 00000000000..d387281ae09 --- /dev/null +++ b/src/components/nav_drawer/nav_drawer_flyout.tsx @@ -0,0 +1,122 @@ +import React, { + useState, + FunctionComponent, + KeyboardEventHandler, + HTMLAttributes, + useRef, +} from 'react'; +import classNames from 'classnames'; +import tabbable from 'tabbable'; + +import { keyCodes } from '../../services'; + +import { EuiTitle } from '../title'; +import { EuiNavDrawerGroup, FlyoutMenuItem } from './nav_drawer_group'; +import { EuiListGroupProps } from '../list_group/list_group'; +import { EuiFocusTrap } from '../focus_trap'; +import { CommonProps } from '../common'; + +export interface EuiNavDrawerFlyoutProps + extends CommonProps, + HTMLAttributes { + /** + * Toggle the nav drawer between collapsed and expanded + */ + isCollapsed?: boolean; + + listItems?: FlyoutMenuItem[] | null; + + /** + * Passthrough function to be called when the flyout is closing + * @see `EuiNavDrawer` + */ + onClose?: (shouldReturnFocus?: boolean) => void; + + /** + * Display a title atop the flyout + */ + title?: string; + + wrapText?: EuiListGroupProps['wrapText']; +} + +export const EuiNavDrawerFlyout: FunctionComponent = ({ + className, + isCollapsed = true, + listItems, + onClose, + title, + wrapText = false, + ...rest +}) => { + const menuElementRef = useRef(null); + const [ + tabbables, + setTabbables, + ] = useState | null>(); + const LABEL = 'navDrawerFlyoutTitle'; + const classes = classNames( + 'euiNavDrawerFlyout', + { + 'euiNavDrawerFlyout-isCollapsed': isCollapsed, + 'euiNavDrawerFlyout-isExpanded': !isCollapsed, + }, + className + ); + + const handleKeyDown: KeyboardEventHandler = event => { + if (event.keyCode === keyCodes.ESCAPE) { + handleClose(); + } else if (event.keyCode === keyCodes.TAB) { + let tabs = tabbables; + if (!tabs && menuElementRef.current) { + tabs = tabbable(menuElementRef.current).filter( + element => element.tagName !== 'DIV' + ); + setTabbables(tabs); + } + if (!tabs) { + return; + } + if ( + (!event.shiftKey && document.activeElement === tabs[tabs.length - 1]) || + (event.shiftKey && document.activeElement === tabs[0]) + ) { + handleClose(); + } + } + }; + + const handleClose = (shouldReturnFocus = true) => { + setTabbables(null); + if (onClose) { + onClose(shouldReturnFocus); + } + }; + + return ( +
+ +
+ {title} +
+
+ {listItems ? ( + + handleClose(false)} + /> + + ) : null} +
+ ); +}; diff --git a/src/components/nav_drawer/nav_drawer_group.js b/src/components/nav_drawer/nav_drawer_group.tsx similarity index 59% rename from src/components/nav_drawer/nav_drawer_group.js rename to src/components/nav_drawer/nav_drawer_group.tsx index b7d383151d7..9c5edd2a977 100644 --- a/src/components/nav_drawer/nav_drawer_group.js +++ b/src/components/nav_drawer/nav_drawer_group.tsx @@ -1,38 +1,65 @@ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, { FunctionComponent, ReactNode } from 'react'; import classNames from 'classnames'; -import { EuiListGroup } from '../list_group/list_group'; +import { EuiListGroup, EuiListGroupProps } from '../list_group/list_group'; +import { EuiListGroupItemProps } from '../list_group/list_group_item'; import { toInitials } from '../../services'; export const ATTR_SELECTOR = 'data-name'; -export const EuiNavDrawerGroup = ({ +export type FlyoutMenuItem = EuiListGroupItemProps & { + 'data-name'?: ReactNode | ReactNode[]; + flyoutMenu?: { + title: string; + listItems: FlyoutMenuItem[]; + }; + label: string; +}; + +export interface EuiNavDrawerGroupProps extends EuiListGroupProps { + listItems?: FlyoutMenuItem[]; + + /** + * While not normally required, it is required to pass a function for handling + * of the flyout menu button click + */ + flyoutMenuButtonClick?: ( + links: FlyoutMenuItem[], + title: string, + item: FlyoutMenuItem + ) => void; + + /** + * Passthrough function to be called when the flyout is closing + * @see `EuiNavDrawer` + */ + onClose?: () => void; +} + +export const EuiNavDrawerGroup: FunctionComponent = ({ className, listItems, flyoutMenuButtonClick, onClose = () => {}, ...rest }) => { - const classes = classNames('euiNavDrawerGroup', className); - - const listItemsExists = listItems && !!listItems.length; - // Alter listItems object with prop flyoutMenu and extra props - const newListItems = !listItemsExists + const newListItems = !(listItems && !!listItems.length) ? undefined : listItems.map(item => { // If the flyout menu exists, pass back the list of times and the title with the onClick handler of the item - const { flyoutMenu, onClick, ...itemProps } = item; + const { flyoutMenu, ...itemProps } = item; if (flyoutMenu && flyoutMenuButtonClick) { const items = [...flyoutMenu.listItems]; const title = `${flyoutMenu.title}`; - itemProps.onClick = () => flyoutMenuButtonClick(items, title, item); + itemProps.onClick = () => { + flyoutMenuButtonClick(items, title, item); + }; itemProps['aria-expanded'] = false; } else { - itemProps.onClick = (...args) => { - if (onClick) { - onClick(...args); + itemProps.onClick = event => { + if (item.onClick) { + item.onClick(event); } onClose(); }; @@ -62,28 +89,10 @@ export const EuiNavDrawerGroup = ({ }); return ( - + ); }; - -EuiNavDrawerGroup.propTypes = { - listItems: PropTypes.arrayOf( - PropTypes.shape({ - ...EuiListGroup.propTypes.listItems[0], - flyoutMenu: PropTypes.shape({ - title: PropTypes.string.isRequired, - listItems: EuiListGroup.propTypes.listItems.isRequired, - }), - }) - ), - /** - * While not normally required, it is required to pass a function for handling - * of the flyout menu button click - */ - flyoutMenuButtonClick: PropTypes.func, - /** - * Passthrough function to be called when the flyout is closing - * See ./nav_drawer.js - */ - onClose: PropTypes.func, -};