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