diff --git a/front_end/component_docs/elements_breadcrumbs/BUILD.gn b/front_end/component_docs/elements_breadcrumbs/BUILD.gn index dc6ed55ea9..8ecc75b020 100644 --- a/front_end/component_docs/elements_breadcrumbs/BUILD.gn +++ b/front_end/component_docs/elements_breadcrumbs/BUILD.gn @@ -7,6 +7,7 @@ import("../../../scripts/build/ninja/copy.gni") copy_to_gen("elements_breadcrumbs") { sources = [ "basic.html", + "scroll-to-active-element.html", "scroll.html", ] diff --git a/front_end/component_docs/elements_breadcrumbs/scroll-to-active-element.html b/front_end/component_docs/elements_breadcrumbs/scroll-to-active-element.html new file mode 100644 index 0000000000..f819c4c795 --- /dev/null +++ b/front_end/component_docs/elements_breadcrumbs/scroll-to-active-element.html @@ -0,0 +1,142 @@ + + + + + + Scrolling breadcrumbs example + + + + +
+
+ + + + + diff --git a/front_end/elements/ElementsBreadcrumbs.ts b/front_end/elements/ElementsBreadcrumbs.ts index 2b93bfb346..4d5fd5ddc9 100644 --- a/front_end/elements/ElementsBreadcrumbs.ts +++ b/front_end/elements/ElementsBreadcrumbs.ts @@ -17,17 +17,19 @@ export interface ElementsBreadcrumbsData { } export class ElementsBreadcrumbs extends HTMLElement { private readonly shadow = this.attachShadow({mode: 'open'}); - private readonly resizeObserver = new ResizeObserver(() => this.update()); + private readonly resizeObserver = new ResizeObserver(() => this.checkForOverflowOnResize()); private crumbsData: ReadonlyArray = []; private selectedDOMNode: Readonly|null = null; private overflowing = false; private userScrollPosition: UserScrollPosition = 'start'; private isObservingResize = false; + private userHasManuallyScrolled = false; set data(data: ElementsBreadcrumbsData) { this.selectedDOMNode = data.selectedNode; this.crumbsData = data.crumbs; + this.userHasManuallyScrolled = false; this.update(); } @@ -43,6 +45,34 @@ export class ElementsBreadcrumbs extends HTMLElement { }; } + /* + * When the window is resized, we need to check if we either: + * 1) overflowing, and now the window is big enough that we don't need to + * 2) not overflowing, and now the window is small and we do need to + * + * If either of these are true, we toggle the overflowing state accordingly and trigger a re-render. + */ + private checkForOverflowOnResize() { + const wrappingElement = this.shadow.querySelector('.crumbs'); + const crumbs = this.shadow.querySelector('.crumbs-scroll-container'); + if (!wrappingElement || !crumbs) { + return; + } + + const totalContainingWidth = wrappingElement.clientWidth; + const totalCrumbsWidth = crumbs.clientWidth; + + if (totalCrumbsWidth >= totalContainingWidth && this.overflowing === false) { + this.overflowing = true; + this.userScrollPosition = 'start'; + this.render(); + } else if (totalCrumbsWidth < totalContainingWidth && this.overflowing === true) { + this.overflowing = false; + this.userScrollPosition = 'start'; + this.render(); + } + } + private update() { this.overflowing = false; this.userScrollPosition = 'start'; @@ -157,6 +187,7 @@ export class ElementsBreadcrumbs extends HTMLElement { private onOverflowClick(direction: 'left'|'right') { return () => { + this.userHasManuallyScrolled = true; const scrollWindow = this.shadow.querySelector('.crumbs-window'); if (!scrollWindow) { @@ -309,14 +340,26 @@ export class ElementsBreadcrumbs extends HTMLElement { } private ensureSelectedNodeIsVisible() { - if (!this.selectedDOMNode || !this.shadow || !this.overflowing) { + /* + * If the user has manually scrolled the crumbs in either direction, we + * effectively hand control over the scrolling down to them. This is to + * prevent the user manually scrolling to the end, and then us scrolling + * them back to the selected node. The moment they click either scroll + * button we set userHasManuallyScrolled, and we reset it when we get new + * data in. This means if the user clicks on a different element in the + * tree, we will auto-scroll that element into view, because we'll get new + * data and hence the flag will be reset. + */ + if (!this.selectedDOMNode || !this.shadow || !this.overflowing || this.userHasManuallyScrolled) { return; } const activeCrumbId = this.selectedDOMNode.id; const activeCrumb = this.shadow.querySelector(`.crumb[data-node-id="${activeCrumbId}"]`); if (activeCrumb) { - activeCrumb.scrollIntoView(); + activeCrumb.scrollIntoView({ + behavior: 'smooth', + }); } } } diff --git a/test/unittests/front_end/elements/ElementsBreadcrumbs_test.ts b/test/unittests/front_end/elements/ElementsBreadcrumbs_test.ts index 069797dff0..6c2f5dd59e 100644 --- a/test/unittests/front_end/elements/ElementsBreadcrumbs_test.ts +++ b/test/unittests/front_end/elements/ElementsBreadcrumbs_test.ts @@ -4,7 +4,7 @@ import {ElementsBreadcrumbs} from '../../../../front_end/elements/ElementsBreadcrumbs.js'; import {crumbsToRender, determineElementTitle, DOMNode} from '../../../../front_end/elements/ElementsBreadcrumbsUtils.js'; -import {assertElement, assertElements, assertShadowRoot, dispatchClickEvent, renderElementIntoDOM, waitForScrollLeft} from '../helpers/DOMHelpers.js'; +import {assertElement, assertElements, assertShadowRoot, dispatchClickEvent, doubleRaf, renderElementIntoDOM, waitForScrollLeft} from '../helpers/DOMHelpers.js'; import {withNoMutations} from '../helpers/MutationHelpers.js'; const {assert} = chai; @@ -349,6 +349,70 @@ describe('ElementsBreadcrumbs', () => { assert.isTrue(rightButton.disabled); }); }); + + it('hides the overflow buttons should the user resize the window to be large enough', async () => { + const thinWrapper = document.createElement('div'); + thinWrapper.style.width = '400px'; + + const component = new ElementsBreadcrumbs(); + thinWrapper.appendChild(component); + + renderElementIntoDOM(thinWrapper); + + component.data = { + crumbs: [divCrumb, bodyCrumb], + selectedNode: bodyCrumb, + }; + + assertShadowRoot(component.shadowRoot); + + const leftButton = component.shadowRoot.querySelector('button.overflow.left'); + assertElement(leftButton, HTMLButtonElement); + const rightButton = component.shadowRoot.querySelector('button.overflow.right'); + assertElement(rightButton, HTMLButtonElement); + + assert.isFalse(leftButton.classList.contains('hidden')); + assert.isFalse(rightButton.classList.contains('hidden')); + + thinWrapper.style.width = '800px'; + // So the browser has time to paint + await doubleRaf(); + + assert.isTrue(leftButton.classList.contains('hidden')); + assert.isTrue(rightButton.classList.contains('hidden')); + }); + + it('shows the overflow buttons should the user resize the window down to be small', async () => { + const thinWrapper = document.createElement('div'); + thinWrapper.style.width = '800px'; + + const component = new ElementsBreadcrumbs(); + thinWrapper.appendChild(component); + + renderElementIntoDOM(thinWrapper); + + component.data = { + crumbs: [divCrumb, bodyCrumb], + selectedNode: bodyCrumb, + }; + + assertShadowRoot(component.shadowRoot); + + const leftButton = component.shadowRoot.querySelector('button.overflow.left'); + assertElement(leftButton, HTMLButtonElement); + const rightButton = component.shadowRoot.querySelector('button.overflow.right'); + assertElement(rightButton, HTMLButtonElement); + + assert.isTrue(leftButton.classList.contains('hidden')); + assert.isTrue(rightButton.classList.contains('hidden')); + + thinWrapper.style.width = '400px'; + // So the browser has time to paint + await doubleRaf(); + + assert.isFalse(leftButton.classList.contains('hidden')); + assert.isFalse(rightButton.classList.contains('hidden')); + }); }); }); diff --git a/test/unittests/front_end/helpers/DOMHelpers.ts b/test/unittests/front_end/helpers/DOMHelpers.ts index ca1d1ee22b..b22293648d 100644 --- a/test/unittests/front_end/helpers/DOMHelpers.ts +++ b/test/unittests/front_end/helpers/DOMHelpers.ts @@ -130,3 +130,7 @@ export function dispatchClickEvent(element: T, options: Mouse assert.fail('Failed to trigger click event successfully.'); } } + +export async function doubleRaf() { + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); +}