blob: 4d5fd5ddc9860d8748af003beed16c15946b4791 [file] [log] [blame]
Jack Franklin279564e2020-07-06 14:25:181// Copyright (c) 2020 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
Jack Franklin478d4682020-09-28 11:39:065import './NodeText.js';
6
Jack Franklin7ab22742020-08-10 10:22:567import * as ComponentHelpers from '../component_helpers/component_helpers.js';
Tim van der Lippee810d532020-07-17 10:31:348import * as LitHtml from '../third_party/lit-html/lit-html.js';
Jack Franklin279564e2020-07-06 14:25:189
Jack Franklina8aa0a82020-09-21 14:32:1610import {crumbsToRender, DOMNode, NodeSelectedEvent, UserScrollPosition} from './ElementsBreadcrumbsUtils.js';
Jack Franklin478d4682020-09-28 11:39:0611
12import type {NodeTextData} from './NodeText.js';
Jack Franklin279564e2020-07-06 14:25:1813
Jack Frankline6e1b792020-09-23 10:19:4914export interface ElementsBreadcrumbsData {
15 selectedNode: DOMNode|null;
16 crumbs: DOMNode[];
17}
Jack Franklin279564e2020-07-06 14:25:1818export class ElementsBreadcrumbs extends HTMLElement {
19 private readonly shadow = this.attachShadow({mode: 'open'});
Jack Franklin10071892020-10-06 13:08:2820 private readonly resizeObserver = new ResizeObserver(() => this.checkForOverflowOnResize());
Jack Franklin279564e2020-07-06 14:25:1821
22 private crumbsData: ReadonlyArray<DOMNode> = [];
23 private selectedDOMNode: Readonly<DOMNode>|null = null;
24 private overflowing = false;
25 private userScrollPosition: UserScrollPosition = 'start';
26 private isObservingResize = false;
Jack Franklin10071892020-10-06 13:08:2827 private userHasManuallyScrolled = false;
Jack Franklin279564e2020-07-06 14:25:1828
Jack Frankline6e1b792020-09-23 10:19:4929 set data(data: ElementsBreadcrumbsData) {
Jack Franklin279564e2020-07-06 14:25:1830 this.selectedDOMNode = data.selectedNode;
31 this.crumbsData = data.crumbs;
Jack Franklin10071892020-10-06 13:08:2832 this.userHasManuallyScrolled = false;
Jack Franklin279564e2020-07-06 14:25:1833 this.update();
34 }
35
36 disconnectedCallback() {
37 this.isObservingResize = false;
38 this.resizeObserver.disconnect();
39 }
40
41 private onCrumbClick(node: DOMNode) {
42 return (event: Event) => {
43 event.preventDefault();
44 this.dispatchEvent(new NodeSelectedEvent(node));
45 };
46 }
47
Jack Franklin10071892020-10-06 13:08:2848 /*
49 * When the window is resized, we need to check if we either:
50 * 1) overflowing, and now the window is big enough that we don't need to
51 * 2) not overflowing, and now the window is small and we do need to
52 *
53 * If either of these are true, we toggle the overflowing state accordingly and trigger a re-render.
54 */
55 private checkForOverflowOnResize() {
56 const wrappingElement = this.shadow.querySelector('.crumbs');
57 const crumbs = this.shadow.querySelector('.crumbs-scroll-container');
58 if (!wrappingElement || !crumbs) {
59 return;
60 }
61
62 const totalContainingWidth = wrappingElement.clientWidth;
63 const totalCrumbsWidth = crumbs.clientWidth;
64
65 if (totalCrumbsWidth >= totalContainingWidth && this.overflowing === false) {
66 this.overflowing = true;
67 this.userScrollPosition = 'start';
68 this.render();
69 } else if (totalCrumbsWidth < totalContainingWidth && this.overflowing === true) {
70 this.overflowing = false;
71 this.userScrollPosition = 'start';
72 this.render();
73 }
74 }
75
Jack Franklin279564e2020-07-06 14:25:1876 private update() {
77 this.overflowing = false;
78 this.userScrollPosition = 'start';
79 this.render();
80 this.engageResizeObserver();
81 this.ensureSelectedNodeIsVisible();
82 }
83
84 private onCrumbMouseMove(node: DOMNode) {
85 return () => node.highlightNode();
86 }
87
88 private onCrumbMouseLeave(node: DOMNode) {
89 return () => node.clearHighlight();
90 }
91
92 private onCrumbFocus(node: DOMNode) {
93 return () => node.highlightNode();
94 }
95
96 private onCrumbBlur(node: DOMNode) {
97 return () => node.clearHighlight();
98 }
99
100 private engageResizeObserver() {
101 if (!this.resizeObserver || this.isObservingResize === true) {
102 return;
103 }
104
105 const crumbs = this.shadow.querySelector('.crumbs');
106
107 if (!crumbs) {
108 return;
109 }
110
111 this.resizeObserver.observe(crumbs);
112 this.isObservingResize = true;
113 }
114
Jack Franklin279564e2020-07-06 14:25:18115 /**
116 * This method runs after render and checks if the crumbs are too large for
117 * their container and therefore we need to render the overflow buttons at
118 * either end which the user can use to scroll back and forward through the crumbs.
119 * If it finds that we are overflowing, it sets the instance variable and
120 * triggers a re-render. If we are not overflowing, this method returns and
121 * does nothing.
122 */
123 private checkForOverflow() {
124 if (this.overflowing) {
125 return;
126 }
127
128 const crumbScrollContainer = this.shadow.querySelector('.crumbs-scroll-container');
129 const crumbWindow = this.shadow.querySelector('.crumbs-window');
130
131 if (!crumbScrollContainer || !crumbWindow) {
132 return;
133 }
134
135 const paddingAllowance = 20;
136 const maxChildWidth = crumbWindow.clientWidth - paddingAllowance;
137
138 if (crumbScrollContainer.clientWidth < maxChildWidth) {
139 return;
140 }
141
142 this.overflowing = true;
143 this.render();
144 }
145
146 private onCrumbsWindowScroll(event: Event) {
147 if (!event.target) {
148 return;
149 }
150
151 /* not all Events are DOM Events so the TS Event def doesn't have
152 * .target typed as an Element but in this case we're getting this
153 * from a DOM event so we're confident of having .target and it
154 * being an element
155 */
156 const scrollWindow = event.target as Element;
157
158 this.updateScrollState(scrollWindow);
159 }
160
161 private updateScrollState(scrollWindow: Element) {
162 const maxScrollLeft = scrollWindow.scrollWidth - scrollWindow.clientWidth;
163 const currentScroll = scrollWindow.scrollLeft;
164
165 /**
166 * When we check if the user is at the beginning or end of the crumbs (such
167 * that we disable the relevant button - you can't keep scrolling right if
168 * you're at the last breadcrumb) we want to not check exact numbers but
169 * give a bit of padding. This means if the user has scrolled to nearly the
170 * end but not quite (e.g. there are 2 more pixels they could scroll) we'll
171 * mark it as them being at the end. This variable controls how much padding
172 * we apply. So if a user has scrolled to within 10px of the end, we count
173 * them as being at the end and disable the button.
174 */
175 const scrollBeginningAndEndPadding = 10;
176
177 if (currentScroll < scrollBeginningAndEndPadding) {
178 this.userScrollPosition = 'start';
179 } else if (currentScroll >= maxScrollLeft - scrollBeginningAndEndPadding) {
180 this.userScrollPosition = 'end';
181 } else {
182 this.userScrollPosition = 'middle';
183 }
184
185 this.render();
186 }
187
188 private onOverflowClick(direction: 'left'|'right') {
189 return () => {
Jack Franklin10071892020-10-06 13:08:28190 this.userHasManuallyScrolled = true;
Jack Franklin279564e2020-07-06 14:25:18191 const scrollWindow = this.shadow.querySelector('.crumbs-window');
192
193 if (!scrollWindow) {
194 return;
195 }
196
197 const amountToScrollOnClick = scrollWindow.clientWidth / 2;
198
199 const newScrollAmount = direction === 'left' ?
200 Math.max(Math.floor(scrollWindow.scrollLeft - amountToScrollOnClick), 0) :
201 scrollWindow.scrollLeft + amountToScrollOnClick;
202
203 scrollWindow.scrollTo({
204 behavior: 'smooth',
205 left: newScrollAmount,
206 });
207 };
208 }
209
210 private renderOverflowButton(direction: 'left'|'right', disabled: boolean) {
Jack Franklina8aa0a82020-09-21 14:32:16211 const buttonStyles = LitHtml.Directives.classMap({
212 overflow: true,
213 [direction]: true,
214 hidden: this.overflowing === false,
215 });
Jack Franklin279564e2020-07-06 14:25:18216
217 return LitHtml.html`
218 <button
Jack Franklina8aa0a82020-09-21 14:32:16219 class=${buttonStyles}
Jack Franklin279564e2020-07-06 14:25:18220 @click=${this.onOverflowClick(direction)}
221 ?disabled=${disabled}
222 aria-label="Scroll ${direction}"
223 >&hellip;</button>
224 `;
225 }
226
227 private render() {
228 const crumbs = crumbsToRender(this.crumbsData, this.selectedDOMNode);
229
230 // Disabled until https://crbug.com/1079231 is fixed.
231 // clang-format off
232 LitHtml.render(LitHtml.html`
233 <style>
234 .crumbs {
235 display: inline-flex;
236 align-items: stretch;
237 width: 100%;
238 overflow: hidden;
239 pointer-events: auto;
240 cursor: default;
241 white-space: nowrap;
242 position: relative;
243 }
244
245 .crumbs-window {
246 flex-grow: 2;
247 overflow: hidden;
248 }
249
250 .crumbs-scroll-container {
251 display: inline-flex;
252 margin: 0;
253 padding: 0;
254 }
255
256 .crumb {
257 display: block;
258 padding: 0 7px;
259 line-height: 23px;
260 white-space: nowrap;
261 }
262
263 .overflow {
264 padding: 0 7px;
265 font-weight: bold;
266 display: block;
267 border: none;
268 flex-grow: 0;
269 flex-shrink: 0;
270 text-align: center;
271 }
272
273 .crumb.selected,
Jack Franklin18748932020-07-16 09:54:55274 .crumb:hover {
Alex Rudenko9f828842020-09-22 13:03:47275 background-color: var(--tab-selected-bg-color);
Jack Franklind8d96d52020-07-15 14:44:26276 }
277
Jack Franklin18748932020-07-16 09:54:55278 .overflow {
279 background-color: var(--toolbar-bg-color);
280 }
281
Jack Franklina8aa0a82020-09-21 14:32:16282 .overflow.hidden {
283 display: none;
284 }
285
Jack Franklin18748932020-07-16 09:54:55286 .overflow:not(:disabled):hover {
287 background-color: var(--toolbar-hover-bg-color);
288 cursor: pointer;
289 }
290
Jack Franklin279564e2020-07-06 14:25:18291 .crumb-link {
292 text-decoration: none;
293 color: inherit;
294 }
Jack Franklin18748932020-07-16 09:54:55295
Jack Franklin7ab22742020-08-10 10:22:56296 ${ComponentHelpers.GetStylesheet.DARK_MODE_CLASS} .overflow:not(:disabled) {
297 color: #fff;
Jack Franklin18748932020-07-16 09:54:55298 }
Jack Franklin279564e2020-07-06 14:25:18299 </style>
300
Jack Franklin7ab22742020-08-10 10:22:56301 <nav class=${`crumbs ${ComponentHelpers.GetStylesheet.applyDarkModeClassIfNeeded()}`}>
Jack Franklin279564e2020-07-06 14:25:18302 ${this.renderOverflowButton('left', this.userScrollPosition === 'start')}
303
304 <div class="crumbs-window" @scroll=${this.onCrumbsWindowScroll}>
305 <ul class="crumbs-scroll-container">
306 ${crumbs.map(crumb => {
307 const crumbClasses = {
308 crumb: true,
309 selected: crumb.selected,
310 };
Jack Franklin279564e2020-07-06 14:25:18311 return LitHtml.html`
312 <li class=${LitHtml.Directives.classMap(crumbClasses)}
313 data-node-id=${crumb.node.id}
314 data-crumb="true"
315 >
316 <a href="#"
317 class="crumb-link"
318 @click=${this.onCrumbClick(crumb.node)}
319 @mousemove=${this.onCrumbMouseMove(crumb.node)}
320 @mouseleave=${this.onCrumbMouseLeave(crumb.node)}
321 @focus=${this.onCrumbFocus(crumb.node)}
322 @blur=${this.onCrumbBlur(crumb.node)}
Jack Franklina8aa0a82020-09-21 14:32:16323 ><devtools-node-text data-node-title=${crumb.title.main} .data=${{
324 nodeTitle: crumb.title.main,
325 nodeId: crumb.title.extras.id,
326 nodeClasses: crumb.title.extras.classes,
327 } as NodeTextData}></devtools-node-text></a>
Jack Franklin279564e2020-07-06 14:25:18328 </li>`;
329 })}
330 </ul>
331 </div>
332 ${this.renderOverflowButton('right', this.userScrollPosition === 'end')}
333 </nav>
334 `, this.shadow, {
335 eventContext: this,
336 });
337 // clang-format on
338
339 this.checkForOverflow();
340 }
341
342 private ensureSelectedNodeIsVisible() {
Jack Franklin10071892020-10-06 13:08:28343 /*
344 * If the user has manually scrolled the crumbs in either direction, we
345 * effectively hand control over the scrolling down to them. This is to
346 * prevent the user manually scrolling to the end, and then us scrolling
347 * them back to the selected node. The moment they click either scroll
348 * button we set userHasManuallyScrolled, and we reset it when we get new
349 * data in. This means if the user clicks on a different element in the
350 * tree, we will auto-scroll that element into view, because we'll get new
351 * data and hence the flag will be reset.
352 */
353 if (!this.selectedDOMNode || !this.shadow || !this.overflowing || this.userHasManuallyScrolled) {
Jack Franklin279564e2020-07-06 14:25:18354 return;
355 }
356 const activeCrumbId = this.selectedDOMNode.id;
357 const activeCrumb = this.shadow.querySelector(`.crumb[data-node-id="${activeCrumbId}"]`);
358
359 if (activeCrumb) {
Jack Franklin10071892020-10-06 13:08:28360 activeCrumb.scrollIntoView({
361 behavior: 'smooth',
362 });
Jack Franklin279564e2020-07-06 14:25:18363 }
364 }
365}
366
367customElements.define('devtools-elements-breadcrumbs', ElementsBreadcrumbs);
368
369declare global {
370 interface HTMLElementTagNameMap {
371 'devtools-elements-breadcrumbs': ElementsBreadcrumbs;
372 }
373}