blob: c1b4350ac53921fb47cb9c9fd7546f947cef45c9 [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() {
Jack Franklin279564e2020-07-06 14:25:1876 this.render();
77 this.engageResizeObserver();
78 this.ensureSelectedNodeIsVisible();
79 }
80
81 private onCrumbMouseMove(node: DOMNode) {
82 return () => node.highlightNode();
83 }
84
85 private onCrumbMouseLeave(node: DOMNode) {
86 return () => node.clearHighlight();
87 }
88
89 private onCrumbFocus(node: DOMNode) {
90 return () => node.highlightNode();
91 }
92
93 private onCrumbBlur(node: DOMNode) {
94 return () => node.clearHighlight();
95 }
96
97 private engageResizeObserver() {
98 if (!this.resizeObserver || this.isObservingResize === true) {
99 return;
100 }
101
102 const crumbs = this.shadow.querySelector('.crumbs');
103
104 if (!crumbs) {
105 return;
106 }
107
108 this.resizeObserver.observe(crumbs);
109 this.isObservingResize = true;
110 }
111
Jack Franklin279564e2020-07-06 14:25:18112 /**
113 * This method runs after render and checks if the crumbs are too large for
114 * their container and therefore we need to render the overflow buttons at
115 * either end which the user can use to scroll back and forward through the crumbs.
116 * If it finds that we are overflowing, it sets the instance variable and
117 * triggers a re-render. If we are not overflowing, this method returns and
118 * does nothing.
119 */
120 private checkForOverflow() {
121 if (this.overflowing) {
122 return;
123 }
124
125 const crumbScrollContainer = this.shadow.querySelector('.crumbs-scroll-container');
126 const crumbWindow = this.shadow.querySelector('.crumbs-window');
127
128 if (!crumbScrollContainer || !crumbWindow) {
129 return;
130 }
131
132 const paddingAllowance = 20;
133 const maxChildWidth = crumbWindow.clientWidth - paddingAllowance;
134
135 if (crumbScrollContainer.clientWidth < maxChildWidth) {
136 return;
137 }
138
139 this.overflowing = true;
140 this.render();
141 }
142
143 private onCrumbsWindowScroll(event: Event) {
144 if (!event.target) {
145 return;
146 }
147
148 /* not all Events are DOM Events so the TS Event def doesn't have
149 * .target typed as an Element but in this case we're getting this
150 * from a DOM event so we're confident of having .target and it
151 * being an element
152 */
153 const scrollWindow = event.target as Element;
154
155 this.updateScrollState(scrollWindow);
156 }
157
158 private updateScrollState(scrollWindow: Element) {
159 const maxScrollLeft = scrollWindow.scrollWidth - scrollWindow.clientWidth;
160 const currentScroll = scrollWindow.scrollLeft;
161
162 /**
163 * When we check if the user is at the beginning or end of the crumbs (such
164 * that we disable the relevant button - you can't keep scrolling right if
165 * you're at the last breadcrumb) we want to not check exact numbers but
166 * give a bit of padding. This means if the user has scrolled to nearly the
167 * end but not quite (e.g. there are 2 more pixels they could scroll) we'll
168 * mark it as them being at the end. This variable controls how much padding
169 * we apply. So if a user has scrolled to within 10px of the end, we count
170 * them as being at the end and disable the button.
171 */
172 const scrollBeginningAndEndPadding = 10;
173
174 if (currentScroll < scrollBeginningAndEndPadding) {
175 this.userScrollPosition = 'start';
176 } else if (currentScroll >= maxScrollLeft - scrollBeginningAndEndPadding) {
177 this.userScrollPosition = 'end';
178 } else {
179 this.userScrollPosition = 'middle';
180 }
181
182 this.render();
183 }
184
185 private onOverflowClick(direction: 'left'|'right') {
186 return () => {
Jack Franklin10071892020-10-06 13:08:28187 this.userHasManuallyScrolled = true;
Jack Franklin279564e2020-07-06 14:25:18188 const scrollWindow = this.shadow.querySelector('.crumbs-window');
189
190 if (!scrollWindow) {
191 return;
192 }
193
194 const amountToScrollOnClick = scrollWindow.clientWidth / 2;
195
196 const newScrollAmount = direction === 'left' ?
197 Math.max(Math.floor(scrollWindow.scrollLeft - amountToScrollOnClick), 0) :
198 scrollWindow.scrollLeft + amountToScrollOnClick;
199
200 scrollWindow.scrollTo({
201 behavior: 'smooth',
202 left: newScrollAmount,
203 });
204 };
205 }
206
207 private renderOverflowButton(direction: 'left'|'right', disabled: boolean) {
Jack Franklina8aa0a82020-09-21 14:32:16208 const buttonStyles = LitHtml.Directives.classMap({
209 overflow: true,
210 [direction]: true,
211 hidden: this.overflowing === false,
212 });
Jack Franklin279564e2020-07-06 14:25:18213
214 return LitHtml.html`
215 <button
Jack Franklina8aa0a82020-09-21 14:32:16216 class=${buttonStyles}
Jack Franklin279564e2020-07-06 14:25:18217 @click=${this.onOverflowClick(direction)}
218 ?disabled=${disabled}
219 aria-label="Scroll ${direction}"
220 >&hellip;</button>
221 `;
222 }
223
224 private render() {
225 const crumbs = crumbsToRender(this.crumbsData, this.selectedDOMNode);
226
227 // Disabled until https://crbug.com/1079231 is fixed.
228 // clang-format off
229 LitHtml.render(LitHtml.html`
230 <style>
231 .crumbs {
232 display: inline-flex;
233 align-items: stretch;
234 width: 100%;
235 overflow: hidden;
236 pointer-events: auto;
237 cursor: default;
238 white-space: nowrap;
239 position: relative;
240 }
241
242 .crumbs-window {
243 flex-grow: 2;
244 overflow: hidden;
245 }
246
247 .crumbs-scroll-container {
248 display: inline-flex;
249 margin: 0;
250 padding: 0;
251 }
252
253 .crumb {
254 display: block;
255 padding: 0 7px;
256 line-height: 23px;
257 white-space: nowrap;
258 }
259
260 .overflow {
261 padding: 0 7px;
262 font-weight: bold;
263 display: block;
264 border: none;
265 flex-grow: 0;
266 flex-shrink: 0;
267 text-align: center;
268 }
269
270 .crumb.selected,
Jack Franklin18748932020-07-16 09:54:55271 .crumb:hover {
Alex Rudenko9f828842020-09-22 13:03:47272 background-color: var(--tab-selected-bg-color);
Jack Franklind8d96d52020-07-15 14:44:26273 }
274
Jack Franklin18748932020-07-16 09:54:55275 .overflow {
276 background-color: var(--toolbar-bg-color);
277 }
278
Jack Franklina8aa0a82020-09-21 14:32:16279 .overflow.hidden {
280 display: none;
281 }
282
Jack Franklin18748932020-07-16 09:54:55283 .overflow:not(:disabled):hover {
284 background-color: var(--toolbar-hover-bg-color);
285 cursor: pointer;
286 }
287
Jack Franklin279564e2020-07-06 14:25:18288 .crumb-link {
289 text-decoration: none;
290 color: inherit;
291 }
Jack Franklin18748932020-07-16 09:54:55292
Jack Franklin8876acb2020-10-07 14:26:09293 :host-context(.-theme-with-dark-background) .overflow:not(:disabled) {
Jack Franklin7ab22742020-08-10 10:22:56294 color: #fff;
Jack Franklin18748932020-07-16 09:54:55295 }
Jack Franklin279564e2020-07-06 14:25:18296 </style>
297
Jack Franklin8876acb2020-10-07 14:26:09298 <nav class="crumbs">
Jack Franklin279564e2020-07-06 14:25:18299 ${this.renderOverflowButton('left', this.userScrollPosition === 'start')}
300
301 <div class="crumbs-window" @scroll=${this.onCrumbsWindowScroll}>
302 <ul class="crumbs-scroll-container">
303 ${crumbs.map(crumb => {
304 const crumbClasses = {
305 crumb: true,
306 selected: crumb.selected,
307 };
Jack Franklin279564e2020-07-06 14:25:18308 return LitHtml.html`
309 <li class=${LitHtml.Directives.classMap(crumbClasses)}
310 data-node-id=${crumb.node.id}
311 data-crumb="true"
312 >
313 <a href="#"
314 class="crumb-link"
315 @click=${this.onCrumbClick(crumb.node)}
316 @mousemove=${this.onCrumbMouseMove(crumb.node)}
317 @mouseleave=${this.onCrumbMouseLeave(crumb.node)}
318 @focus=${this.onCrumbFocus(crumb.node)}
319 @blur=${this.onCrumbBlur(crumb.node)}
Jack Franklina8aa0a82020-09-21 14:32:16320 ><devtools-node-text data-node-title=${crumb.title.main} .data=${{
321 nodeTitle: crumb.title.main,
322 nodeId: crumb.title.extras.id,
323 nodeClasses: crumb.title.extras.classes,
324 } as NodeTextData}></devtools-node-text></a>
Jack Franklin279564e2020-07-06 14:25:18325 </li>`;
326 })}
327 </ul>
328 </div>
329 ${this.renderOverflowButton('right', this.userScrollPosition === 'end')}
330 </nav>
331 `, this.shadow, {
332 eventContext: this,
333 });
334 // clang-format on
335
336 this.checkForOverflow();
337 }
338
339 private ensureSelectedNodeIsVisible() {
Jack Franklin10071892020-10-06 13:08:28340 /*
341 * If the user has manually scrolled the crumbs in either direction, we
342 * effectively hand control over the scrolling down to them. This is to
343 * prevent the user manually scrolling to the end, and then us scrolling
344 * them back to the selected node. The moment they click either scroll
345 * button we set userHasManuallyScrolled, and we reset it when we get new
346 * data in. This means if the user clicks on a different element in the
347 * tree, we will auto-scroll that element into view, because we'll get new
348 * data and hence the flag will be reset.
349 */
350 if (!this.selectedDOMNode || !this.shadow || !this.overflowing || this.userHasManuallyScrolled) {
Jack Franklin279564e2020-07-06 14:25:18351 return;
352 }
353 const activeCrumbId = this.selectedDOMNode.id;
354 const activeCrumb = this.shadow.querySelector(`.crumb[data-node-id="${activeCrumbId}"]`);
355
356 if (activeCrumb) {
Jack Franklin10071892020-10-06 13:08:28357 activeCrumb.scrollIntoView({
358 behavior: 'smooth',
359 });
Jack Franklin279564e2020-07-06 14:25:18360 }
361 }
362}
363
364customElements.define('devtools-elements-breadcrumbs', ElementsBreadcrumbs);
365
366declare global {
367 interface HTMLElementTagNameMap {
368 'devtools-elements-breadcrumbs': ElementsBreadcrumbs;
369 }
370}