Skip to content

Commit c25ce13

Browse files
jackfranklinDevtools-frontend LUCI CQ
authored andcommitted
RPP: further improve keyboard + annotations usage
1. Make it so you can hit enter on a selected event to create an annotation. 2. You can tab from the annotation's input text to the generate AI button and interact with it, without the input disappearing. Fixed: 406539030 Change-Id: I20ffc67aa2493757ce1c16611ba5cefef537707c Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/6400621 Auto-Submit: Jack Franklin <[email protected]> Commit-Queue: Alina Varkki <[email protected]> Reviewed-by: Alina Varkki <[email protected]>
1 parent e44baae commit c25ce13

File tree

5 files changed

+79
-30
lines changed

5 files changed

+79
-30
lines changed

front_end/panels/timeline/TimelineFlameChartView.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,7 @@ export class TimelineFlameChartView extends Common.ObjectWrapper.eventMixin<Even
403403
* NOTE: ENTRY_SELECTED, ENTRY_INVOKED and ENTRY_HOVERED are not always super obvious:
404404
* ENTRY_SELECTED: is KEYBOARD ONLY selection of events (e.g. navigating through the flamechart with your arrow keys)
405405
* ENTRY_HOVERED: is MOUSE ONLY when an event is hovered over with the mouse.
406-
* ENTRY_INVOKED: is when the user cilcks on an event, or hits the "enter" key whilst an event is selected.
406+
* ENTRY_INVOKED: is when the user clicks on an event, or hits the "enter" key whilst an event is selected.
407407
*/
408408
this.onMainEntrySelected = this.onEntrySelected.bind(this, this.mainDataProvider);
409409
this.onNetworkEntrySelected = this.onEntrySelected.bind(this, this.networkDataProvider);

front_end/panels/timeline/overlays/OverlaysImpl.test.ts

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import * as Common from '../../../core/common/common.js';
66
import * as AiAssistanceModels from '../../../models/ai_assistance/ai_assistance.js';
77
import * as Trace from '../../../models/trace/trace.js';
88
import {mockAidaClient} from '../../../testing/AiAssistanceHelpers.js';
9-
import {cleanTextContent, dispatchClickEvent} from '../../../testing/DOMHelpers.js';
9+
import {cleanTextContent, dispatchClickEvent, doubleRaf} from '../../../testing/DOMHelpers.js';
1010
import {describeWithEnvironment, updateHostConfig} from '../../../testing/EnvironmentHelpers.js';
1111
import {
1212
makeInstantEvent,
@@ -668,28 +668,27 @@ describeWithEnvironment('Overlays', () => {
668668
);
669669
});
670670

671-
it('Does not show `generate ai label` button if the label is not empty',
672-
async function() {
673-
updateHostConfig({
674-
aidaAvailability: {
675-
enabled: false,
676-
blockedByAge: true,
677-
blockedByEnterprisePolicy: false,
678-
blockedByGeo: false,
679-
disallowLogging: true,
680-
enterprisePolicyValue: 1,
681-
},
682-
});
671+
it('Does not show `generate ai label` button if the label is not empty', async function() {
672+
updateHostConfig({
673+
aidaAvailability: {
674+
enabled: false,
675+
blockedByAge: true,
676+
blockedByEnterprisePolicy: false,
677+
blockedByGeo: false,
678+
disallowLogging: true,
679+
enterprisePolicyValue: 1,
680+
},
681+
});
683682

684-
const {elementsWrapper, inputField} =
685-
await createAnnotationsLabelElement(this, 'web-dev.json.gz', 50, 'entry label');
686-
assert.strictEqual(inputField?.innerText, 'entry label');
683+
const {elementsWrapper, inputField} =
684+
await createAnnotationsLabelElement(this, 'web-dev.json.gz', 50, 'entry label');
685+
assert.strictEqual(inputField?.innerText, 'entry label');
687686

688-
const aiLabelButtonWrapper =
689-
elementsWrapper.querySelector<HTMLElement>('.ai-label-disabled-button-wrapper') as HTMLSpanElement;
690-
// Button should not exist
691-
assert.isNotOk(aiLabelButtonWrapper);
692-
});
687+
const aiLabelButtonWrapper =
688+
elementsWrapper.querySelector<HTMLElement>('.ai-label-disabled-button-wrapper') as HTMLSpanElement;
689+
// Button should not exist
690+
assert.isNotOk(aiLabelButtonWrapper);
691+
});
693692

