diff --git a/ui/src/base/mithril_utils.ts b/ui/src/base/mithril_utils.ts index 155cfc1de60..737759bf1d7 100644 --- a/ui/src/base/mithril_utils.ts +++ b/ui/src/base/mithril_utils.ts @@ -59,7 +59,8 @@ export class Gate implements m.ClassComponent { 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), ); @@ -83,6 +84,71 @@ export class Gate implements m.ClassComponent { } } +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 { + private observer?: MutationObserver; + private gateElement?: HTMLElement; + private dom?: Element; + private wasVisible?: boolean; + private callback?: (visible: boolean, dom: Element) => void; + + view({children}: m.Vnode): m.Children { + return children; + } + + oncreate(vnode: m.VnodeDOM) { + 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) { + 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 & {redraw: boolean}; // Check if a mithril children is empty (falsy or empty array). If diff --git a/ui/src/core_plugins/dev.perfetto.FlagsPage/flags_page.ts b/ui/src/core_plugins/dev.perfetto.FlagsPage/flags_page.ts index d0ed619201e..2e4ffad82c0 100644 --- a/ui/src/core_plugins/dev.perfetto.FlagsPage/flags_page.ts +++ b/ui/src/core_plugins/dev.perfetto.FlagsPage/flags_page.ts @@ -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'; @@ -159,7 +163,7 @@ export class FlagsPage implements m.ClassComponent { const subpage = decodeURIComponent(attrs.subpage ?? ''); - return m( + const page = m( SettingsShell, { stickyHeaderContent: m( @@ -209,6 +213,7 @@ export class FlagsPage implements m.ClassComponent { m(StackAuto), m(TextInput, { placeholder: 'Search...', + ref: SEARCH_BOX_REF, value: this.filterText, leftIcon: 'search', oninput: (e: Event) => { @@ -280,5 +285,24 @@ export class FlagsPage implements m.ClassComponent { ), ), ); + + 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, + ); } } diff --git a/ui/src/core_plugins/dev.perfetto.FlagsPage/plugins_page.ts b/ui/src/core_plugins/dev.perfetto.FlagsPage/plugins_page.ts index e64b7d7e646..0274b2a7781 100644 --- a/ui/src/core_plugins/dev.perfetto.FlagsPage/plugins_page.ts +++ b/ui/src/core_plugins/dev.perfetto.FlagsPage/plugins_page.ts @@ -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', @@ -110,7 +114,7 @@ export class PluginsPage implements m.ClassComponent { : sorted.map((item) => ({item, segments: []})); const subpage = decodeURIComponent(attrs.subpage ?? ''); - return m( + const page = m( SettingsShell, { title: 'Plugins', @@ -184,6 +188,7 @@ export class PluginsPage implements m.ClassComponent { ), m(TextInput, { placeholder: 'Search...', + ref: SEARCH_BOX_REF, value: this.filterText, leftIcon: 'search', oninput: (e: Event) => { @@ -208,6 +213,26 @@ export class PluginsPage implements m.ClassComponent { : 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) { diff --git a/ui/src/core_plugins/dev.perfetto.SettingsPage/settings_page.ts b/ui/src/core_plugins/dev.perfetto.SettingsPage/settings_page.ts index 4986c72cbfe..99a5e8fa774 100644 --- a/ui/src/core_plugins/dev.perfetto.SettingsPage/settings_page.ts +++ b/ui/src/core_plugins/dev.perfetto.SettingsPage/settings_page.ts @@ -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; @@ -59,7 +63,7 @@ export class SettingsPage implements m.ClassComponent { return a.localeCompare(b); }); - return m( + const page = m( SettingsShell, { title: 'Settings', @@ -111,6 +115,7 @@ export class SettingsPage implements m.ClassComponent { m(StackAuto), m(TextInput, { placeholder: 'Search...', + ref: SEARCH_BOX_REF, value: this.filterText, leftIcon: 'search', oninput: (e: Event) => { @@ -130,6 +135,25 @@ export class SettingsPage implements m.ClassComponent { }), ), ); + + 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, + ); } private getAllSettingsGrouped(settingsManager: SettingsManagerImpl) {