Compare commits

...

2 Commits

Author SHA1 Message Date
Shelley Vohr
5c7a639151 docs: note macOS 14+ 2026-04-09 09:24:06 +02:00
Shelley Vohr
c7fb0d30a1 feat: add menuItem.badge support on macOS
Adds a `badge` property to `MenuItem` on macOS, backed by AppKit's `NSMenuItemBadge` API (macOS 14+). The property accepts a `MenuItemBadge` object with a `type` (`alerts`, `updates`, `new-items`, or `none`) and an optional `count` or custom `content` string.

The badge can be set at construction time or updated dynamically after the item has been added to a menu.
2026-04-08 14:46:48 +02:00
11 changed files with 171 additions and 3 deletions

View File

@@ -63,10 +63,17 @@ See [`Menu`](menu.md) for examples.
* `afterGroupContaining` string[] (optional) - Provides a means for a single context menu to declare
the placement of their containing group after the containing group of the item
with the specified id.
* `badge` Object (optional) _macOS_ - Only available on macOS 14 and up.
* `type` string (optional) - Can be one of `alerts`, `updates`, `new-items` or `none`. Default is `none`.
* `count` number (optional) - The number of items the badge displays. Cannot be used with `type: 'none'`.
* `content` string (optional) - A custom string to display in the badge. Only usable with `type: 'none'`.
> [!NOTE]
> `acceleratorWorksWhenHidden` is specified as being macOS-only because accelerators always work when items are hidden on Windows and Linux. The option is exposed to users to give them the option to turn it off, as this is possible in native macOS development.
> [!NOTE]
> If you use one of the predefined badge types on macOS (not 'none'), the system localizes and pluralizes the string for you. If you create your own custom badge string, you need to localize and pluralize that string yourself.
### Instance Properties
The following properties are available on instances of `MenuItem`:
@@ -181,3 +188,9 @@ A `number` indicating an item's sequential unique id.
#### `menuItem.menu`
A [`Menu`](menu.md) that the item is a part of.
#### `menuItem.badge` _macOS_
An [`MenuItemBadge`](structures/menu-item-badge.md) indicating the badge for the menu item.
This property can be dynamically changed. Only available on macOS 14 and up.

View File

@@ -0,0 +1,5 @@
# MenuItemBadge Object
* `type` string (optional) - Can be one of `alerts`, `updates`, `new-items` or `none`. Default is `none`.
* `count` number (optional) - The number of items the badge displays. Cannot be used with `type: 'none'`.
* `content` string (optional) - A custom string to display in the badge. Only usable with `type: 'none'`.

View File