694693
it('Shows the `generate ai label` button if the label is empty', async function() {
695694
updateHostConfig({
@@ -905,7 +904,7 @@ describeWithEnvironment('Overlays', () => {
905904
assert.lengthOf(overlays.overlaysOfType('TIME_RANGE'), 2);
906905
});
907906

908-
it('Removes empty label if it is empty when navigated away from (removed focused from)', async function() {
907+
it('removes empty label if it is empty when it loses focus', async function() {
909908
const {inputField, overlays, event} = await createAnnotationsLabelElement(this, 'web-dev.json.gz', 50);
910909

911910
// Double click on the label box to make it editable and focus on it
@@ -916,7 +915,8 @@ describeWithEnvironment('Overlays', () => {
916915

917916
// Change the content to not editable by changing the element blur like when clicking outside of it.
918917
// The label is empty since no initial value was passed into it and no characters were entered.
919-
inputField.dispatchEvent(new FocusEvent('blur', {bubbles: true}));
918+
inputField.dispatchEvent(new FocusEvent('focusout', {bubbles: true}));
919+
await doubleRaf();
920920

921921
// Ensure that the entry overlay has been removed because it was saved empty
922922
assert.lengthOf(overlays.overlaysForEntry(event), 0);
@@ -1135,7 +1135,8 @@ describeWithEnvironment('Overlays', () => {
11351135

11361136
// Make the content to editable by changing the element blur like when clicking outside of it.
11371137
// When that happens, the content should be set to not editable.
1138-
inputField.dispatchEvent(new FocusEvent('blur', {bubbles: true}));
1138+
inputField.dispatchEvent(new FocusEvent('focusout', {bubbles: true}));
1139+
await doubleRaf();
11391140
assert.isFalse(inputField.isContentEditable);
11401141

11411142
// Double click on the label to make it editable again

front_end/panels/timeline/overlays/components/EntryLabelOverlay.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -496,14 +496,18 @@ export class EntryLabelOverlay extends HTMLElement {
496496
// Otherwise, show the fre dialog with a 'Got it' button that turns the setting on.
497497
async #handleAiButtonClick(): Promise<void> {
498498
if (this.#aiAnnotationsEnabledSetting.get()) {
499-
// TODO(b/405354543): handle the loading state here.
500499
if (!this.#callTree || !this.#inputField) {
501500
// Shouldn't happen as we only show the Generate UI when we have this, but this satisfies TS.
502501
return;
503502
}
504503
try {
505504
// Trigger a re-render to display the loading component in the place of the button when the label is being generated.
506505
this.#isAILabelLoading = true;
506+
// Trigger a re-render to put focus back on the input box, otherwise
507+
// when the button changes to a loading spinner, it loses focus and the
508+
// editing state is reset because the component loses focus.
509+
this.#render();
510+
this.#focusInputBox();
507511
void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#boundRender);
508512

509513
this.#label = await this.#performanceAgent.generateAIEntryLabel(this.#callTree);
@@ -512,7 +516,7 @@ export class EntryLabelOverlay extends HTMLElement {
512516

513517
this.#isAILabelLoading = false;
514518
// Trigger a re-render to hide the AI Button and display the generated label.
515-
void ComponentHelpers.ScheduledRender.scheduleRender(this, this.#boundRender);
519+
this.#render();
516520
} catch {
517521
// TODO(b/405354265): handle the error state
518522
}
@@ -723,11 +727,41 @@ export class EntryLabelOverlay extends HTMLElement {
723727
// clang-format on
724728
}
725729

730+
#handleFocusOutEvent(): void {
731+
/**
732+
* Usually when the text box loses focus, we want to stop the edit mode and
733+
* just display the annotation. However, if the user tabs from the text box
734+
* to focus the GenerateAI button, we need to ensure that we do not exit
735+
* edit mode. The only reliable method is to listen to the focusout event
736+
* (which bubbles, unlike `blur`) on the parent.
737+
* This means we get any updates on the focus state of anything inside this component.
738+
* Once we get the event, we check to see if focus is still within this
739+
* component (which means either the input, or the button, or the disclaimer popup).
740+
* If it is, we do nothing, but if we have lost focus, we can then exit editable mode.
741+
*
742+
* If you are thinking "why not `blur` on the span" it's because blur does
743+
* not propagate; the span itself never blurs, but the elements inside it
744+
* do as the span is not focusable.
745+
*
746+
* The reason we do it inside a rAF is because on the first run the values
747+
* for `this.hasFocus()` are not accurate. I'm not quite sure why, but by
748+
* letting the browser have a frame to update, it then accurately reports
749+
* the up to date values for `this.hasFocus()`
750+
*/
751+
requestAnimationFrame(() => {
752+
if (!this.hasFocus()) {
753+
this.setLabelEditabilityAndRemoveEmptyLabel(false);
754+
}
755+
});
756+
}
757+
726758
#render(): void {
727759
// clang-format off
728760
Lit.render(
729761
html`
730-
<span class="label-parts-wrapper" role="region" aria-label=${i18nString(UIStrings.entryLabel)}>
762+
<span class="label-parts-wrapper" role="region" aria-label=${i18nString(UIStrings.entryLabel)}
763+
@focusout=${this.#handleFocusOutEvent}
764+
>
731765
<span
732766
class="label-button-input-wrapper">
733767
<span
@@ -739,7 +773,6 @@ export class EntryLabelOverlay extends HTMLElement {
739773
@dblclick=${() => {
740774
this.setLabelEditabilityAndRemoveEmptyLabel(true);
741775
}}
742-
@blur=${() => this.setLabelEditabilityAndRemoveEmptyLabel(false)}
743776
@keydown=${this.#handleLabelInputKeyDown}
744777
@paste=${this.#handleLabelInputPaste}
745778
@keyup=${() => {

front_end/panels/timeline/overlays/components/entryLabelOverlay.css

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
}
5656

5757
.ai-label-button-wrapper:focus,
58+
.ai-label-button-wrapper:focus-within,
5859
.ai-label-button-wrapper:hover {
5960
width: fit-content;
6061
height: var(--sys-size-13);
@@ -105,7 +106,15 @@
105106
font-weight: var(--ref-typeface-weight-medium);
106107
}
107108

108-
.input-field:focus {
109+
110+
/* When the input field is focused we want to style it as a light background so
111+
* it's clear that the user is in it and can edit the text.
112+
* However we also do this styling when the user's focus is on the GenerateAI
113+
* button (using the :focus-within on the parent). This is so if you open an
114+
* empty annotation, and then tab to the GenerateAI button, the text field
115+
* styling doesn't change. */
116+
.input-field:focus,
117+
.label-parts-wrapper:focus-within .input-field {
109118
background-color: var(--color-background);
110119
color: var(--color-background-inverted);
111120
outline: 2px solid var(--color-background-inverted);

front_end/ui/legacy/components/perf_ui/FlameChart.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1820,6 +1820,12 @@ export class FlameChart extends Common.ObjectWrapper.eventMixin<EventTypes, type
18201820
if (event.key === 'Enter') {
18211821
event.consume(true);
18221822
this.dispatchEventToListeners(Events.ENTRY_INVOKED, this.selectedEntryIndex);
1823+
1824+
// Treat hitting enter on an entry just like we would clicking & create the annotation
1825+
this.dispatchEventToListeners(Events.ENTRY_LABEL_ANNOTATION_ADDED, {
1826+
entryIndex: this.selectedEntryIndex,
1827+
withLinkCreationButton: true,
1828+
});
18231829
return true;
18241830
}
18251831
return false;

0 commit comments

Comments
 (0)