Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 67 additions & 1 deletion ui/src/base/mithril_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ export class Gate implements m.ClassComponent<GateAttrs> {
return m(
'',
{
style: {display: attrs.open ? 'contents' : 'none'},
'data-gate-open': String(attrs.open),
'style': {display: attrs.open ? 'contents' : 'none'},
},
this.renderChildren(attrs.open, children),
);
Expand All @@ -83,6 +84,71 @@ export class Gate implements m.ClassComponent<GateAttrs> {
}
}

export interface GateDetectorAttrs {
onVisibilityChanged: (visible: boolean, dom: Element) => void;
}

// A component that detects visibility changes from an ancestor Gate component.
// Place this inside a Gate's subtree to receive callbacks when the Gate opens
// or closes. Uses MutationObserver to watch for changes to the Gate's
// data-gate-open attribute.
//
// Usage:
// view() {
// return m(GateDetector, {
// onVisibilityChanged: (visible, dom) => {
// console.log('Gate is now visible:', visible);
// }
// }, m(...));
// }
export class GateDetector implements m.ClassComponent<GateDetectorAttrs> {
private observer?: MutationObserver;
private gateElement?: HTMLElement;
private dom?: Element;
private wasVisible?: boolean;
private callback?: (visible: boolean, dom: Element) => void;

view({children}: m.Vnode<GateDetectorAttrs>): m.Children {
return children;
}

oncreate(vnode: m.VnodeDOM<GateDetectorAttrs>) {
this.callback = vnode.attrs.onVisibilityChanged;
this.dom = vnode.dom;

// Find closest Gate wrapper using data attribute
const gateElem = this.dom.closest('[data-gate-open]') ?? undefined;
this.gateElement = gateElem as HTMLElement | undefined;
if (!this.gateElement) return;

this.observer = new MutationObserver(this.checkVisibility.bind(this));
this.observer.observe(this.gateElement, {
attributes: true,
attributeFilter: ['data-gate-open'],
});

// Fire initial state
this.checkVisibility();
}

onupdate(vnode: m.VnodeDOM<GateDetectorAttrs>) {
this.callback = vnode.attrs.onVisibilityChanged;
}

private checkVisibility() {
if (!this.gateElement || !this.dom) return;
const visible = this.gateElement.dataset.gateOpen === 'true';
if (visible !== this.wasVisible) {
this.wasVisible = visible;
this.callback?.(visible, this.dom);
}
}

onremove() {
this.observer?.disconnect();
}
}

export type MithrilEvent<T extends Event = Event> = T & {redraw: boolean};

// Check if a mithril children is empty (falsy or empty array). If
Expand Down
26 changes: 25 additions & 1 deletion ui/src/core_plugins/dev.perfetto.FlagsPage/flags_page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ import {Intent} from '../../widgets/common';
import {Anchor} from '../../widgets/anchor';
import {Popup} from '../../widgets/popup';
import {Box} from '../../widgets/box';
import {GateDetector} from '../../base/mithril_utils';
import {findRef} from '../../base/dom_utils';

const SEARCH_BOX_REF = 'flags-search-box';