@@ -111,6 +111,7 @@ auto_filenames = {
"docs/api/structures/media-access-permission-request.md",
"docs/api/structures/memory-info.md",
"docs/api/structures/memory-usage-details.md",
"docs/api/structures/menu-item-badge.md",
"docs/api/structures/mime-typed-buffer.md",
"docs/api/structures/mouse-input-event.md",
"docs/api/structures/mouse-wheel-input-event.md",

View File

@@ -38,6 +38,24 @@ const MenuItem = function (this: any, options: any) {
this.overrideProperty('acceleratorWorksWhenHidden', true);
this.overrideProperty('registerAccelerator', roles.shouldRegisterAccelerator(this.role));
if (process.platform === 'darwin') {
let badgeValue = options.badge;
Object.defineProperty(this, 'badge', {
get: () => badgeValue,
set: (newValue) => {
badgeValue = newValue;
// Update native badge if this item is already in a menu
if (this.menu) {
const index = this.menu.getIndexOfCommandId(this.commandId);
if (index !== -1 && badgeValue) {
this.menu.setBadge(index, badgeValue);
}
}
},
enumerable: true
});
}
if (!MenuItem.types.includes(this.type)) {
throw new Error(`Unknown menu item type: ${this.type}`);
}

View File

@@ -176,6 +176,9 @@ Menu.prototype.insert = function (pos, item) {
if (item.type === 'palette' || item.type === 'header') {
this.setCustomType(pos, item.type);
}
if (process.platform === 'darwin' && item.badge) {
this.setBadge(pos, item.badge);
}
// Make menu accessible to items.
item.overrideReadOnlyProperty('menu', this);

View File

@@ -18,7 +18,6 @@
#include "shell/common/gin_converters/gurl_converter.h"
#include "shell/common/gin_converters/image_converter.h"
#include "shell/common/gin_converters/optional_converter.h"
#include "shell/common/gin_helper/dictionary.h"
#include "shell/common/gin_helper/object_template_builder.h"
#include "shell/common/node_includes.h"
#include "ui/base/models/image_model.h"
@@ -28,6 +27,7 @@
namespace gin {
using SharingItem = electron::ElectronMenuModel::SharingItem;
using Badge = electron::ElectronMenuModel::Badge;
template <>
struct Converter<SharingItem> {
@@ -44,6 +44,28 @@ struct Converter<SharingItem> {
}
};
template <>
struct Converter<Badge> {
static bool FromV8(v8::Isolate* isolate,
v8::Local<v8::Value> val,
Badge* out) {
gin_helper::Dictionary dict;
if (!ConvertFromV8(isolate, val, &dict))
return false;
std::string type_str;
if (dict.Get("type", &type_str)) {
out->type = base::UTF8ToUTF16(type_str);
} else {
out->type = u"none";
}
dict.GetOptional("count", &(out->count));
dict.GetOptional("content", &(out->content));
return true;
}
};
} // namespace gin
#endif
@@ -252,6 +274,21 @@ void Menu::SetCustomType(int index, const std::u16string& customType) {
model_->SetCustomType(index, customType);
}
#if BUILDFLAG(IS_MAC)
void Menu::SetBadge(int index, const gin_helper::Dictionary& badge_dict) {
ElectronMenuModel::Badge badge;
std::string type_str;
if (badge_dict.Get("type", &type_str)) {
badge.type = base::UTF8ToUTF16(type_str);
} else {
badge.type = u"none";
}
badge_dict.GetOptional("count", &badge.count);
badge_dict.GetOptional("content", &badge.content);
model_->SetBadge(index, std::move(badge));
}
#endif
void Menu::Clear() {
model_->Clear();
}
@@ -292,8 +329,12 @@ void Menu::FillObjectTemplate(v8::Isolate* isolate,
.SetMethod("setToolTip", &Menu::SetToolTip)
.SetMethod("setRole", &Menu::SetRole)
.SetMethod("setCustomType", &Menu::SetCustomType)
#if BUILDFLAG(IS_MAC)
.SetMethod("setBadge", &Menu::SetBadge)
#endif
.SetMethod("clear", &Menu::Clear)
.SetMethod("getItemCount", &Menu::GetItemCount)
.SetMethod("getIndexOfCommandId", &Menu::GetIndexOfCommandId)
.SetMethod("popupAt", &Menu::PopupAt)
.SetMethod("closePopupAt", &Menu::ClosePopupAt)
.SetMethod("_getAcceleratorTextAt", &Menu::GetAcceleratorTextAtForTesting)

View File

@@ -13,6 +13,7 @@
#include "shell/browser/event_emitter_mixin.h"
#include "shell/browser/ui/electron_menu_model.h"
#include "shell/common/gin_helper/constructible.h"
#include "shell/common/gin_helper/dictionary.h"
#include "shell/common/gin_helper/self_keep_alive.h"
#include "ui/base/mojom/menu_source_type.mojom-forward.h"
@@ -128,6 +129,9 @@ class Menu : public gin::Wrappable<Menu>,
void SetToolTip(int index, const std::u16string& toolTip);
void SetRole(int index, const std::u16string& role);
void SetCustomType(int index, const std::u16string& customType);
#if BUILDFLAG(IS_MAC)
void SetBadge(int index, const gin_helper::Dictionary& badge);
#endif
void Clear();
int GetIndexOfCommandId(int command_id) const;
int GetItemCount() const;

View File

@@ -117,6 +117,30 @@ NSArray* ConvertSharingItemToNS(const SharingItem& item) {
return result;
}
// Convert a Badge to an NSMenuItemBadge.
NSMenuItemBadge* CreateBadge(const electron::ElectronMenuModel::Badge& badge)
API_AVAILABLE(macos(14.0)) {
NSString* badgeType = base::SysUTF16ToNSString(badge.type);
if ([badgeType isEqualToString:@"alerts"]) {
if (badge.count.has_value())
return [NSMenuItemBadge alertsWithCount:badge.count.value()];
} else if ([badgeType isEqualToString:@"updates"]) {
if (badge.count.has_value())
return [NSMenuItemBadge updatesWithCount:badge.count.value()];
} else if ([badgeType isEqualToString:@"new-items"]) {
if (badge.count.has_value())
return [NSMenuItemBadge newItemsWithCount:badge.count.value()];
} else if ([badgeType isEqualToString:@"none"]) {
if (badge.content.has_value()) {
NSString* content = base::SysUTF8ToNSString(badge.content.value());
return [[NSMenuItemBadge alloc] initWithString:content];
}
}
return nil;
}
} // namespace
// This class stores a base::WeakPtr<electron::ElectronMenuModel> as an
@@ -341,14 +365,12 @@ NSArray* ConvertSharingItemToNS(const SharingItem& item) {
electron::ElectronMenuModel::ItemType type = model->GetTypeAt(index);
std::u16string customType = model->GetCustomTypeAt(index);
// The sectionHeaderWithTitle menu item is only available in macOS 14.0+.
if (@available(macOS 14, *)) {
if (customType == u"header") {
item = [NSMenuItem sectionHeaderWithTitle:label];
}
}
// If the menu item has an icon, set it.
ui::ImageModel icon = model->GetIconAt(index);
if (icon.IsImage())
item.image = icon.GetImage().ToNSImage();
@@ -356,6 +378,15 @@ NSArray* ConvertSharingItemToNS(const SharingItem& item) {
std::u16string toolTip = model->GetToolTipAt(index);
item.toolTip = base::SysUTF16ToNSString(toolTip);
if (@available(macOS 14, *)) {
electron::ElectronMenuModel::Badge badge;
if (model->GetBadgeAt(index, &badge)) {
NSMenuItemBadge* nsBadge = CreateBadge(badge);
if (nsBadge)
item.badge = nsBadge;
}
}
if (role == u"services") {
std::u16string title = u"Services";
NSString* sub_label = l10n_util::FixUpWindowsStyleLabel(title);
@@ -517,6 +548,15 @@ NSArray* ConvertSharingItemToNS(const SharingItem& item) {
} else {
item.image = nil;
}
if (@available(macOS 14, *)) {
electron::ElectronMenuModel::Badge badge;
if (model->GetBadgeAt(index, &badge)) {
item.badge = CreateBadge(badge);
} else {
item.badge = nil;
}
}
}
- (void)refreshMenuTree:(NSMenu*)menu {

View File

@@ -12,6 +12,15 @@ namespace electron {
ElectronMenuModel::SharingItem::SharingItem() = default;
ElectronMenuModel::SharingItem::SharingItem(SharingItem&&) = default;
ElectronMenuModel::SharingItem::~SharingItem() = default;
ElectronMenuModel::Badge::Badge() = default;
ElectronMenuModel::Badge::Badge(Badge&&) = default;
ElectronMenuModel::Badge::Badge(const Badge&) = default;
ElectronMenuModel::Badge& ElectronMenuModel::Badge::operator=(const Badge&) =
default;
ElectronMenuModel::Badge& ElectronMenuModel::Badge::operator=(Badge&&) =
default;
ElectronMenuModel::Badge::~Badge() = default;
#endif
bool ElectronMenuModel::Delegate::GetAcceleratorForCommandId(
@@ -115,6 +124,21 @@ bool ElectronMenuModel::GetSharingItemAt(size_t index,
void ElectronMenuModel::SetSharingItem(SharingItem item) {
sharing_item_.emplace(std::move(item));
}
void ElectronMenuModel::SetBadge(size_t index, Badge badge) {
int command_id = GetCommandIdAt(index);
badges_[command_id] = std::move(badge);
}
bool ElectronMenuModel::GetBadgeAt(size_t index, Badge* badge) const {
int command_id = GetCommandIdAt(index);
const auto iter = badges_.find(command_id);
if (iter != badges_.end()) {
*badge = iter->second;
return true;
}
return false;
}
#endif
void ElectronMenuModel::MenuWillClose() {

View File

@@ -33,6 +33,19 @@ class ElectronMenuModel : public ui::SimpleMenuModel {
std::optional<std::vector<GURL>> urls;
std::optional<std::vector<base::FilePath>> file_paths;
};
struct Badge {
Badge();
Badge(Badge&&);
Badge(const Badge&);
Badge& operator=(const Badge&);
Badge& operator=(Badge&&);
~Badge();
std::u16string type; // "alerts", "updates", "new-items", or "none"
std::optional<int> count;
std::optional<std::string> content;
};
#endif
class Delegate : public ui::SimpleMenuModel::Delegate {
@@ -105,6 +118,9 @@ class ElectronMenuModel : public ui::SimpleMenuModel {
return sharing_item_;
}
// Set/Get the Badge of a menu item.
void SetBadge(size_t index, Badge badge);
bool GetBadgeAt(size_t index, Badge* badge) const;
#endif
// ui::SimpleMenuModel:
@@ -123,6 +139,7 @@ class ElectronMenuModel : public ui::SimpleMenuModel {
#if BUILDFLAG(IS_MAC)
std::optional<SharingItem> sharing_item_;
base::flat_map<int, Badge> badges_; // command id -> badge
#endif
base::flat_map<int, std::u16string> toolTips_; // command id -> tooltip

View File

@@ -166,6 +166,7 @@ declare namespace Electron {
commandsMap: Record<string, MenuItem>;
groupsMap: Record<string, MenuItem[]>;
getItemCount(): number;
getIndexOfCommandId(commandId: number): number;
popupAt(window: BaseWindow, frame: WebFrameMain | undefined, x: number, y: number, positioning: number, sourceType: Required<Electron.PopupOptions>['sourceType'], callback: () => void): void;
closePopupAt(id: number): void;
setSublabel(index: number, label: string): void;
@@ -173,6 +174,7 @@ declare namespace Electron {
setIcon(index: number, image: string | NativeImage): void;
setRole(index: number, role: string): void;
setCustomType(index: number, customType: string): void;
setBadge(index: number, badge: MenuItemBadge): void;
insertItem(index: number, commandId: number, label: string): void;
insertCheckItem(index: number, commandId: number, label: string): void;
insertRadioItem(index: number, commandId: number, label: string, groupId: number): void;