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