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