const RELEASE_PROCESS_URL =
'https://perfetto.dev/docs/visualization/perfetto-ui-release-process';
Expand Down Expand Up @@ -159,7 +163,7 @@ export class FlagsPage implements m.ClassComponent<FlagsPageAttrs> {

const subpage = decodeURIComponent(attrs.subpage ?? '');

return m(
const page = m(
SettingsShell,
{
stickyHeaderContent: m(
Expand Down Expand Up @@ -209,6 +213,7 @@ export class FlagsPage implements m.ClassComponent<FlagsPageAttrs> {
m(StackAuto),
m(TextInput, {
placeholder: 'Search...',
ref: SEARCH_BOX_REF,
value: this.filterText,
leftIcon: 'search',
oninput: (e: Event) => {
Expand Down Expand Up @@ -280,5 +285,24 @@ export class FlagsPage implements m.ClassComponent<FlagsPageAttrs> {
),
),
);

return m(
GateDetector,
{
onVisibilityChanged: (visible: boolean, dom: Element) => {
if (visible) {
const input = findRef(
dom,
SEARCH_BOX_REF,
) as HTMLInputElement | null;
if (input) {
input.focus();
input.select();
}
}
},
},
page,
);
}
}
27 changes: 26 additions & 1 deletion ui/src/core_plugins/dev.perfetto.FlagsPage/plugins_page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ import {EmptyState} from '../../widgets/empty_state';
import {Popup} from '../../widgets/popup';
import {Box} from '../../widgets/box';
import {Icons} from '../../base/semantic_icons';
import {GateDetector} from '../../base/mithril_utils';
import {findRef} from '../../base/dom_utils';

const SEARCH_BOX_REF = 'plugin-search-box';

enum SortOrder {
Name = 'name',
Expand Down Expand Up @@ -110,7 +114,7 @@ export class PluginsPage implements m.ClassComponent<PluginsPageAttrs> {
: sorted.map((item) => ({item, segments: []}));
const subpage = decodeURIComponent(attrs.subpage ?? '');

return m(
const page = m(
SettingsShell,
{
title: 'Plugins',
Expand Down Expand Up @@ -184,6 +188,7 @@ export class PluginsPage implements m.ClassComponent<PluginsPageAttrs> {
),
m(TextInput, {
placeholder: 'Search...',
ref: SEARCH_BOX_REF,
value: this.filterText,
leftIcon: 'search',
oninput: (e: Event) => {
Expand All @@ -208,6 +213,26 @@ export class PluginsPage implements m.ClassComponent<PluginsPageAttrs> {
: this.renderEmptyState(isFiltering),
),
);

return m(
GateDetector,
{
onVisibilityChanged: (visible: boolean, dom: Element) => {
if (visible) {
// Focus the search input
const input = findRef(
dom,
SEARCH_BOX_REF,
) as HTMLInputElement | null;
if (input) {
input.focus();
input.select();
}
}
},
},
page,
);
}

private renderEmptyState(isFiltering: boolean) {
Expand Down
26 changes: 25 additions & 1 deletion ui/src/core_plugins/dev.perfetto.SettingsPage/settings_page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ import {FuzzyFinder, FuzzySegment} from '../../base/fuzzy';
import {Popup} from '../../widgets/popup';
import {Box} from '../../widgets/box';
import {Icons} from '../../base/semantic_icons';
import {GateDetector} from '../../base/mithril_utils';
import {findRef} from '../../base/dom_utils';

const SEARCH_BOX_REF = 'settings-search-box';

export interface SettingsPageAttrs {
readonly subpage?: string;
Expand Down Expand Up @@ -59,7 +63,7 @@ export class SettingsPage implements m.ClassComponent<SettingsPageAttrs> {
return a.localeCompare(b);
});

return m(
const page = m(
SettingsShell,
{
title: 'Settings',
Expand Down Expand Up @@ -111,6 +115,7 @@ export class SettingsPage implements m.ClassComponent<SettingsPageAttrs> {
m(StackAuto),
m(TextInput, {
placeholder: 'Search...',
ref: SEARCH_BOX_REF,
value: this.filterText,
leftIcon: 'search',
oninput: (e: Event) => {
Expand All @@ -130,6 +135,25 @@ export class SettingsPage implements m.ClassComponent<SettingsPageAttrs> {
}),
),
);

return m(
GateDetector,
{
onVisibilityChanged: (visible: boolean, dom: Element) => {
if (visible) {
const input = findRef(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: would this be worth a common helper on GateDetector or so?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I follow you here..?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean something like GateDetector.focusRef(...) so we don't have to repeat the boilerplate code.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ack re the boilerplate code. But focusing text inputs by ref is unrelated to GateDetector.

I could get behind a separate utility like focusInputByRef(...) perhaps?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SGTM, definitely just nice-to have :)

dom,
SEARCH_BOX_REF,
) as HTMLInputElement | null;
if (input) {
input.focus();
input.select();
}
}
},
},
page,
);
}

private getAllSettingsGrouped(settingsManager: SettingsManagerImpl) {
Expand Down