Skip to content

Commit 0f029e0

Browse files
ergunshDevtools-frontend LUCI CQ
authored andcommitted
[Animations] Address UI eng violations in AnimationGroupPreviewUI
The interaction between `AnimationTimeline` and `AnimationGroupPreviewUI` was being done on the DOM level (e.g. `AnimationTimeline` was reaching out to the internal DOM of `AnimationGroupPreviewUI`, attaching event listeners there and manipulating its focusability via setting `tabindex`.) This CL: * Updated `AnimationGroupPreviewUI` to be a `Widget`, used `Lit.render` function and exposed API for `AnimationTimeline` to interact with it. Fixed: 407748637 Change-Id: I64fe8f18966164c2fc4825b1973854a4eca6eae8 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6523402 Reviewed-by: Danil Somsikov <dsv@chromium.org> Commit-Queue: Ergün Erdoğmuş <ergunsh@chromium.org>
1 parent a35ef62 commit 0f029e0

File tree

3 files changed

+287
-132
lines changed

3 files changed

+287
-132
lines changed
Lines changed: 221 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,90 +1,244 @@
11
// Copyright (c) 2015 The Chromium Authors. All rights reserved.
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
4-
/* eslint-disable rulesdir/no-imperative-dom-api */
54

65
import type * as SDK from '../../core/sdk/sdk.js';
7-
import * as IconButton from '../../ui/components/icon_button/icon_button.js';
86
import * as UI from '../../ui/legacy/legacy.js';
7+
import * as Lit from '../../ui/lit/lit.js';
98
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
109

1110
import {AnimationUI} from './AnimationUI.js';
1211

