diff --git a/CHANGELOG.md b/CHANGELOG.md index c38227a9a5e..0b7872a70d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ ## [`master`](https://github.com/elastic/eui/tree/master) +### Feature: EuiCollapsibleNav ([#3019](https://github.com/elastic/eui/pull/3019)) + +- Added `EuiCollapsibleNav` and `EuiCollapsibleNavGroup` components +- Added `EuiPinnableListGroup`, an extension of `EuiListGroup` +- Added `ghost` colored `EuiListGroupItem`, increased overall large size, and fixed focus states +- Added `color` and `size` props to `EuiListGroup` +- Added `home` and `menu` glyphs to `EuiIcon` +- Added simple `euiXScroll` and `euiYScroll` SASS mixins and CSS utility equivelants + +**Bug Fixes** + +- Fixed `EuiAccordion` icon margins, focus state, and flex issue in IE +- Fixed `1.1px` height of `EuiHorizontalRule` + +### Extraneous to feature + - Improved `EuiModal` close button position to prevent from overlapping with the title ([#3176](https://github.com/elastic/eui/pull/3176)) **Bug Fixes** @@ -7,12 +23,13 @@ - Fixed EuiBasicTable proptypes of itemId ([#3133](https://github.com/elastic/eui/pull/3133)) - Updated `EuiSuperDatePicker` to inherit the selected value in quick select ([#3105](https://github.com/elastic/eui/pull/3105)) + ## [`22.1.0`](https://github.com/elastic/eui/tree/v22.1.0) - Added `delimiter` prop to `EuiComboBox` ([#3104](https://github.com/elastic/eui/pull/3104)) - Added `useColorPickerState` and `useColorStopsState` utilities ([#3067](https://github.com/elastic/eui/pull/3067)) - Fixed `EuiSearchBar` related types ([#3147](https://github.com/elastic/eui/pull/3147)) -- Added `prepend` and `append` ability to `EuiSuperSelect` ([#3167](https://github.com/elastic/eui/pull/3167)) +- Added `prepend` and `append` ability to `EuiSuperSelect` ([#3167](https://github.com/elastic/eui/pull/3167)) ## [`22.0.0`](https://github.com/elastic/eui/tree/v22.0.0) diff --git a/scripts/a11y-testing.js b/scripts/a11y-testing.js index 19532560db8..e9ed65cf31c 100644 --- a/scripts/a11y-testing.js +++ b/scripts/a11y-testing.js @@ -19,6 +19,7 @@ const docsPages = async (root, page) => { `${root}#/layout/spacer`, `${root}#/navigation/breadcrumbs`, `${root}#/navigation/context-menu`, + `${root}#/navigation/collapsible-nav`, `${root}#/navigation/control-bar`, `${root}#/navigation/facet`, `${root}#/navigation/link`, diff --git a/src-docs/src/components/guide_components.scss b/src-docs/src/components/guide_components.scss index a37483cf9bc..249c88f7ff0 100644 --- a/src-docs/src/components/guide_components.scss +++ b/src-docs/src/components/guide_components.scss @@ -5,6 +5,10 @@ $guideZLevelHighest: $euiZLevel9 + 1000; .guideBody { background: linear-gradient(90deg, $euiPageBackgroundColor 50%, $euiColorEmptyShade 50%); + + &--overflowHidden { + overflow: hidden; + } } .guidePage { @@ -182,6 +186,14 @@ $guideZLevelHighest: $euiZLevel9 + 1000; height: 1px; } +.guideFullScreenOverlay { + position: fixed; + width: 100%; + height: 100%; + left: 0; + top: 0; +} + @import '../views/guidelines/index'; @import 'guide_section/index'; @import 'guide_rule/index'; diff --git a/src-docs/src/routes.js b/src-docs/src/routes.js index 7cff19e5a28..377a0806a94 100644 --- a/src-docs/src/routes.js +++ b/src-docs/src/routes.js @@ -63,6 +63,8 @@ import { CodeEditorExample } from './views/code_editor/code_editor_example'; import { CodeExample } from './views/code/code_example'; +import { CollapsibleNavExample } from './views/collapsible_nav/collapsible_nav_example'; + import { ColorPickerExample } from './views/color_picker/color_picker_example'; import { ComboBoxExample } from './views/combo_box/combo_box_example'; @@ -321,6 +323,7 @@ const navigation = [ items: [ BreadcrumbsExample, ButtonExample, + CollapsibleNavExample, ContextMenuExample, ControlBarExample, FacetExample, diff --git a/src-docs/src/services/full_screen/full_screen.tsx b/src-docs/src/services/full_screen/full_screen.tsx new file mode 100644 index 00000000000..716e0e93c1a --- /dev/null +++ b/src-docs/src/services/full_screen/full_screen.tsx @@ -0,0 +1,43 @@ +import React, { + useState, + Fragment, + FunctionComponent, + ReactElement, + ReactNode, + useEffect, +} from 'react'; + +import { EuiFocusTrap } from '../../../../src/components/focus_trap'; +import { EuiButton } from '../../../../src/components/button'; + +export const GuideFullScreen: FunctionComponent<{ + children: (setFullScreen: (isFullScreen: boolean) => void) => ReactElement; + buttonText?: ReactNode; + isFullScreen?: boolean; +}> = ({ + children, + isFullScreen = false, + buttonText = 'Show fullscreen demo', +}) => { + const [fullScreen, setFullScreen] = useState(isFullScreen); + + // Watch for fullScreen status and appropriately add/remove body classes for hiding scroll + useEffect(() => { + if (fullScreen) { + document.body.classList.add('guideBody--overflowHidden'); + } + return () => { + document.body.classList.remove('guideBody--overflowHidden'); + }; + }, [fullScreen]); + + return ( + + setFullScreen(true)} iconType="fullScreen"> + {buttonText} + + + {fullScreen && {children(setFullScreen)}} + + ); +}; diff --git a/src-docs/src/views/collapsible_nav/collapsible_nav.tsx b/src-docs/src/views/collapsible_nav/collapsible_nav.tsx new file mode 100644 index 00000000000..1846d214457 --- /dev/null +++ b/src-docs/src/views/collapsible_nav/collapsible_nav.tsx @@ -0,0 +1,50 @@ +import React, { useState } from 'react'; + +import { EuiCollapsibleNav } from '../../../../src/components/collapsible_nav'; +import { EuiButton, EuiButtonToggle } from '../../../../src/components/button'; +import { EuiTitle } from '../../../../src/components/title'; +import { EuiSpacer } from '../../../../src/components/spacer'; +import { EuiText } from '../../../../src/components/text'; +import { EuiCode } from '../../../../src/components/code'; + +export default () => { + const [navIsOpen, setNavIsOpen] = useState(false); + const [navIsDocked, setNavIsDocked] = useState(false); + + return ( + <> + setNavIsOpen(!navIsOpen)}> + Toggle nav + + } + isDocked={navIsDocked} + onClose={() => setNavIsOpen(false)}> +
+ +

I am some nav

