diff --git a/i18ntokens.json b/i18ntokens.json index e66a1fb9d9..45bd99322d 100644 --- a/i18ntokens.json +++ b/i18ntokens.json @@ -233,6 +233,24 @@ }, "filepath": "src/components/bottom_bar/bottom_bar.tsx" }, + { + "token": "ouiBreadcrumbsSimplified.collapsedBadge.ariaLabel", + "defString": "Show collapsed breadcrumbs", + "highlighting": "string", + "loc": { + "start": { + "line": 79, + "column": 6, + "index": 2234 + }, + "end": { + "line": 81, + "column": 45, + "index": 2354 + } + }, + "filepath": "src/components/breadcrumbs/breadcrumbs_simplified.tsx" + }, { "token": "ouiBreadcrumbs.collapsedBadge.ariaLabel", "defString": "Show collapsed breadcrumbs", @@ -2237,14 +2255,14 @@ "highlighting": "string", "loc": { "start": { - "line": 439, + "line": 437, "column": 14, - "index": 11503 + "index": 11485 }, "end": { - "line": 442, + "line": 440, "column": 16, - "index": 11629 + "index": 11611 } }, "filepath": "src/components/date_picker/super_date_picker/super_date_picker.tsx" diff --git a/src/components/breadcrumbs/__snapshots__/breadcrumbs_simplified.test.tsx.snap b/src/components/breadcrumbs/__snapshots__/breadcrumbs_simplified.test.tsx.snap new file mode 100644 index 0000000000..11bda4ba70 --- /dev/null +++ b/src/components/breadcrumbs/__snapshots__/breadcrumbs_simplified.test.tsx.snap @@ -0,0 +1,758 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`OuiSimplifiedBreadcrumbs is rendered 1`] = ` + +`; + +exports[`OuiSimplifiedBreadcrumbs props max doesn't break when max exceeds the number of breadcrumbs 1`] = ` + +`; + +exports[`OuiSimplifiedBreadcrumbs props max renders 1 item 1`] = ` + +`; + +exports[`OuiSimplifiedBreadcrumbs props max renders all items with null 1`] = ` + +`; + +exports[`OuiSimplifiedBreadcrumbs props responsive is rendered 1`] = ` + +`; + +exports[`OuiSimplifiedBreadcrumbs props responsive is rendered as false 1`] = ` + +`; + +exports[`OuiSimplifiedBreadcrumbs props responsive is rendered with custom breakpoints 1`] = ` + +`; + +exports[`OuiSimplifiedBreadcrumbs props truncate as false is rendered 1`] = ` + +`; diff --git a/src/components/breadcrumbs/_breadcrumbs_simplified.scss b/src/components/breadcrumbs/_breadcrumbs_simplified.scss new file mode 100644 index 0000000000..4f6a40a9cf --- /dev/null +++ b/src/components/breadcrumbs/_breadcrumbs_simplified.scss @@ -0,0 +1,85 @@ +/*! + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +.ouiSimplifiedBreadcrumbs { + @include ouiFontSizeS; + margin-bottom: -$ouiSizeXS; /* 1 */ + display: flex; + align-items: center; + flex-wrap: wrap; + min-width: 0; // Ensure it shrinks if the window is narrow + margin-left: 0; +} + +.ouiSimplifiedBreadcrumb { + display: inline-block; + color: $ouiLinkColor !important; // sass-lint:disable-line no-important + margin-right: $ouiBreadcrumbSpacing; +} + +.ouiSimplifiedBreadcrumb--collapsed { + flex-shrink: 0; + color: $ouiBreadcrumbCollapsedLink !important; // sass-lint:disable-line no-important + vertical-align: top !important; // sass-lint:disable-line no-important +} + +.ouiSimplifiedBreadcrumb__collapsedLink:hover { + color: $ouiBreadCrumbHoverColor !important; // sass-lint:disable-line no-important +} + +.ouiSimplifiedBreadcrumbs--truncate { + white-space: nowrap; + flex-wrap: nowrap; + + .ouiSimplifiedBreadcrumb:not(.ouiSimplifiedBreadcrumb--collapsed) { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: top; // overflow hidden causes misalignment of links and slashes, this fixes that + } + + .ouiSimplifiedBreadcrumbWrapper:not(.ouiSimplifiedBreadcrumbWrapper--collapsed) { + max-width: $ouiBreadcrumbTruncateWidth; + overflow: hidden; + text-overflow: ellipsis; + + &.ouiSimplifiedBreadcrumbWrapper--last { + max-width: none; + } + } +} + +.ouiSimplifiedBreadcrumb--truncate { + @include ouiTextTruncate; + max-width: 100%; + text-align: center; + vertical-align: top; // overflow hidden causes misalignment of links and slashes, this fixes that +} + +.ouiSimplifiedBreadcrumbWrapper--truncate { + max-width: $ouiBreadcrumbTruncateWidth; +} + +.ouiBreadcrumbSeparator { + flex-shrink: 0; + display: inline-block; + margin-right: $ouiBreadcrumbSpacing; + width: 2px; + height: $ouiSize; + transform: translateY(-1px) rotate(15deg); + background: $ouiColorLightShade; + color: $ouiColorDisabledText; +} + +.euiLink.euiSimplifiedBreadcrumb { + line-height: inherit; + font-weight: inherit; +} \ No newline at end of file diff --git a/src/components/breadcrumbs/_index.scss b/src/components/breadcrumbs/_index.scss index e41bcc3485..b5b018e0de 100644 --- a/src/components/breadcrumbs/_index.scss +++ b/src/components/breadcrumbs/_index.scss @@ -11,3 +11,4 @@ @import 'variables'; @import 'breadcrumbs'; +@import 'breadcrumbs_simplified'; diff --git a/src/components/breadcrumbs/breadcrumbs.tsx b/src/components/breadcrumbs/breadcrumbs.tsx index 015b91a533..63fc7acf17 100644 --- a/src/components/breadcrumbs/breadcrumbs.tsx +++ b/src/components/breadcrumbs/breadcrumbs.tsx @@ -318,3 +318,134 @@ export const OuiBreadcrumbs: FunctionComponent = ({ ); }; + +export const OuiBreadcrumbsSimplified: FunctionComponent = ({ + breadcrumbs, + className, + responsive = responsiveDefault, + truncate = true, + max = 5, + ...rest +}) => { + const [currentBreakpoint, setCurrentBreakpoint] = useState( + getBreakpoint(typeof window === 'undefined' ? -Infinity : window.innerWidth) + ); + + const functionToCallOnWindowResize = throttle(() => { + const newBreakpoint = getBreakpoint(window.innerWidth); + if (newBreakpoint !== currentBreakpoint) { + setCurrentBreakpoint(newBreakpoint); + } + // reacts every 50ms to resize changes and always gets the final update + }, 50); + + // Add window resize handlers + useEffect(() => { + window.addEventListener('resize', functionToCallOnWindowResize); + + return () => { + window.removeEventListener('resize', functionToCallOnWindowResize); + }; + }, [responsive, functionToCallOnWindowResize]); + + const breadcrumbElements = breadcrumbs.map((breadcrumb, index) => { + const { + text, + href, + onClick, + truncate, + className: breadcrumbClassName, + ...breadcrumbRest + } = breadcrumb; + + const isFirstBreadcrumb = index === 0; + const isLastBreadcrumb = index === breadcrumbs.length - 1; + + const breadcrumbWrapperClasses = classNames('ouiBreadcrumbWrapper', { + 'ouiBreadcrumbWrapper--first': isFirstBreadcrumb, + 'ouiBreadcrumbWrapper--last': isLastBreadcrumb, + 'ouiBreadcrumbWrapper--truncate': truncate, + }); + + const breadcrumbClasses = classNames('ouiBreadcrumb', breadcrumbClassName, { + 'ouiBreadcrumb--last': isLastBreadcrumb, + 'ouiBreadcrumb--truncate': truncate, + }); + + const link = + !href && !onClick ? ( + + {(ref, innerText) => ( + + {text} + + )} + + ) : ( + + {(ref, innerText) => ( + + {text} + + )} + + ); + + const breadcrumbWallClasses = classNames('ouiBreadcrumbWall', { + 'ouiBreadcrumbWall--single': isFirstBreadcrumb && isLastBreadcrumb, + }); + + const wrapper =
{link}
; + const wall = isFirstBreadcrumb ? ( +
{wrapper}
+ ) : ( + wrapper + ); + + return {wall}; + }); + + // Use the default object if they simply passed `true` for responsive + const responsiveObject = + typeof responsive === 'object' ? responsive : responsiveDefault; + + // The max property collapses any breadcrumbs past the max quantity. + // This is the same behavior we want for responsiveness. + // So calculate the max value based on the combination of `max` and `responsive` + + // First, calculate the responsive max value + const responsiveMax = + responsive && responsiveObject[currentBreakpoint as OuiBreakpointSize] + ? responsiveObject[currentBreakpoint as OuiBreakpointSize] + : null; + + // Second, if both max and responsiveMax are set, use the smaller of the two. Otherwise, use the one that is set. + const calculatedMax: OuiBreadcrumbsProps['max'] = + max && responsiveMax ? Math.min(max, responsiveMax) : max || responsiveMax; + + const limitedBreadcrumbs = calculatedMax + ? limitBreadcrumbs(breadcrumbElements, calculatedMax, breadcrumbs) + : breadcrumbElements; + + const classes = classNames('ouiBreadcrumbs', className, { + 'ouiBreadcrumbs--truncate': truncate, + }); + + return ( + + ); +}; diff --git a/src/components/breadcrumbs/breadcrumbs_simplified.test.tsx b/src/components/breadcrumbs/breadcrumbs_simplified.test.tsx new file mode 100644 index 0000000000..306f47db8b --- /dev/null +++ b/src/components/breadcrumbs/breadcrumbs_simplified.test.tsx @@ -0,0 +1,133 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import React from 'react'; +import { render } from 'enzyme'; +import { requiredProps } from '../../test'; +import { OuiSimplifiedBreadcrumbs } from './breadcrumbs_simplified'; + +const breadcrumbs = [ + { + text: 'Animals', + href: '#', + onClick: (e: React.MouseEvent) => { + e.preventDefault(); + console.log('You clicked Animals'); + }, + 'data-test-subj': 'breadcrumbsAnimals', + className: 'customClass', + }, + { + text: 'Metazoans', + }, + { + text: 'Chordates', + }, + { + text: + 'Nebulosa subspecies is also a real mouthful, especially for creatures without mouths', + truncate: true, + }, + { + text: 'Tetrapods', + }, + { + text: 'Reptiles', + onClick: (e: React.MouseEvent) => { + e.preventDefault(); + console.log('You clicked Reptiles'); + }, + }, + { + text: 'Boa constrictor', + href: '#', + truncate: true, + }, + { + text: 'Edit', + }, +]; + +describe('OuiSimplifiedBreadcrumbs', () => { + test('is rendered', () => { + const component = render( + + ); + + expect(component).toMatchSnapshot(); + }); + + describe('props', () => { + describe('responsive', () => { + test('is rendered', () => { + const component = render( + + ); + expect(component).toMatchSnapshot(); + }); + + test('is rendered as false', () => { + const component = render( + + ); + expect(component).toMatchSnapshot(); + }); + + test('is rendered with custom breakpoints', () => { + const component = render( + + ); + expect(component).toMatchSnapshot(); + }); + }); + + describe('truncate as false', () => { + test('is rendered', () => { + const component = render( + + ); + expect(component).toMatchSnapshot(); + }); + }); + + describe('max', () => { + test('renders 1 item', () => { + const component = render( + + ); + expect(component).toMatchSnapshot(); + }); + + test('renders all items with null', () => { + const component = render( + + ); + expect(component).toMatchSnapshot(); + }); + + test("doesn't break when max exceeds the number of breadcrumbs", () => { + const component = render( + + ); + expect(component).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/src/components/breadcrumbs/breadcrumbs_simplified.tsx b/src/components/breadcrumbs/breadcrumbs_simplified.tsx new file mode 100644 index 0000000000..bacdf8267e --- /dev/null +++ b/src/components/breadcrumbs/breadcrumbs_simplified.tsx @@ -0,0 +1,270 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import React, { + Fragment, + FunctionComponent, + ReactNode, + useEffect, + useState, +} from 'react'; +import classNames from 'classnames'; + +import { OuiI18n } from '../i18n'; +import { OuiInnerText } from '../inner_text'; +import { OuiLink } from '../link'; +import { OuiPopover } from '../popover'; +import { OuiIcon } from '../icon'; +import { throttle } from '../../services'; +import { OuiBreakpointSize, getBreakpoint } from '../../services/breakpoint'; +import { + OuiBreadcrumbResponsiveMaxCount, + OuiBreadcrumb, + OuiBreadcrumbsProps, +} from './breadcrumbs'; + +const responsiveDefault: OuiBreadcrumbResponsiveMaxCount = { + xs: 1, + s: 2, + m: 4, +}; + +const limitBreadcrumbs = ( + breadcrumbs: ReactNode[], + max: number, + allBreadcrumbs: OuiBreadcrumb[] +) => { + const breadcrumbsAtStart = []; + const breadcrumbsAtEnd = []; + const limit = Math.min(max, breadcrumbs.length); + const start = Math.floor(limit / 2); + const overflowBreadcrumbs = allBreadcrumbs.slice( + start, + start + breadcrumbs.length - limit + ); + + for (let i = 0; i < limit; i++) { + // We'll alternate with displaying breadcrumbs at the end and at the start, but be biased + // towards breadcrumbs the end so that if max is an odd number, we'll have one more + // breadcrumb visible at the end than at the beginning. + const isEven = i % 2 === 0; + + // We're picking breadcrumbs from the front AND the back, so we treat each iteration as a + // half-iteration. + const normalizedIndex = Math.floor(i * 0.5); + const indexOfBreadcrumb = isEven + ? breadcrumbs.length - 1 - normalizedIndex + : normalizedIndex; + const breadcrumb = breadcrumbs[indexOfBreadcrumb]; + + if (isEven) { + breadcrumbsAtEnd.unshift(breadcrumb); + } else { + breadcrumbsAtStart.push(breadcrumb); + } + } + + const OuiBreadcrumbCollapsed = () => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const ellipsisButton = ( + + {(ariaLabel: string) => ( + setIsPopoverOpen(!isPopoverOpen)}> + … + + )} + + ); + + return ( + +
+ setIsPopoverOpen(false)}> + + +
+ +
+ ); + }; + + if (max < breadcrumbs.length) { + breadcrumbsAtStart.push(); + } + + return [...breadcrumbsAtStart, ...breadcrumbsAtEnd]; +}; + +const OuiBreadcrumbSeparator = () =>
; + +export const OuiSimplifiedBreadcrumbs: FunctionComponent = ({ + breadcrumbs, + className, + responsive = responsiveDefault, + truncate = true, + max = 5, + ...rest +}) => { + const [currentBreakpoint, setCurrentBreakpoint] = useState( + getBreakpoint(typeof window === 'undefined' ? -Infinity : window.innerWidth) + ); + + const functionToCallOnWindowResize = throttle(() => { + const newBreakpoint = getBreakpoint(window.innerWidth); + if (newBreakpoint !== currentBreakpoint) { + setCurrentBreakpoint(newBreakpoint); + } + // reacts every 50ms to resize changes and always gets the final update + }, 50); + + // Add window resize handlers + useEffect(() => { + window.addEventListener('resize', functionToCallOnWindowResize); + + return () => { + window.removeEventListener('resize', functionToCallOnWindowResize); + }; + }, [responsive, functionToCallOnWindowResize]); + + const breadcrumbElements = breadcrumbs.map((breadcrumb, index) => { + const { + text, + href, + onClick, + truncate, + className: breadcrumbClassName, + ...breadcrumbRest + } = breadcrumb; + + const isFirstBreadcrumb = index === 0; + const isLastBreadcrumb = index === breadcrumbs.length - 1; + + const breadcrumbWrapperClasses = classNames( + 'ouiSimplifiedBreadcrumbWrapper', + { + 'ouiSimplifiedBreadcrumbWrapper--first': isFirstBreadcrumb, + 'ouiSimplifiedBreadcrumbWrapper--last': isLastBreadcrumb, + 'ouiSimplifiedBreadcrumbWrapper--truncate': truncate, + } + ); + + const breadcrumbClasses = classNames( + 'ouiSimplifiedBreadcrumb', + breadcrumbClassName, + { + 'ouiSimplifiedBreadcrumb--last': isLastBreadcrumb, + 'ouiSimplifiedBreadcrumb--truncate': truncate, + } + ); + + const link = + !href && !onClick ? ( + + {(ref, innerText) => ( + + {text} + + )} + + ) : ( + + {(ref, innerText) => ( + + {text} + + )} + + ); + + const breadcrumbWallClasses = classNames('ouiSimplifiedBreadcrumbWall', { + 'ouiSimplifiedBreadcrumbWall--single': + isFirstBreadcrumb && isLastBreadcrumb, + }); + + const wrapper =
{link}
; + const wall = isFirstBreadcrumb ? ( +
{wrapper}
+ ) : ( + wrapper + ); + + const separator = ; + + return ( + + {wall} + {separator} + + ); + }); + + // Use the default object if they simply passed `true` for responsive + const responsiveObject = + typeof responsive === 'object' ? responsive : responsiveDefault; + + // The max property collapses any breadcrumbs past the max quantity. + // This is the same behavior we want for responsiveness. + // So calculate the max value based on the combination of `max` and `responsive` + + // First, calculate the responsive max value + const responsiveMax = + responsive && responsiveObject[currentBreakpoint as OuiBreakpointSize] + ? responsiveObject[currentBreakpoint as OuiBreakpointSize] + : null; + + // Second, if both max and responsiveMax are set, use the smaller of the two. Otherwise, use the one that is set. + const calculatedMax: OuiBreadcrumbsProps['max'] = + max && responsiveMax ? Math.min(max, responsiveMax) : max || responsiveMax; + + const limitedBreadcrumbs = calculatedMax + ? limitBreadcrumbs(breadcrumbElements, calculatedMax, breadcrumbs) + : breadcrumbElements; + + const classes = classNames('ouiSimplifiedBreadcrumbs', className, { + 'ouiSimplifiedBreadcrumbs--truncate': truncate, + }); + + return ( + + ); +}; diff --git a/src/components/breadcrumbs/index.ts b/src/components/breadcrumbs/index.ts index 2fb503ef47..718732d269 100644 --- a/src/components/breadcrumbs/index.ts +++ b/src/components/breadcrumbs/index.ts @@ -34,3 +34,5 @@ export { OuiBreadcrumbsProps, OuiBreadcrumbResponsiveMaxCount, } from './breadcrumbs'; + +export { OuiSimplifiedBreadcrumbs } from './breadcrumbs_simplified'; diff --git a/src/components/header/header_breadcrumbs/__snapshots__/header_breadcrumbs.test.tsx.snap b/src/components/header/header_breadcrumbs/__snapshots__/header_breadcrumbs.test.tsx.snap index b529236297..b76cc8e037 100644 --- a/src/components/header/header_breadcrumbs/__snapshots__/header_breadcrumbs.test.tsx.snap +++ b/src/components/header/header_breadcrumbs/__snapshots__/header_breadcrumbs.test.tsx.snap @@ -55,3 +55,71 @@ exports[`OuiHeaderBreadcrumbs is rendered 1`] = `
`; + +exports[`OuiHeaderBreadcrumbs is rendered with simplified breadcrumbs 1`] = ` + +`; diff --git a/src/components/header/header_breadcrumbs/_header_breadcrumbs.scss b/src/components/header/header_breadcrumbs/_header_breadcrumbs.scss index 2bfe091df6..b73d972237 100644 --- a/src/components/header/header_breadcrumbs/_header_breadcrumbs.scss +++ b/src/components/header/header_breadcrumbs/_header_breadcrumbs.scss @@ -18,3 +18,8 @@ align-items: center; flex-grow: 1; } + +.ouiHeaderBreadcrumbs--simplified { + margin-left: 0; + margin-right: 0; +} diff --git a/src/components/header/header_breadcrumbs/header_breadcrumbs.test.tsx b/src/components/header/header_breadcrumbs/header_breadcrumbs.test.tsx index a5e84587a2..87ec77d96f 100644 --- a/src/components/header/header_breadcrumbs/header_breadcrumbs.test.tsx +++ b/src/components/header/header_breadcrumbs/header_breadcrumbs.test.tsx @@ -35,38 +35,50 @@ import { requiredProps } from '../../../test/required_props'; import { OuiHeaderBreadcrumbs } from './header_breadcrumbs'; describe('OuiHeaderBreadcrumbs', () => { - test('is rendered', () => { - const breadcrumbs = [ - { - text: 'Animals', - href: '#', - onClick: (e: React.MouseEvent) => { - e.preventDefault(); - console.log('You clicked Animals'); - }, - 'data-test-subj': 'breadcrumbsAnimals', - className: 'customClass', - }, - { - text: 'Reptiles', - onClick: (e: React.MouseEvent) => { - e.preventDefault(); - console.log('You clicked Reptiles'); - }, - }, - { - text: 'Boa constrictor', - href: '#', + const breadcrumbs = [ + { + text: 'Animals', + href: '#', + onClick: (e: React.MouseEvent) => { + e.preventDefault(); + console.log('You clicked Animals'); }, - { - text: 'Edit', + 'data-test-subj': 'breadcrumbsAnimals', + className: 'customClass', + }, + { + text: 'Reptiles', + onClick: (e: React.MouseEvent) => { + e.preventDefault(); + console.log('You clicked Reptiles'); }, - ]; + }, + { + text: 'Boa constrictor', + href: '#', + }, + { + text: 'Edit', + }, + ]; + test('is rendered', () => { const component = render( ); expect(component).toMatchSnapshot(); }); + + test('is rendered with simplified breadcrumbs', () => { + const component = render( + + ); + + expect(component).toMatchSnapshot(); + }); }); diff --git a/src/components/header/header_breadcrumbs/header_breadcrumbs.tsx b/src/components/header/header_breadcrumbs/header_breadcrumbs.tsx index f80af68497..7ec3c12c11 100644 --- a/src/components/header/header_breadcrumbs/header_breadcrumbs.tsx +++ b/src/components/header/header_breadcrumbs/header_breadcrumbs.tsx @@ -31,16 +31,32 @@ import React, { FunctionComponent } from 'react'; import classNames from 'classnames'; -import { OuiBreadcrumbs, OuiBreadcrumbsProps } from '../../breadcrumbs'; +import { + OuiBreadcrumbs, + OuiBreadcrumbsProps, + OuiSimplifiedBreadcrumbs, +} from '../../breadcrumbs'; -export const OuiHeaderBreadcrumbs: FunctionComponent = ({ - className, - breadcrumbs, - ...rest -}) => { - const classes = classNames('ouiHeaderBreadcrumbs', className); +export const OuiHeaderBreadcrumbs: FunctionComponent< + OuiBreadcrumbsProps & { simplify?: boolean } +> = ({ className, breadcrumbs, simplify, ...rest }) => { + const classes = classNames( + 'ouiHeaderBreadcrumbs', + { + 'ouiHeaderBreadcrumbs--simplified': simplify, + }, + className + ); - return ( + return simplify ? ( + + ) : (