13-
export class AnimationGroupPreviewUI {
14-
#model: SDK.AnimationModel.AnimationGroup;
15-
element: HTMLButtonElement;
16-
readonly #removeButtonInternal: HTMLElement;
17-
readonly #replayOverlayElement: HTMLElement;
18-
readonly #svg: Element;
19-
readonly #viewBoxHeight: number;
20-
21-
constructor(model: SDK.AnimationModel.AnimationGroup) {
22-
this.#model = model;
23-
this.element = document.createElement('button');
24-
this.element.setAttribute(
25-
'jslog', `${VisualLogging.item(`animations.buffer-preview${model.isScrollDriven() ? '-sda' : ''}`).track({
26-
click: true,
27-
})}`);
28-
this.element.classList.add('animation-buffer-preview');
29-
this.element.addEventListener('animationend', () => {
30-
this.element.classList.add('no-animation');
31-
});
12+
const {render, html, svg, Directives: {classMap, ref}} = Lit;
13+
14+
const VIEW_BOX_HEIGHT = 32;
15+
const MAX_ANIMATION_LINES_TO_SHOW = 10;
16+
const MIN_ANIMATION_GROUP_DURATION = 750;
17+
18+
interface ViewInput {
19+
isScrollDrivenAnimationGroup: boolean;
20+
isPreviewAnimationDisabled: boolean;
21+
isSelected: boolean;
22+
isPaused: boolean;
23+
isFocusable: boolean;
24+
label: string;
25+
animationGroupDuration: number;
26+
animations: SDK.AnimationModel.AnimationImpl[];
27+
onPreviewAnimationEnd: () => void;
28+
onRemoveAnimationGroup: () => void;
29+
onSelectAnimationGroup: () => void;
30+
onCreateScreenshotPopover: () => void;
31+
onFocusNextGroup: () => void;
32+
onFocusPreviousGroup: () => void;
33+
}
34+
35+
interface ViewOutput {
36+
replay?: () => void;
37+
focus?: () => void;
38+
}
39+
40+
type View = (input: ViewInput, output: ViewOutput, target: HTMLElement) => void;
3241

33-
this.element.createChild('div', 'animation-paused fill');
42+
const DEFAULT_VIEW: View = (input, output, target) => {
43+
const classes = classMap({
44+
'animation-buffer-preview': true,
45+
selected: input.isSelected,
46+
paused: input.isPaused,
47+
'no-animation': input.isPreviewAnimationDisabled,
48+
});
3449

35-
if (model.isScrollDriven()) {
36-
this.element.appendChild(IconButton.Icon.create('mouse', 'preview-icon'));
37-
} else {
38-
this.element.appendChild(IconButton.Icon.create('watch', 'preview-icon'));
50+
const handleKeyDown = (event: KeyboardEvent): void => {
51+
switch (event.key) {
52+
case 'Backspace':
53+
case 'Delete':
54+
input.onRemoveAnimationGroup();
55+
break;
56+
case 'ArrowLeft':
57+
case 'ArrowUp':
58+
input.onFocusPreviousGroup();
59+
break;
60+
case 'ArrowRight':
61+
case 'ArrowDown':
62+
input.onFocusNextGroup();
3963
}
64+
};
65+
66+
const renderAnimationLines = (): Lit.LitTemplate => {
67+
const timeToPixelRatio = 100 / Math.max(input.animationGroupDuration, MIN_ANIMATION_GROUP_DURATION);
68+
const viewBox = `0 0 100 ${VIEW_BOX_HEIGHT}`;
69+
const lines = input.animations.map((animation, index) => {
70+
const xStartPoint = animation.delayOrStartTime();
71+
const xEndPoint = xStartPoint + animation.iterationDuration();
72+
const yPoint = Math.floor(VIEW_BOX_HEIGHT / Math.max(6, input.animations.length) * index + 1);
73+
const colorForAnimation = AnimationUI.colorForAnimation(animation);
74+
// clang-format off
75+
return svg`<line
76+
x1="${xStartPoint * timeToPixelRatio}"
77+
x2="${xEndPoint * timeToPixelRatio}"
78+
y1="${yPoint}"
79+
y2="${yPoint}"
80+
style="stroke: ${colorForAnimation}"></line>`;
81+
// clang-format on
82+
});
83+
84+
// clang-format off
85+
return html`
86+
<svg
87+
width="100%"
88+
height="100%"
89+
viewBox=${viewBox}
90+
preserveAspectRatio="none"
91+
shape-rendering="crispEdges">
92+
${lines}
93+
</svg>
94+
`;
95+
// clang-format on
96+
};
97+
98+
// clang-format off
99+
render(html`
100+
<div class="animation-group-preview-ui">
101+
<button
102+
jslog=${VisualLogging.item(`animations.buffer-preview${input.isScrollDrivenAnimationGroup ? '-sda' : ''}`).track({click: true})}
103+
class=${classes}
104+
role="option"
105+
aria-label=${input.label}
106+
tabindex=${input.isFocusable ? 0 : -1}
107+
@mouseover=${{
108+
handleEvent: input.onCreateScreenshotPopover,
109+
once: true,
110+
}}
111+
@keydown=${handleKeyDown}
112+
@click=${input.onSelectAnimationGroup}
113+
@animationend=${input.onPreviewAnimationEnd}
114+
${ref(el => {
115+
if (el instanceof HTMLElement) {
116+
output.focus = () => {
117+
el.focus();
118+
};
119+
}
120+
})}>
121+
<div class="animation-paused fill"></div>
122+
<devtools-icon name=${input.isScrollDrivenAnimationGroup ? 'mouse' : 'watch'} class="preview-icon"></devtools-icon>
123+
<div class="animation-buffer-preview-animation" ${ref(el => {
124+
if (el instanceof HTMLElement) {
125+
output.replay = () => {
126+
el.animate(
127+
[
128+
{offset: 0, width: '0%', opacity: 1},
129+
{offset: 0.9, width: '100%', opacity: 1},
130+
{offset: 1, width: '100%', opacity: 0},
131+
],
132+
{duration: 200, easing: 'cubic-bezier(0, 0, 0.2, 1)'}
133+
);
134+
};
135+
}
136+
})}></div>
137+
${renderAnimationLines()}
138+
</button>
139+
<button
140+
class="animation-remove-button"
141+
jslog=${VisualLogging.action('animations.remove-preview').track({click: true})}
142+
@click=${input.onRemoveAnimationGroup}>
143+
<devtools-icon name="cross"></devtools-icon>
144+
</button>
145+
</div>
146+
`, target, {host: input});
147+
// clang-format on
148+
};
149+
150+
interface AnimationGroupPreviewConfig {
151+
animationGroup: SDK.AnimationModel.AnimationGroup;
152+
label: string;
153+
onRemoveAnimationGroup: () => void;
154+
onSelectAnimationGroup: () => void;
155+
onCreateScreenshotPopover: () => void;
156+
onFocusNextGroup: () => void;
157+
onFocusPreviousGroup: () => void;
158+
}
159+
160+
export class AnimationGroupPreviewUI extends UI.Widget.Widget {
161+
#view: View;
162+
#viewOutput: ViewOutput = {};
163+
#config: AnimationGroupPreviewConfig;
164+
#previewAnimationDisabled = false;
165+
#selected = false;
166+
#paused = false;
167+
#focusable = false;
40168

41-
this.#removeButtonInternal = this.element.createChild('button', 'animation-remove-button');
42-
this.#removeButtonInternal.setAttribute(
43-
'jslog', `${VisualLogging.action('animations.remove-preview').track({click: true})}`);
44-
this.#removeButtonInternal.appendChild(IconButton.Icon.create('cross'));
45-
this.#replayOverlayElement = this.element.createChild('div', 'animation-buffer-preview-animation');
46-
this.#svg = UI.UIUtils.createSVGChild(this.element, 'svg');
47-
this.#svg.setAttribute('width', '100%');
48-
this.#svg.setAttribute('preserveAspectRatio', 'none');
49-
this.#svg.setAttribute('height', '100%');
50-
this.#viewBoxHeight = 32;
51-
this.#svg.setAttribute('viewBox', '0 0 100 ' + this.#viewBoxHeight);
52-
this.#svg.setAttribute('shape-rendering', 'crispEdges');
53-
this.render();
169+
constructor(config: AnimationGroupPreviewConfig, view = DEFAULT_VIEW) {
170+
super();
171+
this.#view = view;
172+
this.#config = config;
173+
this.requestUpdate();
54174
}
55175

56-
removeButton(): Element {
57-
return this.#removeButtonInternal;
176+
setSelected(selected: boolean): void {
177+
if (this.#selected === selected) {
178+
return;
179+
}
180+
181+
this.#selected = selected;
182+
this.requestUpdate();
58183
}
59184

60-
replay(): void {
61-
this.#replayOverlayElement.animate(
62-
[
63-
{offset: 0, width: '0%', opacity: 1},
64-
{offset: 0.9, width: '100%', opacity: 1},
65-
{offset: 1, width: '100%', opacity: 0},
66-
],
67-
{duration: 200, easing: 'cubic-bezier(0, 0, 0.2, 1)'});
185+
setPaused(paused: boolean): void {
186+
if (this.#paused === paused) {
187+
return;
188+
}
189+
190+
this.#paused = paused;
191+
this.requestUpdate();
68192
}
69193

70-
render(): void {
71-
this.#svg.removeChildren();
72-
const maxToShow = 10;
73-
const numberOfAnimations = Math.min(this.#model.animations().length, maxToShow);
74-
const timeToPixelRatio = 100 / Math.max(this.#model.groupDuration(), 750);
75-
for (let i = 0; i < numberOfAnimations; i++) {
76-
const animation = this.#model.animations()[i];
77-
const line = UI.UIUtils.createSVGChild(this.#svg, 'line') as SVGLineElement;
78-
79-
const startPoint = animation.delayOrStartTime();
80-
const endPoint = startPoint + animation.iterationDuration();
81-
82-
line.setAttribute('x1', String(startPoint * timeToPixelRatio));
83-
line.setAttribute('x2', String(endPoint * timeToPixelRatio));
84-
const y = String(Math.floor(this.#viewBoxHeight / Math.max(6, numberOfAnimations) * i + 1));
85-
line.setAttribute('y1', y);
86-
line.setAttribute('y2', y);
87-
line.style.stroke = AnimationUI.colorForAnimation(this.#model.animations()[i]);
194+
setFocusable(focusable: boolean): void {
195+
if (this.#focusable === focusable) {
196+
return;
88197
}
198+
199+
this.#focusable = focusable;
200+
this.requestUpdate();
201+
}
202+
203+
override performUpdate(): void {
204+
this.#view(
205+
{
206+
isScrollDrivenAnimationGroup: this.#config.animationGroup.isScrollDriven(),
207+
isPreviewAnimationDisabled: this.#previewAnimationDisabled,
208+
isSelected: this.#selected,
209+
isPaused: this.#paused,
210+
isFocusable: this.#focusable,
211+
label: this.#config.label,
212+
animationGroupDuration: this.#config.animationGroup.groupDuration(),
213+
animations: this.#config.animationGroup.animations().slice(0, MAX_ANIMATION_LINES_TO_SHOW),
214+
onPreviewAnimationEnd: () => {
215+
this.#previewAnimationDisabled = true;
216+
this.requestUpdate();
217+
},
218+
onRemoveAnimationGroup: () => {
219+
this.#config.onRemoveAnimationGroup();
220+
},
221+
onSelectAnimationGroup: () => {
222+
this.#config.onSelectAnimationGroup();
223+
},
224+
onCreateScreenshotPopover: () => {
225+
this.#config.onCreateScreenshotPopover();
226+
},
227+
onFocusNextGroup: () => {
228+
this.#config.onFocusNextGroup();
229+
},
230+
onFocusPreviousGroup: () => {
231+
this.#config.onFocusPreviousGroup();
232+
}
233+
},
234+
this.#viewOutput, this.contentElement);
235+
}
236+
237+
override focus(): void {
238+
this.#viewOutput.focus?.();
239+
}
240+
241+
replay(): void {
242+
this.#viewOutput.replay?.();
89243
}
90244
}

0 commit comments

Comments
 (0)