+
+ + { + setNavIsDocked(!navIsDocked); + }} + /> +
+
+ + {navIsDocked && ( + +

+ The button gets hidden by default when nav is docked unless you set{' '} + hideButtonIfDocked = false. +

+
+ )} + + ); +}; diff --git a/src-docs/src/views/collapsible_nav/collapsible_nav_all.tsx b/src-docs/src/views/collapsible_nav/collapsible_nav_all.tsx new file mode 100644 index 00000000000..4f663f4830f --- /dev/null +++ b/src-docs/src/views/collapsible_nav/collapsible_nav_all.tsx @@ -0,0 +1,281 @@ +import React, { useState } from 'react'; +import _ from 'lodash'; + +import { + EuiCollapsibleNav, + EuiCollapsibleNavGroup, +} from '../../../../src/components/collapsible_nav'; +import { + EuiHeaderSectionItemButton, + EuiHeaderLogo, + EuiHeader, +} from '../../../../src/components/header'; +import { EuiIcon } from '../../../../src/components/icon'; +import { EuiButtonEmpty } from '../../../../src/components/button'; +import { EuiPage } from '../../../../src/components/page'; +import { + EuiPinnableListGroup, + EuiListGroupItem, + EuiPinnableListGroupItemProps, +} from '../../../../src/components/list_group'; +import { EuiFlexItem } from '../../../../src/components/flex'; +import { EuiHorizontalRule } from '../../../../src/components/horizontal_rule'; +import { GuideFullScreen } from '../../services/full_screen/full_screen'; + +import { + DeploymentsGroup, + KibanaNavLinks, + SecurityGroup, +} from './collapsible_nav_list'; +import { EuiShowFor } from '../../../../src/components/responsive'; + +const TopLinks: EuiPinnableListGroupItemProps[] = [ + { + label: 'Home', + iconType: 'home', + isActive: true, + 'aria-current': true, + href: '#/navigation/collapsible-nav', + pinnable: false, + }, +]; +const KibanaLinks: EuiPinnableListGroupItemProps[] = KibanaNavLinks.map( + link => { + return { + ...link, + href: '#/navigation/collapsible-nav', + }; + } +); +const LearnLinks: EuiPinnableListGroupItemProps[] = [ + { label: 'Docs', href: '#/navigation/collapsible-nav' }, + { label: 'Blogs', href: '#/navigation/collapsible-nav' }, + { label: 'Webinars', href: '#/navigation/collapsible-nav' }, + { label: 'Elastic.co', href: 'https://elastic.co' }, +]; + +export default () => { + const [navIsOpen, setNavIsOpen] = useState( + JSON.parse(String(localStorage.getItem('navIsDocked'))) || false + ); + const [navIsDocked, setNavIsDocked] = useState( + JSON.parse(String(localStorage.getItem('navIsDocked'))) || false + ); + + /** + * Accordion toggling + */ + const [openGroups, setOpenGroups] = useState( + JSON.parse(String(localStorage.getItem('openNavGroups'))) || [ + 'Kibana', + 'Learn', + ] + ); + + // Save which groups are open and which are not with state and local store + const toggleAccordion = (isOpen: boolean, title?: string) => { + if (!title) return; + const itExists = openGroups.includes(title); + if (isOpen) { + if (itExists) return; + openGroups.push(title); + } else { + const index = openGroups.indexOf(title); + if (index > -1) { + openGroups.splice(index, 1); + } + } + setOpenGroups([...openGroups]); + localStorage.setItem('openNavGroups', JSON.stringify(openGroups)); + }; + + /** + * Pinning + */ + const [pinnedItems, setPinnedItems] = useState< + EuiPinnableListGroupItemProps[] + >(JSON.parse(String(localStorage.getItem('pinnedItems'))) || []); + + const addPin = (item: any) => { + if (!item || _.find(pinnedItems, { label: item.label })) { + return; + } + item.pinned = true; + const newPinnedItems = pinnedItems ? pinnedItems.concat(item) : [item]; + setPinnedItems(newPinnedItems); + localStorage.setItem('pinnedItems', JSON.stringify(newPinnedItems)); + }; + + const removePin = (item: any) => { + const pinIndex = _.findIndex(pinnedItems, { label: item.label }); + if (pinIndex > -1) { + item.pinned = false; + const newPinnedItems = pinnedItems; + newPinnedItems.splice(pinIndex, 1); + setPinnedItems([...newPinnedItems]); + localStorage.setItem('pinnedItems', JSON.stringify(newPinnedItems)); + } + }; + + function alterLinksWithCurrentState( + links: EuiPinnableListGroupItemProps[], + showPinned = false + ): EuiPinnableListGroupItemProps[] { + return links.map(link => { + const { pinned, ...rest } = link; + return { + pinned: showPinned ? pinned : false, + ...rest, + }; + }); + } + + function addLinkNameToPinTitle(listItem: EuiPinnableListGroupItemProps) { + return `Pin ${listItem.label} to top`; + } + + function addLinkNameToUnpinTitle(listItem: EuiPinnableListGroupItemProps) { + return `Unpin ${listItem.label}`; + } + + const collapsibleNav = ( + setNavIsOpen(!navIsOpen)}> + + ); + + const leftSectionItems = [ + collapsibleNav, + Elastic, + ]; + + return ( + + {setIsFullScreen => ( + + setIsFullScreen(false)}> + Exit full screen + , + ], + }, + ]} + /> + + + + )} + + ); +}; diff --git a/src-docs/src/views/collapsible_nav/collapsible_nav_example.js b/src-docs/src/views/collapsible_nav/collapsible_nav_example.js new file mode 100644 index 00000000000..94b9a04327f --- /dev/null +++ b/src-docs/src/views/collapsible_nav/collapsible_nav_example.js @@ -0,0 +1,214 @@ +import React from 'react'; +import { Link } from 'react-router'; + +import { renderToHtml } from '../../services'; + +import { GuideSectionTypes } from '../../components'; + +import { + EuiCode, + EuiCollapsibleNav, + EuiText, + EuiSpacer, + EuiCallOut, + EuiCollapsibleNavGroup, +} from '../../../../src/components'; + +import CollapsibleNav from './collapsible_nav'; +const collapsibleNavSource = require('!!raw-loader!./collapsible_nav'); +const collapsibleNavHtml = renderToHtml(CollapsibleNav); + +import CollapsibleNavGroup from './collapsible_nav_group'; +const collapsibleNavGroupSource = require('!!raw-loader!./collapsible_nav_group'); +const collapsibleNavGroupHtml = renderToHtml(CollapsibleNavGroup); + +import CollapsibleNavList from './collapsible_nav_list'; +const collapsibleNavListSource = require('!!raw-loader!./collapsible_nav_list'); +const collapsibleNavListHtml = renderToHtml(CollapsibleNavList); + +import CollapsibleNavAll from './collapsible_nav_all'; +const collapsibleNavAllSource = require('!!raw-loader!./collapsible_nav_all'); +const collapsibleNavAllHtml = renderToHtml(CollapsibleNavAll); + +export const CollapsibleNavExample = { + title: 'Collapsible nav', + intro: ( + +

+ This is a high level component that creates a flyout-style navigational + pane. It is the next evolution of{' '} + + EuiNavDrawer + {' '} + which will be deprecated soon. +

+ +
+ ), + sections: [ + { + source: [ + { + type: GuideSectionTypes.JS, + code: collapsibleNavSource, + }, + { + type: GuideSectionTypes.HTML, + code: collapsibleNavHtml, + }, + ], + text: ( + <> +

+ EuiCollapsibleNav is a similar implementation to{' '} + + EuiFlyout + + ; the visibility of which must be maintained by the consuming + application. An extra feature that it provides is the ability to{' '} + dock the flyout. This affixes the flyout to the + window and pushes the body content by adding left side padding. +

+ + + ), + props: { EuiCollapsibleNav }, + demo: , + snippet: ` setNavIsOpen(!navIsOpen)}>Toggle nav} + isOpen={navIsOpen} + isDocked={navIsDocked} + onClose={() => setNavIsOpen(false)} +/>`, + }, + { + title: 'Collapsible nav group', + source: [ + { + type: GuideSectionTypes.JS, + code: collapsibleNavGroupSource, + }, + { + type: GuideSectionTypes.HTML, + code: collapsibleNavGroupHtml, + }, + ], + text: ( + <> +

+ An EuiCollapsibleNavGroup adds some basic borders + and background color of none,{' '} + light, or dark. Give each + section a heading by providing an optional title{' '} + and iconType. Make the section collapsible ( + accordion style) with{' '} + isCollapsible=true. +

+

+ When in isCollapsible mode, a{' '} + title and{' '} + initialIsOpen:boolean is required. +

+ + ), + props: { + EuiCollapsibleNavGroup, + }, + demo: , + snippet: ``, + }, + { + title: 'Nav groups with lists and other content', + source: [ + { + type: GuideSectionTypes.JS, + code: collapsibleNavListSource, + }, + { + type: GuideSectionTypes.HTML, + code: collapsibleNavListHtml, + }, + ], + text: ( + <> +

+ EuiCollapsibleNavGroups can contain any children. + They work well with{' '} + + EuiListGroup, EuiPinnableListGroup + {' '} + and simple{' '} + + EuiText + + . +

+

Below are a few established patterns to use.

+ + ), + demo: , + snippet: ` + {}} + maxWidth="none" + color="subdued" + gutterSize="none" + size="s" + /> +`, + }, + { + title: 'Full pattern with header and saved pins', + source: [ + { + type: GuideSectionTypes.JS, + code: collapsibleNavAllSource, + }, + { + type: GuideSectionTypes.HTML, + code: collapsibleNavAllHtml, + }, + ], + text: ( + <> +

Putting it all together

+

+ The button below will launch a full screen example that includes{' '} + + EuiHeader + {' '} + with a toggle button to open an EuiCollapsibleNav. + The contents of which are multiple{' '} + EuiCollapsibleNavGroups and saves the + open/closed/pinned state for each section and item in local store. +

+

+ This is just a pattern and should be treated as such. Consuming + applications will need to create the navigation groups according to + their context and save the states as is appropriate to their data + store. +

+ + ), + demo: , + }, + ], +}; diff --git a/src-docs/src/views/collapsible_nav/collapsible_nav_group.tsx b/src-docs/src/views/collapsible_nav/collapsible_nav_group.tsx new file mode 100644 index 00000000000..89463b9a46d --- /dev/null +++ b/src-docs/src/views/collapsible_nav/collapsible_nav_group.tsx @@ -0,0 +1,55 @@ +import React from 'react'; + +import { EuiCollapsibleNavGroup } from '../../../../src/components/collapsible_nav'; +import { EuiText } from '../../../../src/components/text'; +import { EuiCode } from '../../../../src/components/code'; + +export default () => ( + <> + + +

This is a basic group without any modifications

+
+
+ + +

+ This is a nice group with a heading supplied via{' '} + title and iconType. +

+
+
+ + +

+ This group is collapsible and set with{' '} + initialIsOpen. It has a heading that is the + collapsing button via title and{' '} + iconType. +

+
+
+ + +

+ This is a dark collapsible group + that is initally set to closed,{' '} + iconSize="xxl" and{' '} + titleSize="s". +

+
+
+ +); diff --git a/src-docs/src/views/collapsible_nav/collapsible_nav_list.tsx b/src-docs/src/views/collapsible_nav/collapsible_nav_list.tsx new file mode 100644 index 00000000000..c64b3661cf3 --- /dev/null +++ b/src-docs/src/views/collapsible_nav/collapsible_nav_list.tsx @@ -0,0 +1,128 @@ +import React from 'react'; + +import { EuiCollapsibleNavGroup } from '../../../../src/components/collapsible_nav'; +import { EuiText } from '../../../../src/components/text'; +import { + EuiListGroup, + EuiListGroupProps, + EuiPinnableListGroup, + EuiPinnableListGroupItemProps, +} from '../../../../src/components/list_group'; +import { EuiSpacer } from '../../../../src/components/spacer'; +import { EuiButton, EuiButtonIcon } from '../../../../src/components/button'; +import { EuiLink } from '../../../../src/components/link'; + +const deploymentsList: EuiListGroupProps['listItems'] = [ + { + label: 'combining-binaries', + iconType: 'logoAzureMono', + size: 's', + }, + { + label: 'stack-monitoring', + iconType: 'logoAWSMono', + size: 's', + }, +]; + +export const TopNavLinks: EuiPinnableListGroupItemProps[] = [ + { + label: 'Home', + iconType: 'home', + isActive: true, + pinnable: false, + }, + { label: 'Dashboards', pinned: true }, + { label: 'Dev tools', pinned: true }, + { label: 'Maps', pinned: true }, +]; + +export const KibanaNavLinks: EuiPinnableListGroupItemProps[] = [ + { label: 'Discover' }, + { label: 'Visualize' }, + { label: 'Dashboards' }, + { label: 'Canvas' }, + { label: 'Maps' }, + { label: 'Machine Learning' }, + { label: 'Graph' }, +]; + +export const DeploymentsGroup = ( + + Deployment
+ personal-databoard + + } + iconType="logoGCPMono" + iconSize="xl" + isCollapsible={true} + initialIsOpen={false} + background="dark"> +
+ + + + Manage deployments + +
+
+); + +export const SecurityGroup = ( + + }> + +

