blob: 49e803e5543d490d62051473a7d1743702d221d7 [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
5import * as LitHtml from '../lit_html/lit_html.js';
6
7import {crumbsToRender, CrumbTitle, DOMNode, NodeSelectedEvent, UserScrollPosition} from './ElementsBreadcrumbsUtils.js';
8
9export class ElementsBreadcrumbs extends HTMLElement {
10 private readonly shadow = this.attachShadow({mode: 'open'});
11 private readonly resizeObserver = new ResizeObserver(() => this.update());
12
13 private crumbsData: ReadonlyArray<DOMNode> = [];
14 private selectedDOMNode: Readonly<DOMNode>|null = null;
15 private overflowing = false;
16 private userScrollPosition: UserScrollPosition = 'start';
17 private isObservingResize = false;
18
Jack Franklin18748932020-07-16 09:54:5519 set data(data: {selectedNode: DOMNode|null, crumbs: DOMNode[]}) {
Jack Franklin279564e2020-07-06 14:25:1820 this.selectedDOMNode = data.selectedNode;
21 this.crumbsData = data.crumbs;
22 this.update();
23 }
24
25 disconnectedCallback() {
26 this.isObservingResize = false;
27 this.resizeObserver.disconnect();
28 }
29
30 private onCrumbClick(node: DOMNode) {
31 return (event: Event) => {
32 event.preventDefault();
33 this.dispatchEvent(new NodeSelectedEvent(node));
34 };
35 }
36
37 private update() {
38 this.overflowing = false;
39 this.userScrollPosition = 'start';
40 this.render();
41 this.engageResizeObserver();
42 this.ensureSelectedNodeIsVisible();
43 }
44
45 private onCrumbMouseMove(node: DOMNode) {
46 return () => node.highlightNode();
47 }
48
49 private onCrumbMouseLeave(node: DOMNode) {
50 return () => node.clearHighlight();
51 }
52
53 private onCrumbFocus(node: DOMNode) {
54 return () => node.highlightNode();
55 }
56
57 private onCrumbBlur(node: DOMNode) {
58 return () => node.clearHighlight();
59 }
60
61 private engageResizeObserver() {
62 if (!this.resizeObserver || this.isObservingResize === true) {
63 return;
64 }
65
66 const crumbs = this.shadow.querySelector('.crumbs');
67
68 if (!crumbs) {
69 return;
70 }
71
72 this.resizeObserver.observe(crumbs);
73 this.isObservingResize = true;
74 }
75
76 private renderCrumbText(title: CrumbTitle) {
77 const parts = [
78 LitHtml.html`<span class="node-label-name">${title.main}</span>`,
79 ];
80
81 if (title.extras.id) {
82 parts.push(LitHtml.html`<span class="node-label-id">#${title.extras.id}</span>`);
83 }
84
85 if (title.extras.classes && title.extras.classes.length > 0) {
86 const text = title.extras.classes.map(c => `.${c}`).join('');
87 parts.push(LitHtml.html`<span class="extra node-label-class">${text}</span>`);
88 }
89
90 return parts;
91 }
92
93 /**
94 * This method runs after render and checks if the crumbs are too large for
95 * their container and therefore we need to render the overflow buttons at
96 * either end which the user can use to scroll back and forward through the crumbs.
97 * If it finds that we are overflowing, it sets the instance variable and
98 * triggers a re-render. If we are not overflowing, this method returns and
99 * does nothing.
100 */
101 private checkForOverflow() {
102 if (this.overflowing) {
103 return;
104 }
105
106 const crumbScrollContainer = this.shadow.querySelector('.crumbs-scroll-container');
107 const crumbWindow = this.shadow.querySelector('.crumbs-window');
108
109 if (!crumbScrollContainer || !crumbWindow) {
110 return;
111 }
112
113 const paddingAllowance = 20;
114 const maxChildWidth = crumbWindow.clientWidth - paddingAllowance;
115
116 if (crumbScrollContainer.clientWidth < maxChildWidth) {
117 return;
118 }
119
120 this.overflowing = true;
121 this.render();
122 }
123
124 private onCrumbsWindowScroll(event: Event) {
125 if (!event.target) {
126 return;
127 }
128
129 /* not all Events are DOM Events so the TS Event def doesn't have
130 * .target typed as an Element but in this case we're getting this
131 * from a DOM event so we're confident of having .target and it
132 * being an element
133 */
134 const scrollWindow = event.target as Element;
135
136 this.updateScrollState(scrollWindow);
137 }
138
139 private updateScrollState(scrollWindow: Element) {
140 const maxScrollLeft = scrollWindow.scrollWidth - scrollWindow.clientWidth;
141 const currentScroll = scrollWindow.scrollLeft;
142
143 /**
144 * When we check if the user is at the beginning or end of the crumbs (such
145 * that we disable the relevant button - you can't keep scrolling right if
146 * you're at the last breadcrumb) we want to not check exact numbers but
147 * give a bit of padding. This means if the user has scrolled to nearly the
148 * end but not quite (e.g. there are 2 more pixels they could scroll) we'll
149 * mark it as them being at the end. This variable controls how much padding
150 * we apply. So if a user has scrolled to within 10px of the end, we count
151 * them as being at the end and disable the button.
152 */
153 const scrollBeginningAndEndPadding = 10;
154
155 if (currentScroll < scrollBeginningAndEndPadding) {
156 this.userScrollPosition = 'start';
157 } else if (currentScroll >= maxScrollLeft - scrollBeginningAndEndPadding) {
158 this.userScrollPosition = 'end';
159 } else {
160 this.userScrollPosition = 'middle';
161 }
162
163 this.render();
164 }
165
166 private onOverflowClick(direction: 'left'|'right') {
167 return () => {
168 const scrollWindow = this.shadow.querySelector('.crumbs-window');
169
170 if (!scrollWindow) {
171 return;
172 }
173
174 const amountToScrollOnClick = scrollWindow.clientWidth / 2;
175
176 const newScrollAmount = direction === 'left' ?
177 Math.max(Math.floor(scrollWindow.scrollLeft - amountToScrollOnClick), 0) :
178 scrollWindow.scrollLeft + amountToScrollOnClick;
179
180 scrollWindow.scrollTo({
181 behavior: 'smooth',
182 left: newScrollAmount,
183 });
184 };
185 }
186
187 private renderOverflowButton(direction: 'left'|'right', disabled: boolean) {
188 if (this.overflowing === false) {
189 return LitHtml.html``;
190 }
191
192 return LitHtml.html`
193 <button
194 class="overflow ${direction}"
195 @click=${this.onOverflowClick(direction)}
196 ?disabled=${disabled}
197 aria-label="Scroll ${direction}"
198 >&hellip;</button>
199 `;
200 }
201
202 private render() {
203 const crumbs = crumbsToRender(this.crumbsData, this.selectedDOMNode);
204
205 // Disabled until https://crbug.com/1079231 is fixed.
206 // clang-format off
207 LitHtml.render(LitHtml.html`
208 <style>
209 .crumbs {
210 display: inline-flex;
211 align-items: stretch;
212 width: 100%;
213 overflow: hidden;
214 pointer-events: auto;
215 cursor: default;
216 white-space: nowrap;
217 position: relative;
218 }
219
220 .crumbs-window {
221 flex-grow: 2;
222 overflow: hidden;
223 }
224
225 .crumbs-scroll-container {
226 display: inline-flex;
227 margin: 0;
228 padding: 0;
229 }
230
231 .crumb {
232 display: block;
233 padding: 0 7px;
234 line-height: 23px;
235 white-space: nowrap;
236 }
237
238 .overflow {
239 padding: 0 7px;
240 font-weight: bold;
241 display: block;
242 border: none;
243 flex-grow: 0;
244 flex-shrink: 0;
245 text-align: center;
246 }
247
248 .crumb.selected,
Jack Franklin18748932020-07-16 09:54:55249 .crumb:hover {
Simon Zünd8794ac62020-07-16 07:14:10250 background-color: var(--toolbar-bg-color);
Jack Franklind8d96d52020-07-15 14:44:26251 }
252
Jack Franklin18748932020-07-16 09:54:55253 .overflow {
254 background-color: var(--toolbar-bg-color);
255 }
256
257
258 .overflow:not(:disabled):hover {
259 background-color: var(--toolbar-hover-bg-color);
260 cursor: pointer;
261 }
262
Jack Franklin279564e2020-07-06 14:25:18263 .crumb:not(.selected) .node-label-name {
264 color: var(--dom-tag-name-color);
265 }
266
267 .crumb:not(.selected) .node-label-class {
268 color: var(--dom-attribute-name-color);
269 }
270
271 .crumb-link {
272 text-decoration: none;
273 color: inherit;
274 }
Jack Franklin18748932020-07-16 09:54:55275
276 @media(prefers-color-scheme: dark) {
277 .overflow:not(:disabled) {
278 color: #fff;
279 }
280 }
Jack Franklin279564e2020-07-06 14:25:18281 </style>
282
283 <nav class="crumbs">
284 ${this.renderOverflowButton('left', this.userScrollPosition === 'start')}
285
286 <div class="crumbs-window" @scroll=${this.onCrumbsWindowScroll}>
287 <ul class="crumbs-scroll-container">
288 ${crumbs.map(crumb => {
289 const crumbClasses = {
290 crumb: true,
291 selected: crumb.selected,
292 };
293 const crumbText = this.renderCrumbText(crumb.title);
294
295 return LitHtml.html`
296 <li class=${LitHtml.Directives.classMap(crumbClasses)}
297 data-node-id=${crumb.node.id}
298 data-crumb="true"
299 >
300 <a href="#"
301 class="crumb-link"
302 @click=${this.onCrumbClick(crumb.node)}
303 @mousemove=${this.onCrumbMouseMove(crumb.node)}
304 @mouseleave=${this.onCrumbMouseLeave(crumb.node)}
305 @focus=${this.onCrumbFocus(crumb.node)}
306 @blur=${this.onCrumbBlur(crumb.node)}
307 >${crumbText}</a>
308 </li>`;
309 })}
310 </ul>
311 </div>
312 ${this.renderOverflowButton('right', this.userScrollPosition === 'end')}
313 </nav>
314 `, this.shadow, {
315 eventContext: this,
316 });
317 // clang-format on
318
319 this.checkForOverflow();
320 }
321
322 private ensureSelectedNodeIsVisible() {
323 if (!this.selectedDOMNode || !this.shadow || !this.overflowing) {
324 return;
325 }
326 const activeCrumbId = this.selectedDOMNode.id;
327 const activeCrumb = this.shadow.querySelector(`.crumb[data-node-id="${activeCrumbId}"]`);
328
329 if (activeCrumb) {
330 activeCrumb.scrollIntoView();
331 }
332 }
333}
334
335customElements.define('devtools-elements-breadcrumbs', ElementsBreadcrumbs);
336
337declare global {
338 interface HTMLElementTagNameMap {
339 'devtools-elements-breadcrumbs': ElementsBreadcrumbs;
340 }
341}