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