main
js 263 lines 7.92 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @flow
8 */
9 import type {
10 Element,
11 ActivitySliceFilter,
12 ComponentFilter,
13 } from 'react-devtools-shared/src/frontend/types';
14 import typeof {
15 SyntheticMouseEvent,
16 SyntheticKeyboardEvent,
17 } from 'react-dom-bindings/src/events/SyntheticEvent';
18 import type Store from 'react-devtools-shared/src/devtools/store';
19
20 import * as React from 'react';
21 import {useContext, useMemo, useTransition} from 'react';
22 import {
23 ComponentFilterActivitySlice,
24 ElementTypeActivity,
25 } from 'react-devtools-shared/src/frontend/types';
26 import styles from './ActivityList.css';
27 import {
28 TreeStateContext,
29 TreeDispatcherContext,
30 } from '../Components/TreeContext';
31 import {useHighlightHostInstance} from '../hooks';
32 import {StoreContext} from '../context';
33
34 export function useChangeActivitySliceAction(): (
35 id: Element['id'] | null,
36 ) => void {
37 const store = useContext(StoreContext);
38
39 function changeActivitySliceAction(activityID: Element['id'] | null) {
40 const nextFilters: ComponentFilter[] = [];
41 // Remove any existing activity slice filter
42 for (let i = 0; i < store.componentFilters.length; i++) {
43 const filter = store.componentFilters[i];
44 if (filter.type !== ComponentFilterActivitySlice) {
45 nextFilters.push(filter);
46 }
47 }
48
49 if (activityID !== null) {
50 const rendererID = store.getRendererIDForElement(activityID);
51 if (rendererID === null) {
52 throw new Error('Expected to find renderer.');
53 }
54 const activityFilter: ActivitySliceFilter = {
55 type: ComponentFilterActivitySlice,
56 activityID,
57 rendererID,
58 isValid: true,
59 isEnabled: true,
60 };
61 nextFilters.push(activityFilter);
62 }
63 store.componentFilters = nextFilters;
64 }
65
66 return changeActivitySliceAction;
67 }
68
69 function findNearestActivityParentID(
70 elementID: Element['id'],
71 store: Store,
72 ): Element['id'] | null {
73 let currentID: null | Element['id'] = elementID;
74 while (currentID !== null) {
75 const element = store.getElementByID(currentID);
76 if (element === null) {
77 return null;
78 }
79 if (element.type === ElementTypeActivity) {
80 return element.id;
81 }
82 currentID = element.parentID;
83 }
84
85 return currentID;
86 }
87
88 function useSelectedActivityID(): Element['id'] | null {
89 const {inspectedElementID} = useContext(TreeStateContext);
90 const store = useContext(StoreContext);
91 return useMemo(() => {
92 if (inspectedElementID === null) {
93 return null;
94 }
95 const nearestActivityID = findNearestActivityParentID(
96 inspectedElementID,
97 store,
98 );
99 return nearestActivityID;
100 }, [inspectedElementID, store]);
101 }
102
103 export default function ActivityList({
104 activities,
105 }: {
106 activities: $ReadOnlyArray<{id: Element['id'], depth: number}>,
107 }): React$Node {
108 const {activityID, inspectedElementID} = useContext(TreeStateContext);
109 const treeDispatch = useContext(TreeDispatcherContext);
110 const store = useContext(StoreContext);
111 const selectedActivityID = useSelectedActivityID();
112 const {highlightHostInstance, clearHighlightHostInstance} =
113 useHighlightHostInstance();
114
115 const [isPendingActivitySliceSelection, startActivitySliceSelection] =
116 useTransition();
117 const changeActivitySliceAction = useChangeActivitySliceAction();
118
119 const includeAllOption = activityID !== null;
120
121 function handleKeyDown(event: SyntheticKeyboardEvent) {
122 switch (event.key) {
123 case 'Escape':
124 startActivitySliceSelection(() => {
125 changeActivitySliceAction(null);
126 });
127 event.preventDefault();
128 break;
129 case 'Enter':
130 case ' ':
131 startActivitySliceSelection(() => {
132 changeActivitySliceAction(inspectedElementID);
133 });
134 event.preventDefault();
135 break;
136 case 'Home':
137 treeDispatch({
138 type: 'SELECT_ELEMENT_BY_ID',
139 payload: includeAllOption ? null : activities[0].id,
140 });
141 event.preventDefault();
142 break;
143 case 'End':
144 treeDispatch({
145 type: 'SELECT_ELEMENT_BY_ID',
146 payload: activities[activities.length - 1].id,
147 });
148 event.preventDefault();
149 break;
150 case 'ArrowUp': {
151 const currentIndex = activities.findIndex(
152 activity => activity.id === selectedActivityID,
153 );
154 let nextIndex: number;
155 if (currentIndex === -1) {
156 // Currently selecting "All", wrap around to last Activity.
157 nextIndex = activities.length - 1;
158 } else {
159 nextIndex = currentIndex - 1;
160 if (!includeAllOption) {
161 nextIndex = (nextIndex + activities.length) % activities.length;
162 }
163 }
164
165 treeDispatch({
166 type: 'SELECT_ELEMENT_BY_ID',
167 payload: nextIndex === -1 ? null : activities[nextIndex].id,
168 });
169 event.preventDefault();
170 break;
171 }
172 case 'ArrowDown': {
173 const currentIndex = activities.findIndex(
174 activity => activity.id === selectedActivityID,
175 );
176 let nextIndex: number;
177 if (includeAllOption && currentIndex === activities.length - 1) {
178 // Currently selecting last Activity, wrap around to "All".
179 nextIndex = -1;
180 } else {
181 nextIndex = (currentIndex + 1) % activities.length;
182 }
183 treeDispatch({
184 type: 'SELECT_ELEMENT_BY_ID',
185 payload: nextIndex === -1 ? null : activities[nextIndex].id,
186 });
187 event.preventDefault();
188 break;
189 }
190 default:
191 break;
192 }
193 }
194
195 function handleClick(id: Element['id'] | null, event: SyntheticMouseEvent) {
196 event.preventDefault();
197 treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: id});
198 }
199
200 function handleDoubleClick() {
201 if (inspectedElementID !== null) {
202 changeActivitySliceAction(inspectedElementID);
203 }
204 }
205
206 return (
207 <div className={styles.ActivityListContaier}>
208 <div className={styles.ActivityListHeader} />
209 <ol
210 role="listbox"
211 className={styles.ActivityListList}
212 data-pending-activity-slice-selection={isPendingActivitySliceSelection}
213 tabIndex={0}
214 onKeyDown={handleKeyDown}>
215 {includeAllOption && (
216 // TODO: Obsolete once filtered Activities are included in this list.
217 <li
218 role="option"
219 aria-selected={null === selectedActivityID ? 'true' : 'false'}
220 className={styles.ActivityListItem}
221 onClick={handleClick.bind(null, null)}
222 onDoubleClick={handleDoubleClick}>
223 All
224 </li>
225 )}
226 {activities.map(({id, depth}) => {
227 const activity = store.getElementByID(id);
228 if (activity === null) {
229 return null;
230 }
231 const name = activity.nameProp;
232 if (name === null) {
233 // This shouldn't actually happen. We only want to show activities with a name.
234 // And hide the whole list if no named Activities are present.
235 return null;
236 }
237
238 // TODO: Filtered Activities should have dedicated styles once we include
239 // filtered Activities in this list.
240 return (
241 <li
242 key={activity.id}
243 role="option"
244 aria-selected={
245 activity.id === selectedActivityID ? 'true' : 'false'
246 }
247 className={styles.ActivityListItem}
248 onClick={handleClick.bind(null, activity.id)}
249 onDoubleClick={handleDoubleClick}
250 onPointerOver={highlightHostInstance.bind(
251 null,
252 activity.id,
253 false,
254 )}
255 onPointerLeave={clearHighlightHostInstance}>
256 {'\u00A0'.repeat(depth + (includeAllOption ? 1 : 0)) + name}
257 </li>
258 );
259 })}
260 </ol>
261 </div>
262 );
263 }