+ Threat prevention, detection, and response with SIEM and endpoint + security. +
+ Learn more +

+
+
+); + +export default () => ( + <> + {DeploymentsGroup} + + {}} + maxWidth="none" + color="text" + gutterSize="none" + size="s" + /> + + + {}} + maxWidth="none" + color="subdued" + gutterSize="none" + size="s" + /> + + {SecurityGroup} + +); diff --git a/src-docs/src/views/icon/icons.js b/src-docs/src/views/icon/icons.js index 9b3fb3853f8..d8f76852a32 100644 --- a/src-docs/src/views/icon/icons.js +++ b/src-docs/src/views/icon/icons.js @@ -94,6 +94,7 @@ export const iconTypes = [ 'heart', 'heatmap', 'help', + 'home', 'iInCircle', 'image', 'importAction', @@ -129,6 +130,7 @@ export const iconTypes = [ 'mapMarker', 'memory', 'merge', + 'menu', 'menuLeft', 'menuRight', 'minimize', diff --git a/src-docs/src/views/list_group/list_group_example.js b/src-docs/src/views/list_group/list_group_example.js index 7e704b16cfc..bf2aa4cd593 100644 --- a/src-docs/src/views/list_group/list_group_example.js +++ b/src-docs/src/views/list_group/list_group_example.js @@ -1,4 +1,5 @@ import React from 'react'; +import { Link } from 'react-router'; import { renderToHtml } from '../../services'; @@ -7,8 +8,10 @@ import { GuideSectionTypes } from '../../components'; import { EuiListGroup, EuiListGroupItem, + EuiPinnableListGroup, EuiCode, } from '../../../../src/components'; +import { EuiPinnableListGroupItem } from './props'; import ListGroup from './list_group'; const listGroupSource = require('!!raw-loader!./list_group'); @@ -30,6 +33,10 @@ import ListGroupItemColor from './list_group_item_color'; const listGroupItemColorSource = require('!!raw-loader!./list_group_item_color'); const listGroupItemColorHtml = renderToHtml(ListGroupItemColor); +import PinnableListGroup from './pinnable_list_group'; +const pinnableListGroupSource = require('!!raw-loader!./pinnable_list_group'); +const pinnableListGroupHtml = renderToHtml(PinnableListGroup); + export const ListGroupExample = { title: 'List group', sections: [ @@ -191,7 +198,8 @@ export const ListGroupExample = { anchor, or span. You can enforce a different color of primary,{' '} text, or subdued with the{' '} - color prop. + color prop. Or provide the prop directly to{' '} + EuiListGroup.

They also accept options for text size;{' '} @@ -204,6 +212,58 @@ export const ListGroupExample = { label="Primary" color="primary" size="s" +/>`, + }, + { + title: 'Pinnable list group', + source: [ + { + type: GuideSectionTypes.JS, + code: pinnableListGroupSource, + }, + { + type: GuideSectionTypes.HTML, + code: pinnableListGroupHtml, + }, + ], + text: ( + <> +

+ EuiPinnableListGroup is simply an extra wrapper + around an{' '} + + EuiListGroup + {' '} + that provides visual indicators for pinning. +

+

+ Pinning is the concept that users can click a pin icon and add it to + a subset of links (most likely shown in different list group). By + providing an onPinClick handler, the component + will automatically add the pin action to the item. However, the + consuming application must manage the listItems + and their pinned state. +

+

+ In order to get the full benefit of using{' '} + EuiPinnableListGroup, the component only supports + providing list items via the listItem prop and + does not support children. +

+ + ), + props: { EuiPinnableListGroup, EuiPinnableListGroupItem }, + demo: , + snippet: ` {}} + listItems={[ + { + label: 'A link', + href: '#', + pinned: true, + isActive: true, + }, + ]} />`, }, ], diff --git a/src-docs/src/views/list_group/list_group_item_color.tsx b/src-docs/src/views/list_group/list_group_item_color.tsx index 883bd524338..46c58e3d680 100644 --- a/src-docs/src/views/list_group/list_group_item_color.tsx +++ b/src-docs/src/views/list_group/list_group_item_color.tsx @@ -4,20 +4,29 @@ import { EuiListGroupItem, EuiListGroup, } from '../../../../src/components/list_group'; +import { EuiSpacer } from '../../../../src/components/spacer'; export default () => ( - - + <> + + - {}} - label="Primary (s)" - color="primary" - size="s" - /> + {}} + label="Primary (s)" + color="primary" + size="s" + /> - + - - + + + + + + + + + ); diff --git a/src-docs/src/views/list_group/list_group_link_actions.js b/src-docs/src/views/list_group/list_group_link_actions.js index e3e31b1df89..ec6edf64304 100644 --- a/src-docs/src/views/list_group/list_group_link_actions.js +++ b/src-docs/src/views/list_group/list_group_link_actions.js @@ -68,7 +68,7 @@ export default class extends Component { extraAction={{ color: 'subdued', onClick: this.link1Clicked, - iconType: favorite1 === 'link1' ? 'pinFilled' : 'pin', + iconType: favorite1 === 'link1' ? 'starFilled' : 'starEmpty', iconSize: 's', 'aria-label': 'Favorite link1', alwaysShow: favorite1 === 'link1', @@ -83,7 +83,7 @@ export default class extends Component { extraAction={{ color: 'subdued', onClick: this.link2Clicked, - iconType: favorite2 === 'link2' ? 'pinFilled' : 'pin', + iconType: favorite2 === 'link2' ? 'starFilled' : 'starEmpty', iconSize: 's', 'aria-label': 'Favorite link2', alwaysShow: favorite2 === 'link2', @@ -98,7 +98,7 @@ export default class extends Component { extraAction={{ color: 'subdued', onClick: this.link3Clicked, - iconType: favorite3 === 'link3' ? 'pinFilled' : 'pin', + iconType: favorite3 === 'link3' ? 'starFilled' : 'starEmpty', iconSize: 's', 'aria-label': 'Favorite link3', alwaysShow: favorite3 === 'link3', @@ -114,7 +114,7 @@ export default class extends Component { extraAction={{ color: 'subdued', onClick: () => window.alert('Action clicked'), - iconType: 'pin', + iconType: 'starEmpty', iconSize: 's', 'aria-label': 'Favorite link4', }} diff --git a/src-docs/src/views/list_group/pinnable_list_group.tsx b/src-docs/src/views/list_group/pinnable_list_group.tsx new file mode 100644 index 00000000000..4716de39ef8 --- /dev/null +++ b/src-docs/src/views/list_group/pinnable_list_group.tsx @@ -0,0 +1,57 @@ +import React from 'react'; + +import { + EuiPinnableListGroup, + EuiPinnableListGroupItemProps, +} from '../../../../src/components/list_group'; + +const someListItems: EuiPinnableListGroupItemProps[] = [ + { + label: 'Label with iconType', + iconType: 'stop', + }, + { + label: 'Pinned button with onClick', + pinned: true, + onClick: e => { + console.log('Pinned button clicked', e); + }, + }, + { + label: 'Link with href and custom pin titles', + href: '/#', + }, + { + label: 'Active link', + isActive: true, + href: '/#', + }, + { + label: 'Custom extra actions will override pinning ability', + extraAction: { + iconType: 'bell', + alwaysShow: true, + 'aria-label': 'bell', + }, + }, + { + label: 'Item with pinnability turned off', + pinnable: false, + }, +]; + +export default () => ( + <> + { + console.warn('Clicked: ', item); + }} + maxWidth="none" + pinTitle={(item: EuiPinnableListGroupItemProps) => `Pin ${item.label}`} + unpinTitle={(item: EuiPinnableListGroupItemProps) => + `Unpin ${item.label}` + } + /> + +); diff --git a/src-docs/src/views/list_group/props.tsx b/src-docs/src/views/list_group/props.tsx new file mode 100644 index 00000000000..c131a02172c --- /dev/null +++ b/src-docs/src/views/list_group/props.tsx @@ -0,0 +1,7 @@ +import React, { FunctionComponent } from 'react'; + +import { EuiPinnableListGroupItemProps } from '../../../../src/components/list_group'; + +export const EuiPinnableListGroupItem: FunctionComponent< + EuiPinnableListGroupItemProps +> = () =>
; diff --git a/src/components/accordion/__snapshots__/accordion.test.tsx.snap b/src/components/accordion/__snapshots__/accordion.test.tsx.snap index 4f49249582f..d34f5210c31 100644 --- a/src/components/accordion/__snapshots__/accordion.test.tsx.snap +++ b/src/components/accordion/__snapshots__/accordion.test.tsx.snap @@ -35,7 +35,9 @@ exports[`EuiAccordion behavior closes when clicked twice 1`] = ` /> - +
- +
- +
- +
- +
- +
Button content
@@ -306,7 +318,7 @@ exports[`EuiAccordion props buttonContentClassName is rendered 1`] = ` />
@@ -344,7 +356,9 @@ exports[`EuiAccordion props extraAction is rendered 1`] = ` data-euiicon-type="arrowRight" /> - +
- +
{icon} - {buttonContent} + + {buttonContent} + {optionalAction} diff --git a/src/components/collapsible_nav/__snapshots__/collapsible_nav.test.tsx.snap b/src/components/collapsible_nav/__snapshots__/collapsible_nav.test.tsx.snap new file mode 100644 index 00000000000..9c6136cdc25 --- /dev/null +++ b/src/components/collapsible_nav/__snapshots__/collapsible_nav.test.tsx.snap @@ -0,0 +1,122 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiCollapsibleNav is rendered 1`] = `null`; + +exports[`EuiCollapsibleNav props button 1`] = ` + + +
+
+
+`; + +exports[`EuiCollapsibleNav props isOpen 1`] = ` +Array [ +
, +
+
+
+
+ +
+
+
, +] +`; + +exports[`EuiCollapsibleNav props onClose 1`] = `null`; diff --git a/src/components/collapsible_nav/_collapsible_nav.scss b/src/components/collapsible_nav/_collapsible_nav.scss new file mode 100644 index 00000000000..f15d58a6b49 --- /dev/null +++ b/src/components/collapsible_nav/_collapsible_nav.scss @@ -0,0 +1,57 @@ +// Extends euiFlyout +@use '../flyout/flyout'; + +.euiCollapsibleNav { + @extend %eui-flyout; + right: auto; + left: 0; + width: $euiCollapsibleNavWidth; + max-width: 80vw; + + &:not(.euiCollapsibleNav--isDocked) { + animation: euiCollapsibleNavIn $euiAnimSpeedNormal $euiAnimSlightResistance; + } +} + +.euiCollapsibleNav__closeButton { + position: absolute; + right: 0; + top: $euiSize; + margin-right: -25%; +} + +@include euiBreakpoint('l', 'xl') { + // The addition of this class is handled through JS as well + // but adding under the breakpoint mixin is an additional fail-safe + .euiCollapsibleNav.euiCollapsibleNav--isDocked { + @include euiBottomShadowMedium; + + .euiCollapsibleNav__closeButton { + display: none; + } + } + + .euiCollapsibleNav__toggle--navIsDocked { + display: none; + } + + .euiBody--collapsibleNavIsDocked { + // Shrink the content from the left so it's no longer overlapped by the nav drawer (ALWAYS) + padding-left: $euiCollapsibleNavWidth !important; // sass-lint:disable-line no-important + transition: padding $euiAnimSpeedFast $euiAnimSlightResistance; + } +} + +// Specific keyframes so in comes in from the left +@keyframes euiCollapsibleNavIn { + 0% { + opacity: 0; + transform: translateX(-100%); + } + + 75% { + opacity: 1; + transform: translateX(0%); + } +} + diff --git a/src/components/collapsible_nav/_index.scss b/src/components/collapsible_nav/_index.scss new file mode 100644 index 00000000000..a31c44be949 --- /dev/null +++ b/src/components/collapsible_nav/_index.scss @@ -0,0 +1,4 @@ +@import 'variables'; + +@import 'collapsible_nav'; +@import 'collapsible_nav_group/index'; diff --git a/src/components/collapsible_nav/_variables.scss b/src/components/collapsible_nav/_variables.scss new file mode 100644 index 00000000000..f883cd5f8a2 --- /dev/null +++ b/src/components/collapsible_nav/_variables.scss @@ -0,0 +1,14 @@ +// Sizing +$euiCollapsibleNavWidth: $euiSize * 20; // ~ 320px + +$euiCollapsibleNavGroupLightBackgroundColor: $euiPageBackgroundColor; + +$euiCollapsibleNavGroupDarkBackgroundColor: lightOrDarkTheme( + shade($euiColorDarkestShade, 20%), + shade($euiColorLightestShade, 50%), +); + +$euiCollapsibleNavGroupDarkHighContrastColor: makeGraphicContrastColor( + $euiColorPrimary, + $euiCollapsibleNavGroupDarkBackgroundColor +); diff --git a/src/components/collapsible_nav/collapsible_nav.test.tsx b/src/components/collapsible_nav/collapsible_nav.test.tsx new file mode 100644 index 00000000000..e2cd94f2348 --- /dev/null +++ b/src/components/collapsible_nav/collapsible_nav.test.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { render } from 'enzyme'; +import { requiredProps } from '../../test/required_props'; + +import { EuiCollapsibleNav } from './collapsible_nav'; + +jest.mock('../overlay_mask', () => ({ + EuiOverlayMask: (props: any) =>
, +})); + +describe('EuiCollapsibleNav', () => { + test('is rendered', () => { + const component = render(); + + expect(component).toMatchSnapshot(); + }); + + describe('props', () => { + test('onClose', () => { + const component = render( + {}} /> + ); + + expect(component).toMatchSnapshot(); + }); + + test('isDocked', () => { + const component = render(); + + expect(component).toMatchSnapshot(); + }); + + test('isOpen', () => { + const component = render(); + + expect(component).toMatchSnapshot(); + }); + + test('button', () => { + const component = render( + } /> + ); + + expect(component).toMatchSnapshot(); + }); + + test('hideButtonIfDocked', () => { + const component = render( + } + hideButtonIfDocked={false} + /> + ); + + expect(component).toMatchSnapshot(); + }); + }); +}); diff --git a/src/components/collapsible_nav/collapsible_nav.tsx b/src/components/collapsible_nav/collapsible_nav.tsx new file mode 100644 index 00000000000..9b917b5ed31 --- /dev/null +++ b/src/components/collapsible_nav/collapsible_nav.tsx @@ -0,0 +1,148 @@ +import React, { + FunctionComponent, + ReactNode, + useEffect, + useState, + HTMLAttributes, + ReactElement, + cloneElement, +} from 'react'; +import classNames from 'classnames'; +import { throttle } from '../color_picker/utils'; +import { EuiWindowEvent, keyCodes, htmlIdGenerator } from '../../services'; +import { EuiFocusTrap } from '../focus_trap'; +import { EuiOverlayMask } from '../overlay_mask'; +import { CommonProps } from '../common'; +import { EuiButtonEmpty } from '../button'; +import { EuiI18n } from '../i18n'; + +export type EuiCollapsibleNavProps = CommonProps & + HTMLAttributes & { + children?: ReactNode; + /** + * Keeps navigation flyout visible and push `` content via padding + */ + isDocked?: boolean; + /** + * Shows the navigation flyout + */ + isOpen?: boolean; + /** + * Button for controlling visible state of the nav + */ + button?: ReactElement; + /** + * Removes display of toggle button when in docked state + */ + hideButtonIfDocked?: boolean; + onClose?: () => void; + }; + +export const EuiCollapsibleNav: FunctionComponent = ({ + children, + className, + isDocked = false, + isOpen = false, + onClose, + button, + hideButtonIfDocked = true, + id, + ...rest +}) => { + const [flyoutID] = useState(id || htmlIdGenerator()('euiCollapsibleNav')); + const [windowIsLargeEnoughToDock, setWindowIsLargeEnoughToDock] = useState( + window.innerWidth >= 992 + ); + const navIsDocked = isDocked && windowIsLargeEnoughToDock; + + const functionToCallOnWindowResize = throttle(() => { + if (window.innerWidth < 992) { + setWindowIsLargeEnoughToDock(false); + } else { + setWindowIsLargeEnoughToDock(true); + } + // reacts every 50ms to resize changes and always gets the final update + }, 50); + + // Watch for docked status and appropriately add/remove body classes and resize handlers + useEffect(() => { + if (isDocked) { + document.body.classList.add('euiBody--collapsibleNavIsDocked'); + window.addEventListener('resize', functionToCallOnWindowResize); + } + return () => { + document.body.classList.remove('euiBody--collapsibleNavIsDocked'); + window.removeEventListener('resize', functionToCallOnWindowResize); + }; + }, [isDocked, functionToCallOnWindowResize]); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.keyCode === keyCodes.ESCAPE) { + event.preventDefault(); + collapse(); + } + }; + + const collapse = () => { + if (!navIsDocked) { + onClose && onClose(); + } + }; + + const classes = classNames( + 'euiCollapsibleNav', + { 'euiCollapsibleNav--isDocked': navIsDocked }, + className + ); + + let optionalOverlay; + if (!navIsDocked) { + optionalOverlay = ; + } + + // Show a trigger button if one was passed but + // not if hideButtonIfDocked and navIsDocked + const trigger = + button && + !(hideButtonIfDocked && navIsDocked) && + cloneElement(button as ReactElement, { + 'aria-controls': flyoutID, + 'aria-expanded': isOpen, + 'aria-pressed': isOpen, + className: classNames( + button.props.className, + 'euiCollapsibleNav__toggle' + ), + }); + + const flyout = ( + <> + + {optionalOverlay} + {/* Trap focus only when docked={false} */} + + + + + ); + + return ( + <> + {trigger} + {(isOpen || navIsDocked) && flyout} + + ); +}; diff --git a/src/components/collapsible_nav/collapsible_nav_group/__snapshots__/collapsible_nav_group.test.tsx.snap b/src/components/collapsible_nav/collapsible_nav_group/__snapshots__/collapsible_nav_group.test.tsx.snap new file mode 100644 index 00000000000..ac3c4be8d54 --- /dev/null +++ b/src/components/collapsible_nav/collapsible_nav_group/__snapshots__/collapsible_nav_group.test.tsx.snap @@ -0,0 +1,259 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiCollapsibleNavGroup is rendered 1`] = ` +
+
+
+`; + +exports[`EuiCollapsibleNavGroup props background dark is rendered 1`] = ` +
+
+
+`; + +exports[`EuiCollapsibleNavGroup props background light is rendered 1`] = ` +
+
+
+`; + +exports[`EuiCollapsibleNavGroup props background none is rendered 1`] = ` +
+
+
+`; + +exports[`EuiCollapsibleNavGroup props iconSize is rendered 1`] = ` +
+
+
+
+ +
+

+ Title +

+
+
+
+
+
+`; + +exports[`EuiCollapsibleNavGroup props iconType is rendered 1`] = ` +
+
+
+
+ +
+

+ Title +

+
+
+
+
+
+`; + +exports[`EuiCollapsibleNavGroup props title is rendered 1`] = ` +
+
+
+
+

+ Title +

+
+
+
+
+
+`; + +exports[`EuiCollapsibleNavGroup props titleElement can change the rendered element to h2 1`] = ` +
+
+
+
+

+ Title +

+
+
+
+
+
+`; + +exports[`EuiCollapsibleNavGroup props titleSize can be larger 1`] = ` +
+
+
+`; + +exports[`EuiCollapsibleNavGroup throws a warning if iconType is passed without a title 1`] = ` +
+
+
+`; + +exports[`EuiCollapsibleNavGroup when isCollapsible is true will render an accordion 1`] = ` +
+
+ +
+
+
+
+
+
+
+
+
+`; diff --git a/src/components/collapsible_nav/collapsible_nav_group/_collapsible_nav_group.scss b/src/components/collapsible_nav/collapsible_nav_group/_collapsible_nav_group.scss new file mode 100644 index 00000000000..257c5142be5 --- /dev/null +++ b/src/components/collapsible_nav/collapsible_nav_group/_collapsible_nav_group.scss @@ -0,0 +1,59 @@ +.euiCollapsibleNavGroup { + &:not(:first-child) { + border-top: $euiBorderThin; + } + + // This class does not accept a custom classname + .euiAccordion__triggerWrapper { + // Add padding to the trigger wrapper in case an `extraAction` is passed + // that doesn't get wrapped in the `__heading` + padding: $euiSize; + } +} + +.euiCollapsibleNavGroup--light { + background-color: $euiCollapsibleNavGroupLightBackgroundColor; +} + +.euiCollapsibleNavGroup--dark { + background-color: $euiCollapsibleNavGroupDarkBackgroundColor; + color: $euiColorGhost; + + // Forcing better contrast of focus state on EuiAccordion toggle icon + .euiCollapsibleNavGroup__heading:focus .euiAccordion__iconWrapper { + color: $euiCollapsibleNavGroupDarkHighContrastColor; + animation-name: euiCollapsibleNavGroupDarkFocusRingAnimate !important; // sass-lint:disable-line no-important + } + + .euiCollapsibleNavGroup__title { + color: inherit; + line-height: inherit; + } +} + +.euiCollapsibleNavGroup__heading { + font-weight: $euiFontWeightSemiBold; + + // If the heading is not in an accordion, it needs the padding + &:not(.euiAccordion__button) { + padding: $euiSize; + } +} + +.euiCollapsibleNavGroup__children { + padding: $euiSizeS; +} + +.euiCollapsibleNavGroup--withHeading .euiCollapsibleNavGroup__children { + padding-top: 0; +} + +@keyframes euiCollapsibleNavGroupDarkFocusRingAnimate { + 0% { + box-shadow: 0 0 0 $euiFocusRingAnimStartSize $euiFocusRingAnimStartColor; + } + + 100% { + box-shadow: 0 0 0 $euiFocusRingSize $euiCollapsibleNavGroupDarkHighContrastColor; + } +} diff --git a/src/components/collapsible_nav/collapsible_nav_group/_index.scss b/src/components/collapsible_nav/collapsible_nav_group/_index.scss new file mode 100644 index 00000000000..667ebab6881 --- /dev/null +++ b/src/components/collapsible_nav/collapsible_nav_group/_index.scss @@ -0,0 +1 @@ +@import 'collapsible_nav_group'; diff --git a/src/components/collapsible_nav/collapsible_nav_group/collapsible_nav_group.test.tsx b/src/components/collapsible_nav/collapsible_nav_group/collapsible_nav_group.test.tsx new file mode 100644 index 00000000000..3a37ffed510 --- /dev/null +++ b/src/components/collapsible_nav/collapsible_nav_group/collapsible_nav_group.test.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { render } from 'enzyme'; +import { requiredProps } from '../../../test/required_props'; + +import { EuiCollapsibleNavGroup, BACKGROUNDS } from './collapsible_nav_group'; + +describe('EuiCollapsibleNavGroup', () => { + test('is rendered', () => { + const component = render( + + ); + + expect(component).toMatchSnapshot(); + }); + + describe('props', () => { + test('title is rendered', () => { + const component = render( + + ); + + expect(component).toMatchSnapshot(); + }); + + test('iconType is rendered', () => { + const component = render( + + ); + + expect(component).toMatchSnapshot(); + }); + + test('iconSize is rendered', () => { + const component = render( + + ); + + expect(component).toMatchSnapshot(); + }); + + describe('background', () => { + BACKGROUNDS.forEach(color => { + test(`${color} is rendered`, () => { + const component = render( + + ); + + expect(component).toMatchSnapshot(); + }); + }); + }); + + test('titleElement can change the rendered element to h2', () => { + const component = render( + + ); + + expect(component).toMatchSnapshot(); + }); + + test('titleSize can be larger', () => { + const component = render( + + ); + + expect(component).toMatchSnapshot(); + }); + }); + + describe('when isCollapsible is true', () => { + test('will render an accordion', () => { + const component = render( + + ); + + expect(component).toMatchSnapshot(); + }); + }); + + describe('throws a warning', () => { + const oldConsoleError = console.warn; + let consoleStub: jest.Mock; + + beforeEach(() => { + // We don't use jest.spyOn() here, because EUI's tests apply a global + // console.error() override that throws an exception. For these + // tests, we just want to know if console.error() was called. + console.warn = consoleStub = jest.fn(); + }); + + afterEach(() => { + console.warn = oldConsoleError; + }); + + test('if iconType is passed without a title', () => { + const component = render( + + ); + + expect(consoleStub).toBeCalled(); + expect(consoleStub.mock.calls[0][0]).toMatch( + 'not render an icon without `title`' + ); + expect(component).toMatchSnapshot(); + }); + }); +}); diff --git a/src/components/collapsible_nav/collapsible_nav_group/collapsible_nav_group.tsx b/src/components/collapsible_nav/collapsible_nav_group/collapsible_nav_group.tsx new file mode 100644 index 00000000000..f59694d1e73 --- /dev/null +++ b/src/components/collapsible_nav/collapsible_nav_group/collapsible_nav_group.tsx @@ -0,0 +1,172 @@ +import React, { + FunctionComponent, + ReactNode, + useState, + HTMLAttributes, +} from 'react'; +import classNames from 'classnames'; +import { CommonProps, ExclusiveUnion } from '../../common'; +import { htmlIdGenerator } from '../../../services'; + +import { EuiAccordion, EuiAccordionProps } from '../../accordion'; +import { EuiIcon, IconType, IconSize } from '../../icon'; +import { EuiFlexGroup, EuiFlexItem } from '../../flex'; +import { EuiTitle, EuiTitleProps, EuiTitleSize } from '../../title'; + +type Background = 'none' | 'light' | 'dark'; +const backgroundToClassNameMap: { [color in Background]: string } = { + none: '', + light: 'euiCollapsibleNavGroup--light', + dark: 'euiCollapsibleNavGroup--dark', +}; +export const BACKGROUNDS = Object.keys( + backgroundToClassNameMap +) as Background[]; + +export interface EuiCollapsibleNavGroupInterface extends CommonProps { + children?: ReactNode; + /** + * Sits left of the `title` and only when `title` is present + */ + iconType?: IconType; + /** + * Change the size of the icon in the `title` + */ + iconSize?: IconSize; + /** + * Optionally provide an id, otherwise one will be created + */ + id?: string; + /** + * Adds a background color to the entire group, + * applying the correct text color to the `title` only + */ + background?: Background; + /** + * Determines the title's heading element + */ + titleElement?: 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'span'; + /** + * Title sizing equivelant to EuiTitle, but only `s` and smaller + */ + titleSize?: Omit; +} + +type GroupAsAccordion = EuiCollapsibleNavGroupInterface & + // The HTML `title` prop conflicts in type with our `title` prop + Omit & { + /** + * If `true`, wraps children in the body of an accordion, + * requiring the prop `title` to be used as the button. + * When `false`, simply renders a div without any accordion functionality. + */ + isCollapsible: true; + /** + * The title gets wrapped in the appropriate heading level + * with the option to add an iconType + */ + title: ReactNode; + }; + +type GroupAsDiv = EuiCollapsibleNavGroupInterface & { + /** + * If `true`, wraps children in the body of an accordion, + * requiring the prop `title` to be used as the button. + * When `false`, simply renders a div without any accordion functionality. + */ + isCollapsible?: false; + /** + * The title gets wrapped in the appropriate heading level + * with the option to add an iconType + */ + title?: ReactNode; +} & HTMLAttributes; + +export type EuiCollapsibleNavGroupProps = ExclusiveUnion< + GroupAsAccordion, + GroupAsDiv +>; + +export const EuiCollapsibleNavGroup: FunctionComponent< + EuiCollapsibleNavGroupProps +> = ({ + className, + children, + id, + title, + iconType, + iconSize = 'l', + background = 'none', + isCollapsible = false, + titleElement = 'h3', + titleSize = 'xxs', + ...rest +}) => { + const [groupID] = useState(id || htmlIdGenerator()()); + const titleID = `${groupID}__title`; + + const classes = classNames( + 'euiCollapsibleNavGroup', + backgroundToClassNameMap[background], + { + 'euiCollapsibleNavGroup--withHeading': title, + }, + className + ); + + // Warn if consumer passes an iconType without a title + if (iconType && !title) { + console.warn( + 'EuiCollapsibleNavGroup will not render an icon without `title`.' + ); + } + + const content = ( +
{children}
+ ); + + const headingClasses = 'euiCollapsibleNavGroup__heading'; + + const TitleElement = titleElement; + const titleContent = title ? ( + + {iconType && ( + + + )} + + + + + {title} + + + + + ) : ( + undefined + ); + + if (isCollapsible && title) { + return ( + + {content} + + ); + } else { + return ( +
+ {titleContent &&
{titleContent}
} + {content} +
+ ); + } +}; diff --git a/src/components/collapsible_nav/collapsible_nav_group/index.ts b/src/components/collapsible_nav/collapsible_nav_group/index.ts new file mode 100644 index 00000000000..3ded0215793 --- /dev/null +++ b/src/components/collapsible_nav/collapsible_nav_group/index.ts @@ -0,0 +1,4 @@ +export { + EuiCollapsibleNavGroup, + EuiCollapsibleNavGroupProps, +} from './collapsible_nav_group'; diff --git a/src/components/collapsible_nav/index.ts b/src/components/collapsible_nav/index.ts new file mode 100644 index 00000000000..846e72f558f --- /dev/null +++ b/src/components/collapsible_nav/index.ts @@ -0,0 +1,6 @@ +export { + EuiCollapsibleNavGroup, + EuiCollapsibleNavGroupProps, +} from './collapsible_nav_group'; + +export { EuiCollapsibleNav, EuiCollapsibleNavProps } from './collapsible_nav'; diff --git a/src/components/flyout/_flyout.scss b/src/components/flyout/_flyout.scss index def8aec630b..690060c08c9 100644 --- a/src/components/flyout/_flyout.scss +++ b/src/components/flyout/_flyout.scss @@ -1,4 +1,6 @@ -.euiFlyout { +@import '../header/variables'; + +%eui-flyout { border-left: $euiBorderThin; // The mixin augments the above // sass-lint:disable mixins-before-declarations @@ -10,10 +12,20 @@ height: 100%; z-index: $euiZModal; background: $euiColorEmptyShade; - animation: euiFlyout $euiAnimSpeedNormal $euiAnimSlightResistance; display: flex; flex-direction: column; align-items: stretch; + + // When the EuiHeader is fixed, we need to account for it in the position of the flyout + .euiBody--headerIsFixed & { + top: $euiHeaderHeightCompensation; + height: calc(100% - #{$euiHeaderHeightCompensation}); + } +} + +.euiFlyout { + @extend %eui-flyout; + animation: euiFlyout $euiAnimSpeedNormal $euiAnimSlightResistance; } // The actual size of the X button in pixels is a bit fuzzy because of all the diff --git a/src/components/header/_header.scss b/src/components/header/_header.scss index 389fb60f949..839a4858f2d 100644 --- a/src/components/header/_header.scss +++ b/src/components/header/_header.scss @@ -20,5 +20,5 @@ } .euiBody--headerIsFixed { - padding-top: $euiHeaderChildSize + $euiSizeS; // Extra padding to accound for the shadow + padding-top: $euiHeaderHeightCompensation + $euiSizeS; // Extra padding to account for the shadow } diff --git a/src/components/header/_variables.scss b/src/components/header/_variables.scss index 61b4c2a86c7..9da0688ee28 100644 --- a/src/components/header/_variables.scss +++ b/src/components/header/_variables.scss @@ -4,4 +4,8 @@ $euiHeaderBackgroundColor: $euiColorEmptyShade; $euiHeaderBreadcrumbColor: $euiColorDarkestShade; // Layout vars -$euiHeaderChildSize: $euiSizeXXL + $euiSizeS; +$euiHeaderHeight: $euiSizeXXL + $euiSizeS; +$euiHeaderChildSize: $euiHeaderHeight; + +// Use the following variable in other components to afford for the fixed header +$euiHeaderHeightCompensation: $euiHeaderHeight + 1px !default; diff --git a/src/components/horizontal_rule/_horizontal_rule.scss b/src/components/horizontal_rule/_horizontal_rule.scss index 4e903a07051..b90b06bf18b 100644 --- a/src/components/horizontal_rule/_horizontal_rule.scss +++ b/src/components/horizontal_rule/_horizontal_rule.scss @@ -1,9 +1,9 @@ .euiHorizontalRule { border: none; - // Sometimes Chrome "calculates" an element height of e.g. 0.990px, which it - // rounds down, thereby hiding the element. - height: 1.1px; + height: 1px; background-color: $euiBorderColor; + flex-shrink: 0; // Ensure when used in flex group, it retains its size + flex-grow: 0; // Ensure when used in flex group, it retains its size &.euiHorizontalRule--full { width: 100%; diff --git a/src/components/icon/__snapshots__/icon.test.tsx.snap b/src/components/icon/__snapshots__/icon.test.tsx.snap index 884cca1da10..c9d70b0f23b 100644 --- a/src/components/icon/__snapshots__/icon.test.tsx.snap +++ b/src/components/icon/__snapshots__/icon.test.tsx.snap @@ -2697,6 +2697,23 @@ exports[`EuiIcon props type help is rendered 1`] = ` `; +exports[`EuiIcon props type home is rendered 1`] = ` + +`; + exports[`EuiIcon props type iInCircle is rendered 1`] = `
    `; -exports[`EuiListGroup is rendered with listItems 1`] = ` +exports[`EuiListGroup listItems is rendered 1`] = `
    • +
    • + + + Active link + + +
    • @@ -87,21 +103,122 @@ exports[`EuiListGroup is rendered with listItems 1`] = `
    `; +exports[`EuiListGroup listItems is rendered with color 1`] = ` + +`; + +exports[`EuiListGroup listItems is rendered with size 1`] = ` +
      +`; + exports[`EuiListGroup props bordered is rendered 1`] = `
        `; exports[`EuiListGroup props flush is rendered 1`] = `
          `; exports[`EuiListGroup props gutter size m is rendered 1`] = `
            `; @@ -113,38 +230,38 @@ exports[`EuiListGroup props gutter size none is rendered 1`] = ` exports[`EuiListGroup props gutter size s is rendered 1`] = `
              `; exports[`EuiListGroup props maxWidth as a number is rendered 1`] = `
                `; exports[`EuiListGroup props maxWidth as a string is rendered 1`] = `
                  `; exports[`EuiListGroup props maxWidth as true is rendered 1`] = `
                    `; exports[`EuiListGroup props showToolTips is rendered 1`] = `
                      `; exports[`EuiListGroup props wrapText is rendered 1`] = `
                        `; diff --git a/src/components/list_group/__snapshots__/list_group_item.test.tsx.snap b/src/components/list_group/__snapshots__/list_group_item.test.tsx.snap index e110e1cebbe..c43facbadcc 100644 --- a/src/components/list_group/__snapshots__/list_group_item.test.tsx.snap +++ b/src/components/list_group/__snapshots__/list_group_item.test.tsx.snap @@ -17,6 +17,23 @@ exports[`EuiListGroupItem is rendered 1`] = ` `; +exports[`EuiListGroupItem props color ghost is rendered 1`] = ` +
                      • + + + Label + + +
                      • +`; + exports[`EuiListGroupItem props color inherit is rendered 1`] = `
                      • { expect(component).toMatchSnapshot(); }); - test('is rendered with listItems', () => { - const component = render(); + describe('listItems', () => { + test('is rendered', () => { + const component = render(); - expect(component).toMatchSnapshot(); + expect(component).toMatchSnapshot(); + }); + + test('is rendered with color', () => { + const component = render( + + ); + + expect(component).toMatchSnapshot(); + }); + + test('is rendered with size', () => { + const component = render(); + + expect(component).toMatchSnapshot(); + }); }); describe('props', () => { diff --git a/src/components/list_group/list_group.tsx b/src/components/list_group/list_group.tsx index b79a1d388de..22fe2003f4f 100644 --- a/src/components/list_group/list_group.tsx +++ b/src/components/list_group/list_group.tsx @@ -7,8 +7,8 @@ import { CommonProps } from '../common'; type GutterSize = 'none' | 's' | 'm'; const gutterSizeToClassNameMap: { [size in GutterSize]: string } = { none: '', - s: 'euiListGroup--gutterS', - m: 'euiListGroup--gutterM', + s: 'euiListGroup--gutterSmall', + m: 'euiListGroup--gutterMedium', }; export const GUTTER_SIZES = Object.keys( gutterSizeToClassNameMap @@ -36,6 +36,16 @@ export type EuiListGroupProps = CommonProps & */ listItems?: EuiListGroupItemProps[]; + /** + * Change the colors of all `listItems` at once + */ + color?: EuiListGroupItemProps['color']; + + /** + * Change the size of all `listItems` at once + */ + size?: EuiListGroupItemProps['size']; + /** * Sets the max-width of the page, * set to `true` to use the default size, @@ -68,6 +78,8 @@ export const EuiListGroup: FunctionComponent = ({ wrapText = false, maxWidth = true, showToolTips = false, + color, + size, ariaLabelledby, ...rest }) => { @@ -105,6 +117,8 @@ export const EuiListGroup: FunctionComponent = ({ key={`title-${index}`} showToolTip={showToolTips} wrapText={wrapText} + color={color} + size={size} {...item} />, ]; diff --git a/src/components/list_group/list_group_item.tsx b/src/components/list_group/list_group_item.tsx index 0801ae8043e..04457fe55e9 100644 --- a/src/components/list_group/list_group_item.tsx +++ b/src/components/list_group/list_group_item.tsx @@ -25,12 +25,13 @@ const sizeToClassNameMap: { [size in ItemSize]: string } = { }; export const SIZES = Object.keys(sizeToClassNameMap) as ItemSize[]; -type Color = 'inherit' | 'primary' | 'text' | 'subdued'; +type Color = 'inherit' | 'primary' | 'text' | 'subdued' | 'ghost'; const colorToClassNameMap: { [color in Color]: string } = { inherit: '', primary: 'euiListGroupItem--primary', text: 'euiListGroupItem--text', subdued: 'euiListGroupItem--subdued', + ghost: 'euiListGroupItem--ghost', }; export const COLORS = Object.keys(colorToClassNameMap) as Color[]; @@ -38,7 +39,7 @@ export type EuiListGroupItemProps = CommonProps & ExclusiveUnion< ExclusiveUnion< ButtonHTMLAttributes, - AnchorHTMLAttributes + Omit, 'href'> >, HTMLAttributes > & { @@ -48,7 +49,7 @@ export type EuiListGroupItemProps = CommonProps & size?: ItemSize; /** * By default the item will inherit the color of its wrapper (button/link/span), - * otherwise pass on of the acceptable options + * otherwise pass one of the acceptable options */ color?: Color; diff --git a/src/components/list_group/pinnable_list_group/__snapshots__/pinnable_list_group.test.tsx.snap b/src/components/list_group/pinnable_list_group/__snapshots__/pinnable_list_group.test.tsx.snap new file mode 100644 index 00000000000..75048f15230 --- /dev/null +++ b/src/components/list_group/pinnable_list_group/__snapshots__/pinnable_list_group.test.tsx.snap @@ -0,0 +1,321 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiPinnableListGroup can have custom pin icon titles 1`] = ` + +`; + +exports[`EuiPinnableListGroup is rendered 1`] = ` + +`; diff --git a/src/components/list_group/pinnable_list_group/_index.scss b/src/components/list_group/pinnable_list_group/_index.scss new file mode 100644 index 00000000000..0bd00f0595e --- /dev/null +++ b/src/components/list_group/pinnable_list_group/_index.scss @@ -0,0 +1 @@ +@import 'pinnable_list_group'; diff --git a/src/components/list_group/pinnable_list_group/_pinnable_list_group.scss b/src/components/list_group/pinnable_list_group/_pinnable_list_group.scss new file mode 100644 index 00000000000..05f7191ad2a --- /dev/null +++ b/src/components/list_group/pinnable_list_group/_pinnable_list_group.scss @@ -0,0 +1,9 @@ +.euiPinnableListGroup__itemExtraAction { + svg { + transform: rotate(45deg); + } +} + +.euiPinnableListGroup__itemExtraAction-pinned:not(:hover):not(:focus) { + color: makeGraphicContrastColor($euiColorLightShade); +} diff --git a/src/components/list_group/pinnable_list_group/index.ts b/src/components/list_group/pinnable_list_group/index.ts new file mode 100644 index 00000000000..0b9cf97d764 --- /dev/null +++ b/src/components/list_group/pinnable_list_group/index.ts @@ -0,0 +1,5 @@ +export { + EuiPinnableListGroup, + EuiPinnableListGroupProps, + EuiPinnableListGroupItemProps, +} from './pinnable_list_group'; diff --git a/src/components/list_group/pinnable_list_group/pinnable_list_group.test.tsx b/src/components/list_group/pinnable_list_group/pinnable_list_group.test.tsx new file mode 100644 index 00000000000..7957953c655 --- /dev/null +++ b/src/components/list_group/pinnable_list_group/pinnable_list_group.test.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { render } from 'enzyme'; +import { requiredProps } from '../../../test/required_props'; + +import { + EuiPinnableListGroup, + EuiPinnableListGroupItemProps, +} from './pinnable_list_group'; + +const someListItems: EuiPinnableListGroupItemProps[] = [ + { + label: 'Label with iconType', + iconType: 'stop', + }, + { + label: 'Custom extra action', + extraAction: { + iconType: 'bell', + alwaysShow: true, + 'aria-label': 'bell', + }, + }, + { + label: 'Active link', + isActive: true, + href: '#', + }, + { + label: 'Button with onClick', + pinned: true, + onClick: e => { + console.log('Visualize clicked', e); + }, + }, + { + label: 'Link with href', + href: '#', + }, + { + label: 'Not pinnable', + href: '#', + pinnable: false, + }, +]; + +describe('EuiPinnableListGroup', () => { + test('is rendered', () => { + const component = render( + {}} + /> + ); + + expect(component).toMatchSnapshot(); + }); + + test('can have custom pin icon titles', () => { + const component = render( + {}} + pinTitle={(item: EuiPinnableListGroupItemProps) => + `Pin ${item.label} to the top` + } + unpinTitle={(item: EuiPinnableListGroupItemProps) => + `Unpin ${item.label} to the top` + } + /> + ); + + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/components/list_group/pinnable_list_group/pinnable_list_group.tsx b/src/components/list_group/pinnable_list_group/pinnable_list_group.tsx new file mode 100644 index 00000000000..c1b1a551d07 --- /dev/null +++ b/src/components/list_group/pinnable_list_group/pinnable_list_group.tsx @@ -0,0 +1,129 @@ +import React, { FunctionComponent } from 'react'; +import classNames from 'classnames'; +import { CommonProps } from '../../common'; + +import { EuiI18n } from '../../i18n'; +import { EuiListGroup, EuiListGroupProps } from '../list_group'; +import { EuiListGroupItemProps } from '../list_group_item'; + +const pinExtraAction: EuiListGroupItemProps['extraAction'] = { + color: 'primary', + iconType: 'pinFilled', + iconSize: 's', + className: 'euiPinnableListGroup__itemExtraAction', +}; + +const pinnedExtraAction: EuiListGroupItemProps['extraAction'] = { + color: 'primary', + iconType: 'pinFilled', + iconSize: 's', + className: + 'euiPinnableListGroup__itemExtraAction euiPinnableListGroup__itemExtraAction-pinned', + alwaysShow: true, +}; + +export type EuiPinnableListGroupItemProps = EuiListGroupItemProps & { + /** + * Saves the pinned status and changes the visibility of the pin icon + */ + pinned?: boolean; + /** + * Passing `onPinClick` to the full EuiPinnableListGroup, will make every item pinnable. + * Set this property to `false` to turn off individual item pinnability + */ + pinnable?: boolean; +}; + +export interface EuiPinnableListGroupProps + extends CommonProps, + EuiListGroupProps { + /** + * Extends `EuiListGroupItemProps`, at the very least, expecting a `label`. + * See #EuiPinnableListGroupItem + */ + listItems: EuiPinnableListGroupItemProps[]; + /** + * Shows the pin icon and calls this function on click. + * Returns `item: EuiPinnableListGroupItemProps` + */ + onPinClick: (item: EuiPinnableListGroupItemProps) => void; + /** + * The pin icon needs a title/aria-label for accessibility. + * It is a function that passes the item back and must return a string `(item) => string`. + * Default is `"Pin item"` + */ + pinTitle?: (item: EuiPinnableListGroupItemProps) => string; + /** + * The unpin icon needs a title/aria-label for accessibility. + * It is a function that passes the item back and must return a string `(item) => string`. + * Default is `"Unpin item"` + */ + unpinTitle?: (item: EuiPinnableListGroupItemProps) => string; +} + +export const EuiPinnableListGroup: FunctionComponent< + EuiPinnableListGroupProps +> = ({ className, listItems, pinTitle, unpinTitle, onPinClick, ...rest }) => { + const classes = classNames('euiPinnableListGroup', className); + + // Alter listItems object with extra props + const getNewListItems = ( + pinExtraActionLabel: string, + pinnedExtraActionLabel: string + ) => + listItems.map(item => { + const { pinned, pinnable = true, ...itemProps } = item; + // Make some declarations of props for the nav implementation + itemProps.className = classNames( + 'euiPinnableListGroup__item', + item.className + ); + + // Add the pinning action unless the item has it's own extra action + if (onPinClick && !itemProps.extraAction && pinnable) { + // Different displays for pinned vs unpinned + if (pinned) { + itemProps.extraAction = { + ...pinnedExtraAction, + title: unpinTitle ? unpinTitle(item) : pinnedExtraActionLabel, + 'aria-label': unpinTitle + ? unpinTitle(item) + : pinnedExtraActionLabel, + }; + } else { + itemProps.extraAction = { + ...pinExtraAction, + title: pinTitle ? pinTitle(item) : pinExtraActionLabel, + 'aria-label': pinTitle ? pinTitle(item) : pinExtraActionLabel, + }; + } + // Return the item on click + itemProps.extraAction.onClick = () => onPinClick(item); + } + + return itemProps; + }); + + return ( + + {([pinExtraActionLabel, pinnedExtraActionLabel]: string[]) => { + const newListItems = getNewListItems( + pinExtraActionLabel, + pinnedExtraActionLabel + ); + return ( + + ); + }} + + ); +}; diff --git a/src/components/nav_drawer/__snapshots__/nav_drawer.test.js.snap b/src/components/nav_drawer/__snapshots__/nav_drawer.test.js.snap index b8b6c6384b4..4c0ce67796d 100644 --- a/src/components/nav_drawer/__snapshots__/nav_drawer.test.js.snap +++ b/src/components/nav_drawer/__snapshots__/nav_drawer.test.js.snap @@ -15,7 +15,7 @@ exports[`EuiNavDrawer is rendered 1`] = ` id="navDrawerMenu" >