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