Skip to content

Commit 69127f9

Browse files
feat(elements): animate dot/triangle opening/closing (#3507)
* feat(elements): animate dot/triangle opening/closing * Code review suggestion
1 parent 3ce9a0e commit 69127f9

File tree

7 files changed

+104
-25
lines changed

7 files changed

+104
-25
lines changed

packages/elements/src/components/drawer/drawer.component.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,13 @@ export class MutationTestReportDrawer extends LitElement {
3838

3939
#contentHeightController: ResizeController<number>;
4040

41+
#abortController: AbortController;
42+
4143
constructor() {
4244
super();
4345
this.mode = 'closed';
4446
this.hasDetail = false;
47+
this.#abortController = new AbortController();
4548

4649
this.#headerRef = createRef();
4750
this.#contentHeightController = new ResizeController(this, {
@@ -65,11 +68,11 @@ export class MutationTestReportDrawer extends LitElement {
6568

6669
connectedCallback(): void {
6770
super.connectedCallback();
68-
window.addEventListener('keydown', this.#handleKeyDown);
71+
window.addEventListener('keydown', this.#handleKeyDown, { signal: this.#abortController.signal });
6972
}
7073

7174
disconnectedCallback(): void {
72-
window.removeEventListener('keydown', this.#handleKeyDown);
75+
this.#abortController.abort();
7376
super.disconnectedCallback();
7477
}
7578

packages/elements/src/components/file/file.component.ts

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { prismjs, tailwind } from '../../style/index.js';
1313
import { RealTimeElement } from '../real-time-element.js';
1414
import type { StateFilter } from '../state-filter/state-filter.component.js';
1515
import style from './file.scss?inline';
16-
import { renderDots, renderLine } from './util.js';
16+
import { beginElementAnimation, circle, renderDots, renderLine, triangle } from './util.js';
1717

1818
const diffOldClass = 'diff-old';
1919
const diffNewClass = 'diff-new';
@@ -39,6 +39,8 @@ export class FileComponent extends RealTimeElement {
3939
@state()
4040
public declare mutants: MutantModel[];
4141

42+
#abortController: AbortController;
43+
4244
private codeRef = createRef<HTMLElement>();
4345

4446
public constructor() {
@@ -47,8 +49,25 @@ export class FileComponent extends RealTimeElement {
4749
this.selectedMutantStates = [];
4850
this.lines = [];
4951
this.mutants = [];
52+
this.#abortController = new AbortController();
53+
}
54+
55+
connectedCallback(): void {
56+
super.connectedCallback();
57+
window.addEventListener('keydown', this.#handleKeyDown, { signal: this.#abortController.signal });
5058
}
5159

60+
disconnectedCallback(): void {
61+
this.#abortController.abort();
62+
super.disconnectedCallback();
63+
}
64+
65+
#handleKeyDown = (event: KeyboardEvent) => {
66+
if (event.key === 'Escape' && this.selectedMutant) {
67+
this.toggleMutant(this.selectedMutant);
68+
}
69+
};
70+
5271
private readonly filtersChanged = (event: MteCustomEvent<'filters-changed'>) => {
5372
// Pending is not filterable, but they should still be shown to the user.
5473
this.selectedMutantStates = (event.detail as MutantStatus[]).concat(['Pending']);
@@ -139,25 +158,25 @@ export class FileComponent extends RealTimeElement {
139158
(mutant) =>
140159
svg`<svg mutant-id="${mutant.id}" class="mutant-dot ${this.selectedMutant?.id === mutant.id ? 'selected' : ''} ${mutant.status}" height="10" width="12">
141160
<title>${title(mutant)}</title>
142-
${
143-
this.selectedMutant?.id === mutant.id
144-
? // Triangle pointing down
145-
svg`<path class="stroke-gray-800" d="M5,10 L0,0 L10,0 Z" />`
146-
: // Circle
147-
svg`<circle cx="5" cy="5" r="5" />`
148-
}
149-
</svg>`,
161+
${this.selectedMutant?.id === mutant.id ? triangle : circle}
162+
</svg>`,
150163
)
151164
: nothing;
152165
}
153166

154167
private toggleMutant(mutant: MutantModel) {
155168
this.removeCurrentDiff();
156169

170+
// Animate (de)selection
171+
this.#animateMutantToggle(mutant);
172+
157173
if (this.selectedMutant === mutant) {
158174
this.selectedMutant = undefined;
159175
this.dispatchEvent(createCustomEvent('mutant-selected', { selected: false, mutant }));
160176
return;
177+
} else if (this.selectedMutant) {
178+
// Animate old selected mutant
179+
this.#animateMutantToggle(this.selectedMutant);
161180
}
162181

163182
this.selectedMutant = mutant;
@@ -274,6 +293,10 @@ export class FileComponent extends RealTimeElement {
274293
const lineEnd = '</td></tr>';
275294
return lines.map((line) => `${lineStart}${line}${lineEnd}`).join('');
276295
}
296+
297+
#animateMutantToggle(mutant: MutantModel) {
298+
beginElementAnimation(this.codeRef.value, 'mutant-id', mutant.id);
299+
}
277300
}
278301

279302
function title(mutant: MutantModel): string {

packages/elements/src/components/file/file.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ $mutant-squiggly: 'Survived', 'NoCoverage';
5757
}
5858

5959
.mutant-dot {
60-
@apply m-0.5 cursor-pointer;
60+
@apply mx-0.5 cursor-pointer;
6161
}
6262

6363
.diff-old {

packages/elements/src/components/file/util.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { TemplateResult } from 'lit';
2-
import { html, nothing } from 'lit';
2+
import { html, nothing, svg } from 'lit';
33
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
44

55
export function renderDots(dots: typeof nothing | TemplateResult[], finalDots: typeof nothing | TemplateResult[]) {
@@ -15,3 +15,26 @@ export function renderLine(line: string, dots: TemplateResult | typeof nothing)
1515
><td class="line-number"></td><td class="line-marker"></td><td class="code flex"><span>${unsafeHTML(line)}</span>${dots}</td></tr
1616
>`;
1717
}
18+
19+
// To edit, I recommend opening the SVG in a tool like Inkscape
20+
const circleSvgPath = 'M 0,5 C 0,-1.66 10,-1.66 10,5 10,7.76 7.76,10 5,10 2.24,10 0,7.76 0,5 Z';
21+
// Triangle with curve paths of 0, so it has the same number of path elements as the circle, which is needed for animation
22+
const triangleSvgPath = 'M 0,0 C 0,0 10,0 10,0 10,0 5,10 5,10 5,10 0,0 0,0 Z';
23+
// Fancy cubic bezier curve for the animation. Same as `transition-*` in tailwind
24+
const animationCurve = '0.4 0 0.2 1';
25+
26+
const pathF = (from: string, to: string, strokeOpacity: number) =>
27+
svg`<path stroke-opacity="${strokeOpacity}" class="transition-stroke-opacity stroke-gray-800" d="${to}">
28+
<animate values="${from};${to}" attributeName="d" dur="0.2s" begin="indefinite" calcMode="spline" keySplines="${animationCurve}" />
29+
</path>`;
30+
31+
export const triangle = pathF(circleSvgPath, triangleSvgPath, 1);
32+
export const circle = pathF(triangleSvgPath, circleSvgPath, 0);
33+
34+
/**
35+
* Animate a svg element that has a path.animate child
36+
*/
37+
export function beginElementAnimation(root: ParentNode | undefined, prop: string, value: string) {
38+
const el = root?.querySelector<SVGAnimateElement>(`[${prop}="${encodeURIComponent(value)}"] path animate`);
39+
el?.beginElement();
40+
}

packages/elements/src/components/test-file/test-file.component.ts

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { createCustomEvent } from '../../lib/custom-events.js';
1414
import { getContextClassForTestStatus, getEmojiForTestStatus, scrollToCodeFragmentIfNeeded } from '../../lib/html-helpers.js';
1515
import { prismjs, tailwind } from '../../style/index.js';
1616
import '../../style/prism-plugins';
17-
import { renderDots, renderLine } from '../file/util.js';
17+
import { beginElementAnimation, circle, renderDots, renderLine, triangle } from '../file/util.js';
1818
import { RealTimeElement } from '../real-time-element.js';
1919
import type { StateFilter } from '../state-filter/state-filter.component.js';
2020

@@ -40,14 +40,33 @@ export class TestFileComponent extends RealTimeElement {
4040
@state()
4141
private declare tests: TestModel[];
4242

43+
#abortController: AbortController;
44+
4345
constructor() {
4446
super();
4547
this.filters = [];
4648
this.lines = [];
4749
this.enabledStates = [];
4850
this.tests = [];
51+
this.#abortController = new AbortController();
52+
}
53+
54+
connectedCallback(): void {
55+
super.connectedCallback();
56+
window.addEventListener('keydown', this.#handleKeyDown, { signal: this.#abortController.signal });
57+
}
58+
59+
disconnectedCallback(): void {
60+
this.#abortController.abort();
61+
super.disconnectedCallback();
4962
}
5063

64+
#handleKeyDown = (event: KeyboardEvent) => {
65+
if (event.key === 'Escape') {
66+
this.#deselectTest();
67+
}
68+
};
69+
5170
private readonly filtersChanged = (event: MteCustomEvent<'filters-changed'>) => {
5271
this.enabledStates = event.detail as TestStatus[];
5372
if (this.selectedTest && !this.enabledStates.includes(this.selectedTest.status)) {
@@ -56,10 +75,14 @@ export class TestFileComponent extends RealTimeElement {
5675
};
5776

5877
private toggleTest(test: TestModel) {
78+
this.#animateTestToggle(test);
5979
if (this.selectedTest === test) {
6080
this.selectedTest = undefined;
6181
this.dispatchEvent(createCustomEvent('test-selected', { selected: false, test }));
6282
} else {
83+
if (this.selectedTest) {
84+
this.#animateTestToggle(this.selectedTest);
85+
}
6386
this.selectedTest = test;
6487
this.dispatchEvent(createCustomEvent('test-selected', { selected: true, test }));
6588
scrollToCodeFragmentIfNeeded(this.shadowRoot!.querySelector(`[test-id="${test.id}"]`));
@@ -140,9 +163,11 @@ export class TestFileComponent extends RealTimeElement {
140163
return this.renderTestDots([...testsByLine.entries()].filter(([line]) => line > lastLine).flatMap(([, tests]) => tests));
141164
};
142165

143-
return html`<pre id="report-code-block" class="line-numbers flex rounded-md p-1"><code class="flex language-${determineLanguage(
144-
this.model.name,
145-
)}">
166+
return html`<pre
167+
id="report-code-block"
168+
@click="${this.#deselectTest}"
169+
class="line-numbers flex rounded-md p-1"
170+
><code class="flex language-${determineLanguage(this.model.name)}">
146171
<table>
147172
${map(this.lines, (line, lineIndex) => {
148173
const lineNr = lineIndex + 1;
@@ -155,6 +180,12 @@ export class TestFileComponent extends RealTimeElement {
155180
return nothing;
156181
}
157182

183+
#deselectTest = () => {
184+
if (this.selectedTest) {
185+
this.toggleTest(this.selectedTest);
186+
}
187+
};
188+
158189
private renderTestDots(tests: TestModel[] | undefined) {
159190
return tests?.length
160191
? tests.map(
@@ -170,13 +201,7 @@ export class TestFileComponent extends RealTimeElement {
170201
width="12"
171202
>
172203
<title>${title(test)}</title>
173-
${
174-
this.selectedTest === test
175-
? // Triangle pointing down
176-
svg`<path class="stroke-gray-800" d="M5,10 L0,0 L10,0 Z" />`
177-
: // Circle
178-
svg`<circle cx="5" cy="5" r="5" />`
179-
}
204+
${this.selectedTest === test ? triangle : circle}
180205
</svg>`,
181206
)
182207
: nothing;
@@ -226,6 +251,10 @@ export class TestFileComponent extends RealTimeElement {
226251
this.lines = transformHighlightedLines(highlightCode(this.model.source, this.model.name));
227252
}
228253
}
254+
255+
#animateTestToggle(test: TestModel) {
256+
beginElementAnimation(this.shadowRoot!, 'test-id', test.id);
257+
}
229258
}
230259
function title(test: TestModel): string {
231260
return `${test.name} (${test.status})`;

packages/elements/src/components/test-file/test-file.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,5 @@ $test-themes: (
1313
}
1414

1515
.test-dot {
16-
@apply m-0.5 cursor-pointer;
16+
@apply mx-0.5 cursor-pointer;
1717
}

packages/elements/tailwind.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ const config: Config = {
6565
'max-width': 'max-width',
6666
height: 'height',
6767
width: 'width',
68+
'stroke-opacity': 'stroke-opacity',
6869
},
6970
},
7071
},

0 commit comments

Comments
 (0)