blob: d252d874c8ba58fc5d6b6798da373e54d2256e7c [file]
// Copyright (C) 2026 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import m from 'mithril';
import {classNames} from '../../base/classnames';
import {formatHotkey} from '../../base/hotkeys';
import {Icons} from '../../base/semantic_icons';
import {exists} from '../../base/utils';
import type {AppImpl} from '../../core/app_impl';
import {raf} from '../../core/raf_scheduler';
import {Router} from '../../core/router';
import type {SidebarMenuItemInternal} from '../../core/sidebar_manager';
import type {Command} from '../../public/commands';
import {Icon} from '../../widgets/icon';
import {Spinner} from '../../widgets/spinner';
export interface SidebarItemAttrs {
readonly app: AppImpl;
readonly item: SidebarMenuItemInternal;
}
export class SidebarItem implements m.ClassComponent<SidebarItemAttrs> {
private pending = false;
view({attrs}: m.CVnode<SidebarItemAttrs>): m.Children {
const item = attrs.item;
let href = '#';
let disabled = false;
let target = null;
let isActive = false;
let command: Command | undefined = undefined;
let tooltip = valueOrCallback(item.tooltip);
let onclick: (() => unknown | Promise<unknown>) | undefined = undefined;
const commandId = 'commandId' in item ? item.commandId : undefined;
const action = 'action' in item ? item.action : undefined;
let text = valueOrCallback(item.text);
const disabReason: boolean | string | undefined = valueOrCallback(
item.disabled,
);
if (disabReason === true || typeof disabReason === 'string') {
disabled = true;
onclick = () => typeof disabReason === 'string' && alert(disabReason);
} else if (action !== undefined) {
onclick = action;
} else if (commandId !== undefined) {
const app = attrs.app;
const cmdMgr = (app.trace ?? app).commands;
command = cmdMgr.hasCommand(commandId ?? '')
? cmdMgr.getCommand(commandId)
: undefined;
if (command === undefined) {
disabled = true;
} else {
text = text !== undefined ? text : command.name;
if (command.defaultHotkey !== undefined) {
tooltip =
`${tooltip ?? command.name}` +
` [${formatHotkey(command.defaultHotkey)}]`;
}
onclick = () => cmdMgr.runCommand(commandId);
}
}
if ('href' in item && item.href !== undefined) {
href = item.href;
target = href.startsWith('#') ? null : '_blank';
isActive = pageMatchesHref(href);
}
const isLink = href !== '#';
const iconEl =
exists(item.icon) &&
m(Icon, {
className: 'pf-sidebar__button-icon',
icon: valueOrCallback(item.icon),
});
const spinnerEl =
this.pending && m(Spinner, {className: 'pf-sidebar__spinner'});
const cssClass = valueOrCallback(item.cssClass);
return m(
'li.pf-sidebar__item',
{
key: item.id, // This is to work around a mithril bug (b/449784590).
className: classNames(isActive && 'pf-active'),
},
isLink
? m(
'a',
{
className: cssClass,
onclick: onclick && this.wrapClickHandler(onclick),
href,
target,
disabled,
title: tooltip,
},
iconEl,
text,
target === '_blank' &&
m(Icon, {
className: 'pf-sidebar__external-link-icon',
icon: Icons.ExternalLink,
}),
spinnerEl,
)
: m(
'button',
{
className: cssClass,
onclick: onclick && this.wrapClickHandler(onclick),
disabled,
title: tooltip,
},
iconEl,
text,
spinnerEl,
),
);
}
// Wraps the onClick for items that provided an `action`. The action can be
// sync or async; for async actions we render a spinner until it resolves and
// also debounce re-clicks. We always preventDefault to neutralise the
// accessibility-driven `<a href="#">`.
private wrapClickHandler(itemAction: Function) {
return (e: Event) => {
e.preventDefault();
const res = itemAction();
if (!(res instanceof Promise)) return;
if (this.pending) return; // Don't queue another action while one runs.
this.pending = true;
res.finally(() => {
this.pending = false;
raf.scheduleFullRedraw();
});
};
}
}
export function pageMatchesHref(href: string): boolean {
if (!href.startsWith('#!')) return false;
const currentHash = window.location.hash;
if (currentHash.length > 0 && !currentHash.startsWith('#!')) return false;
const currentPage = Router.getCurrentRoute().page;
const hrefPage = Router.parseFragment(href).page;
return hrefPage === currentPage;
}
// Used to deal with fields like the entry name, which can be either a direct
// string or a callback that returns the string.
function valueOrCallback<T>(value: T | (() => T)): T;
function valueOrCallback<T>(value: T | (() => T) | undefined): T | undefined;
function valueOrCallback<T>(value: T | (() => T) | undefined): T | undefined {
if (value === undefined) return undefined;
return value instanceof Function ? value() : value;
}