diff --git a/CHANGELOG.md b/CHANGELOG.md index a9aab2db999..5a12bf1d427 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - Added `showOnFocus` prop to `EuiScreenReaderOnly` to force display on keyboard focus ([#2976](https://github.com/elastic/eui/pull/2976)) - Added `EuiSkipLink` component ([#2976](https://github.com/elastic/eui/pull/2976)) - Created `EuiBadgeGroup` component ([#2921](https://github.com/elastic/eui/pull/2921)) +- Added `sections` and `position` props to `EuiHeader` ([#2928](https://github.com/elastic/eui/pull/2928)) **Bug Fixes** diff --git a/src-docs/src/views/header/header_example.js b/src-docs/src/views/header/header_example.js index 32e8ad7eca8..72b503e9988 100644 --- a/src-docs/src/views/header/header_example.js +++ b/src-docs/src/views/header/header_example.js @@ -1,4 +1,5 @@ import React from 'react'; +import { Link } from 'react-router'; import { renderToHtml } from '../../services'; @@ -17,10 +18,20 @@ import { EuiHeaderLink, } from '../../../../src/components'; +import { EuiHeaderSectionsProp } from './props'; + import Header from './header'; const headerSource = require('!!raw-loader!./header'); const headerHtml = renderToHtml(Header); +import HeaderSections from './header_sections'; +const headerSectionsSource = require('!!raw-loader!./header_sections'); +const headerSectionsHtml = renderToHtml(HeaderSections); + +import HeaderPosition from './header_position'; +const headerPositionSource = require('!!raw-loader!./header_position'); +const headerPositionHtml = renderToHtml(HeaderPosition); + import HeaderAlert from './header_alert'; const headerAlertSource = require('!!raw-loader!./header_alert'); const headerAlertHtml = renderToHtml(HeaderAlert); @@ -45,10 +56,27 @@ const headerSnippet = ` `; +const headerSectionsSnippet = ``; + const headerLinksSnippet = ` @@ -78,7 +106,11 @@ export const HeaderExample = { code: headerHtml, }, ], - text:

The header is made up of several individual components.

, + text: ( +

+ The header is made up of many individual components. +

+ ), props: { EuiHeader, EuiHeaderBreadcrumbs, @@ -86,12 +118,80 @@ export const HeaderExample = { EuiHeaderSectionItem, EuiHeaderSectionItemButton, EuiHeaderLogo, + EuiHeaderSectionsProp, }, snippet: headerSnippet, demo:
, }, { - title: 'Links', + title: 'Sections', + source: [ + { + type: GuideSectionTypes.JS, + code: headerSectionsSource, + }, + { + type: GuideSectionTypes.HTML, + code: headerSectionsHtml, + }, + ], + text: ( + <> +

+ Alternatively, you can pass an array objects to the{' '} + sections props that takes a key of{' '} + items (array of children to wrap in an{' '} + EuiHeaderSectionItem) and/or{' '} + breadcrumbs (array of{' '} + breadcrumb objects). Each + item in the array will be wrapped in an{' '} + EuiHeaderSection. +

+

+ Note: Passing sections and{' '} + children will disregard the{' '} + children as it is not easily interpreted at what + location the children should be placed. +

+ + ), + props: { + EuiHeader, + EuiHeaderSectionsProp, + EuiHeaderSection, + EuiHeaderSectionItem, + }, + snippet: headerSectionsSnippet, + demo: , + }, + { + title: 'Fixed header', + source: [ + { + type: GuideSectionTypes.JS, + code: headerPositionSource, + }, + { + type: GuideSectionTypes.HTML, + code: headerPositionHtml, + }, + ], + text: ( + <> +

+ Most consumer need a header that does not scroll way with the page + contents. You can apply this display by changing{' '} + position to fixed. It will + also add the appropriate padding to the window body by applying a + class. +

+ + ), + snippet: '', + demo: , + }, + { + title: 'Header links', source: [ { type: GuideSectionTypes.JS, @@ -117,7 +217,7 @@ export const HeaderExample = { demo: , }, { - title: 'Display header alerts', + title: 'Alerts in the header', source: [ { type: GuideSectionTypes.JS, diff --git a/src-docs/src/views/header/header_position.js b/src-docs/src/views/header/header_position.js new file mode 100644 index 00000000000..9e9d1de9fe9 --- /dev/null +++ b/src-docs/src/views/header/header_position.js @@ -0,0 +1,38 @@ +import React, { useState } from 'react'; + +import { + EuiHeader, + EuiHeaderLogo, + EuiSwitch, +} from '../../../../src/components'; + +export default () => { + const [position, setPosition] = useState('static'); + + const sections = [ + { + items: [ + , + ], + borders: 'none', + }, + { + items: [ +
+ setPosition(e.target.checked ? 'fixed' : 'static')} + /> +
, + ], + borders: 'none', + }, + ]; + + return ; +}; diff --git a/src-docs/src/views/header/header_sections.js b/src-docs/src/views/header/header_sections.js new file mode 100644 index 00000000000..dbe68843a00 --- /dev/null +++ b/src-docs/src/views/header/header_sections.js @@ -0,0 +1,82 @@ +import React from 'react'; + +import { + EuiHeader, + EuiFieldSearch, + EuiHeaderLogo, +} from '../../../../src/components'; + +import HeaderAppMenu from './header_app_menu'; +import HeaderUserMenu from './header_user_menu'; +import HeaderSpacesMenu from './header_spaces_menu'; + +export default () => { + const renderLogo = ( + + ); + + const breadcrumbs = [ + { + text: 'Management', + href: '#', + onClick: e => { + e.preventDefault(); + console.log('You clicked management'); + }, + 'data-test-subj': 'breadcrumbsAnimals', + className: 'customClass', + }, + { + text: 'Truncation test is here for a really long item', + href: '#', + onClick: e => { + e.preventDefault(); + console.log('You clicked truncation test'); + }, + }, + { + text: 'hidden', + href: '#', + onClick: e => { + e.preventDefault(); + console.log('You clicked hidden'); + }, + }, + { + text: 'Users', + href: '#', + onClick: e => { + e.preventDefault(); + console.log('You clicked users'); + }, + }, + { + text: 'Create', + }, + ]; + + const renderSearch = ( + + ); + + const sections = [ + { + items: [renderLogo, ], + borders: 'right', + breadcrumbs: breadcrumbs, + }, + { + items: [renderSearch,
], + borders: 'none', + }, + { + items: [, ], + }, + ]; + + return ; +}; diff --git a/src-docs/src/views/header/props.tsx b/src-docs/src/views/header/props.tsx new file mode 100644 index 00000000000..4259e7d9842 --- /dev/null +++ b/src-docs/src/views/header/props.tsx @@ -0,0 +1,7 @@ +import React, { FunctionComponent } from 'react'; + +import { EuiHeaderSections } from '../../../../src/components/header'; + +export const EuiHeaderSectionsProp: FunctionComponent< + EuiHeaderSections +> = () =>
; diff --git a/src/components/header/__snapshots__/header.test.tsx.snap b/src/components/header/__snapshots__/header.test.tsx.snap index 2ac2a381dee..3654b8e49a9 100644 --- a/src/components/header/__snapshots__/header.test.tsx.snap +++ b/src/components/header/__snapshots__/header.test.tsx.snap @@ -3,17 +3,101 @@ exports[`EuiHeader is rendered 1`] = `
`; exports[`EuiHeader renders children 1`] = `
Hello!
`; + +exports[`EuiHeader renders in fixed position 1`] = ` +
+ + Hello! + +
+`; + +exports[`EuiHeader sections render breadcrumbs and props 1`] = ` +
+ +
+`; + +exports[`EuiHeader sections render simple items and borders 1`] = ` +
+
+
+ Item 1 +
+
+ Item 2 +
+
+
+
+ Item A +
+
+ Item B +
+
+
+`; + +exports[`EuiHeader throws a warning if both children and sections were passed 1`] = ` +
+
+
+ Item 1 +
+
+ Item 2 +
+
+
+`; diff --git a/src/components/header/_header.scss b/src/components/header/_header.scss index ca9c05b1145..389fb60f949 100644 --- a/src/components/header/_header.scss +++ b/src/components/header/_header.scss @@ -6,6 +6,19 @@ position: relative; z-index: $euiZHeader; // ensure the shadow shows above content display: flex; + justify-content: space-between; background: $euiHeaderBackgroundColor; border-bottom: $euiBorderThin; + + &--fixed { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: $euiZLevel7; + } +} + +.euiBody--headerIsFixed { + padding-top: $euiHeaderChildSize + $euiSizeS; // Extra padding to accound for the shadow } diff --git a/src/components/header/_header_logo.scss b/src/components/header/_header_logo.scss index 1ac01250f81..b8687ef6639 100644 --- a/src/components/header/_header_logo.scss +++ b/src/components/header/_header_logo.scss @@ -12,6 +12,10 @@ vertical-align: middle; white-space: nowrap; + &:hover { + background: $euiColorLightestShade; + } + &:focus, &:hover { text-decoration: none; diff --git a/src/components/header/header.test.tsx b/src/components/header/header.test.tsx index 5a36ac03da4..8be9d11b706 100644 --- a/src/components/header/header.test.tsx +++ b/src/components/header/header.test.tsx @@ -20,4 +20,84 @@ describe('EuiHeader', () => { expect(component).toMatchSnapshot(); }); + + test('renders in fixed position', () => { + const component = render( + + Hello! + + ); + + expect(component).toMatchSnapshot(); + }); + + describe('sections', () => { + test('render simple items and borders', () => { + const component = render( + + ); + + expect(component).toMatchSnapshot(); + }); + + test('render breadcrumbs and props', () => { + 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 both children and sections were passed', () => { + const component = render( + + Child + + ); + + expect(consoleStub).toBeCalled(); + expect(consoleStub.mock.calls[0][0]).toMatch( + 'cannot accept both `children` and `sections`' + ); + expect(component).toMatchSnapshot(); + }); + }); }); diff --git a/src/components/header/header.tsx b/src/components/header/header.tsx index 1c59a263e1d..2da6d3eeb3c 100644 --- a/src/components/header/header.tsx +++ b/src/components/header/header.tsx @@ -1,20 +1,124 @@ -import React, { FunctionComponent, HTMLAttributes } from 'react'; +import React, { FunctionComponent, HTMLAttributes, useEffect } from 'react'; import classNames from 'classnames'; - import { CommonProps } from '../common'; -export type EuiHeaderProps = CommonProps & HTMLAttributes; +import { + EuiHeaderSectionItem, + EuiHeaderSectionItemProps, + EuiHeaderSection, +} from './header_section'; +import { EuiHeaderBreadcrumbs } from './header_breadcrumbs'; +import { Breadcrumb, EuiBreadcrumbsProps } from '../breadcrumbs'; + +type EuiHeaderSectionItemType = EuiHeaderSectionItemProps['children']; +type EuiHeaderSectionBorderType = EuiHeaderSectionItemProps['border']; + +export interface EuiHeaderSections { + /** + * An arry of items that will be wrapped in a #EuiHeaderSectionItem + */ + items?: EuiHeaderSectionItemType[]; + /** + * Apply the passed border side to each #EuiHeaderSectionItem + */ + borders?: EuiHeaderSectionBorderType; + /** + * Breadcrumbs in the header cannot be wrapped in a #EuiHeaderSection in order for truncation to work. + * Simply pass the array of Breadcrumb objects + */ + breadcrumbs?: Breadcrumb[]; + /** + * Other props to pass to #EuiHeaderBreadcrumbs + */ + breadcrumbProps?: Omit; +} + +function createHeaderSection( + sections: EuiHeaderSectionItemType[], + border?: EuiHeaderSectionBorderType +) { + return sections.map((section, index) => { + return ( + + {section} + + ); + }); +} + +export type EuiHeaderProps = CommonProps & + HTMLAttributes & { + /** + * An array of objects to wrap in a #EuiHeaderSection. + * Each section is spaced using `space-between`. + * See #EuiHeaderSectionsProp for object details. + * This prop disregards the prop `children` if both are passed. + */ + sections?: EuiHeaderSections[]; + /** + * Helper that positions the header against the window body and + * adds the correct amount of top padding to the window when in `fixed` mode + */ + position?: 'static' | 'fixed'; + }; export const EuiHeader: FunctionComponent = ({ children, className, + sections, + position = 'static', ...rest }) => { - const classes = classNames('euiHeader', className); + const classes = classNames('euiHeader', `euiHeader--${position}`, className); + + useEffect(() => { + if (position === 'fixed') { + document.body.classList.add('euiBody--headerIsFixed'); + } + return () => { + document.body.classList.remove('euiBody--headerIsFixed'); + }; + }, [position]); + + let contents; + if (sections) { + if (children) { + // In case both children and sections are passed, warn in the console that the children will be disregarded + console.warn( + 'EuiHeader cannot accept both `children` and `sections`. It will disregard the `children`.' + ); + } + + contents = sections.map((section, index) => { + const content = []; + if (section.items) { + // Items get wrapped in EuiHeaderSection and each item in a EuiHeaderSectionItem + content.push( + + {createHeaderSection(section.items, section.borders)} + + ); + } + if (section.breadcrumbs) { + content.push( + // Breadcrumbs are separate and cannot be contained in a EuiHeaderSection + // in order for truncation to work + + ); + } + return content; + }); + } else { + contents = children; + } return (
- {children} + {contents}
); }; diff --git a/src/components/header/header_section/_header_section_item.scss b/src/components/header/header_section/_header_section_item.scss index 098b6a98667..ac58b1774c1 100644 --- a/src/components/header/header_section/_header_section_item.scss +++ b/src/components/header/header_section/_header_section_item.scss @@ -2,10 +2,8 @@ .euiHeaderSectionItem { position: relative; - - &:hover { - background: $euiColorLightestShade; - } + display: flex; + align-items: center; &:after { position: absolute; @@ -23,6 +21,10 @@ text-align: center; font-size: 0; // aligns icons better vertically + &:hover { + background: $euiColorLightestShade; + } + &:focus { background: $euiFocusBackgroundColor; } diff --git a/src/components/header/header_section/header_section_item.tsx b/src/components/header/header_section/header_section_item.tsx index 4bcdff3937d..2989c03c321 100644 --- a/src/components/header/header_section/header_section_item.tsx +++ b/src/components/header/header_section/header_section_item.tsx @@ -1,4 +1,4 @@ -import React, { FunctionComponent } from 'react'; +import React, { FunctionComponent, ReactNode } from 'react'; import classNames from 'classnames'; import { CommonProps } from '../../common'; @@ -11,16 +11,14 @@ const borderToClassNameMap: { [border in Border]: string | undefined } = { none: undefined, }; -type Props = CommonProps & { +export type EuiHeaderSectionItemProps = CommonProps & { border?: Border; + children?: ReactNode; }; -export const EuiHeaderSectionItem: FunctionComponent = ({ - border = 'left', - children, - className, - ...rest -}) => { +export const EuiHeaderSectionItem: FunctionComponent< + EuiHeaderSectionItemProps +> = ({ border = 'left', children, className, ...rest }) => { const classes = classNames( 'euiHeaderSectionItem', borderToClassNameMap[border], diff --git a/src/components/header/header_section/index.ts b/src/components/header/header_section/index.ts index 472a50057a3..2d032f7b088 100644 --- a/src/components/header/header_section/index.ts +++ b/src/components/header/header_section/index.ts @@ -1,5 +1,8 @@ export { EuiHeaderSection } from './header_section'; -export { EuiHeaderSectionItem } from './header_section_item'; +export { + EuiHeaderSectionItem, + EuiHeaderSectionItemProps, +} from './header_section_item'; export { EuiHeaderSectionItemButton } from './header_section_item_button'; diff --git a/src/components/header/index.ts b/src/components/header/index.ts index 731612722e6..029216ab1b4 100644 --- a/src/components/header/index.ts +++ b/src/components/header/index.ts @@ -1,4 +1,4 @@ -export { EuiHeader, EuiHeaderProps } from './header'; +export { EuiHeader, EuiHeaderProps, EuiHeaderSections } from './header'; export { EuiHeaderAlert, EuiHeaderAlertProps } from './header_alert'; 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 cc4c0775932..623e6ad1ce6 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 @@ -294,7 +294,7 @@ exports[`EuiListGroupItem renders a disabled button even if provided an href 2`] `; -exports[`EuiListGroupItem throws an warning if both iconType and icon are provided but still renders 1`] = ` +exports[`EuiListGroupItem throws a warning if both iconType and icon are provided but still renders 1`] = `
  • diff --git a/src/components/list_group/list_group_item.test.tsx b/src/components/list_group/list_group_item.test.tsx index ef4e9264c32..6582f7303b2 100644 --- a/src/components/list_group/list_group_item.test.tsx +++ b/src/components/list_group/list_group_item.test.tsx @@ -140,7 +140,7 @@ describe('EuiListGroupItem', () => { expect(component).toMatchSnapshot(); }); - describe('throws an warning', () => { + describe('throws a warning', () => { const oldConsoleError = console.warn; let consoleStub: jest.Mock;