add dark theme support for puter.ui.contextMenu and puter.ui.setMenubar (works only with puter.env === 'web')

This commit is contained in:
Miika Kuisma
2026-06-24 20:47:17 +03:00
parent d0a292e2aa
commit b01cd3bfa3
6 changed files with 173 additions and 1 deletions
+8
View File
@@ -1096,6 +1096,10 @@ class UI extends EventListener {
// Replace any existing menubar
document.querySelectorAll('puter-menubar').forEach(el => el.remove());
const el = document.createElement('puter-menubar');
// Forward an explicit theme ('dark' | 'light') to the web component;
// unset → the component follows the system preference. The component
// also forwards this to the dropdowns it spawns. (env=web only.)
if ( spec.theme ) el.setAttribute('theme', spec.theme);
el.items = spec.items || [];
document.body.appendChild(el);
};
@@ -1137,6 +1141,10 @@ class UI extends EventListener {
}
// Standalone fallback: render web component
const el = document.createElement('puter-context-menu');
// Forward an explicit theme ('dark' | 'light') to the web component;
// unset → the component follows the system preference. The component
// also forwards this to any submenus it spawns. (env=web only.)
if ( spec.theme ) el.setAttribute('theme', spec.theme);
el.items = spec.items || [];
// Use mouse position or provided position
const x = spec.x ?? (globalThis.event?.clientX ?? 0);
+9
View File
@@ -360,6 +360,15 @@ To force a specific theme regardless of the OS preference, set the `theme` attri
The attribute is live — changing it at runtime re-paints the component immediately. For `<puter-menubar>` and `<puter-context-menu>`, the `theme` is forwarded to any dropdowns and submenus they spawn, so the whole tree stays in sync.
When you use the imperative API instead of the elements directly, pass a `theme` in the options and it is applied as the `theme` attribute on the rendered component:
```js
puter.ui.setMenubar({ theme: 'dark', items: [ /* … */ ] });
puter.ui.contextMenu({ theme: 'dark', items: [ /* … */ ] });
```
This only takes effect when running standalone (`puter.env === 'web'`). Inside the Puter desktop (`puter.env === 'app'`) the spec is handled by the desktop and the `theme` option is ignored.
## Responsive / mobile
All components have mobile breakpoints at `@media (max-width: 480px)`. Notable behaviors:
+67 -1
View File
@@ -78,6 +78,7 @@
<h2>Context Menu</h2>
<button oncontextmenu="testContextMenu(event); return false;">Right-click me</button>
<button onclick="testContextMenuClick(event)">Click for Context Menu</button>
<button oncontextmenu="testContextMenuDark(event); return false;">Right-click me for dark menu</button>
<div class="result" id="menu-result">Right-click or click a button to test...</div>
</div>
@@ -89,6 +90,7 @@
<div class="section">
<h2>Menubar</h2>
<button onclick="testMenubar()">Show Menubar</button>
<button onclick="testMenubarDark()">Show Dark Menubar</button>
<button onclick="hideMenubar()">Hide Menubar</button>
<div class="result" id="menubar-result">Click "Show Menubar" to add an app menubar at top of page...</div>
</div>
@@ -107,7 +109,7 @@
<div class="result" id="font-result">Click to test...</div>
</div>
<script src="/dist/puter.js"></script>
<script src="/dist/puter.dev.js"></script>
<script>
async function testAlert() {
const result = await puter.ui.alert('Hello from standalone Puter!');
@@ -217,6 +219,31 @@
});
}
function testContextMenuDark(e) {
puter.ui.contextMenu({
x: e.clientX,
y: e.clientY,
theme: 'dark',
items: [
{ label: 'Get Info', icon: ICONS.info, action: () => log('menu', 'Get Info') },
'-',
{ label: 'Play Next', icon: ICONS.play, action: () => log('menu', 'Play Next') },
{ label: 'Add to Queue', icon: ICONS.queue, action: () => log('menu', 'Add to Queue') },
'-',
{ label: 'Go to Artist', icon: ICONS.user, action: () => log('menu', 'Go to Artist') },
{ label: 'Go to Album', icon: ICONS.disc, action: () => log('menu', 'Go to Album') },
'-',
{ label: 'Add to Playlist', icon: ICONS.plus, items: [
{ label: 'Favorites', action: () => log('menu', 'Add to Favorites') },
{ label: 'Workout Mix', action: () => log('menu', 'Add to Workout Mix') },
{ label: 'Chill Vibes', action: () => log('menu', 'Add to Chill Vibes') },
]},
{ label: 'Download', icon: ICONS.download, action: () => log('menu', 'Download') },
{ label: 'Delete', icon: ICONS.trash, type: 'danger', action: () => log('menu', 'Delete') },
],
});
}
function testContextMenuClick(e) {
puter.ui.contextMenu({
x: e.clientX,
@@ -275,6 +302,45 @@
document.getElementById('menubar-result').textContent = 'Menubar shown at top of page';
}
function testMenubarDark() {
puter.ui.setMenubar({
theme: 'dark',
items: [
{ label: 'File', items: [
{ label: 'New', icon: ICONS.plus, shortcut: '\u2318N', action: () => log('menubar', 'New') },
{ label: 'Open\u2026', shortcut: '\u2318O', action: () => log('menubar', 'Open') },
'-',
{ label: 'Save', shortcut: '\u2318S', action: () => log('menubar', 'Save') },
{ label: 'Save As\u2026', shortcut: '\u21E7\u2318S', action: () => log('menubar', 'Save As') },
'-',
{ label: 'Close Window', shortcut: '\u2318W', action: () => log('menubar', 'Close') },
]},
{ label: 'Edit', items: [
{ label: 'Undo', icon: ICONS.cut, shortcut: '\u2318Z', action: () => log('menubar', 'Undo') },
{ label: 'Redo', shortcut: '\u21E7\u2318Z', action: () => log('menubar', 'Redo') },
'-',
{ label: 'Cut', icon: ICONS.cut, shortcut: '\u2318X', action: () => log('menubar', 'Cut') },
{ label: 'Copy', icon: ICONS.copy, shortcut: '\u2318C', action: () => log('menubar', 'Copy') },
{ label: 'Paste', icon: ICONS.paste, shortcut: '\u2318V', action: () => log('menubar', 'Paste') },
'-',
{ label: 'Delete', icon: ICONS.trash, type: 'danger', action: () => log('menubar', 'Delete') },
]},
{ label: 'View', items: [
{ label: 'Show Sidebar', checked: true, action: () => log('menubar', 'Toggle Sidebar') },
{ label: 'Show Toolbar', checked: false, action: () => log('menubar', 'Toggle Toolbar') },
'-',
{ label: 'Zoom In', shortcut: '\u2318+', action: () => log('menubar', 'Zoom In') },
{ label: 'Zoom Out', shortcut: '\u2318-', action: () => log('menubar', 'Zoom Out') },
]},
{ label: 'Help', items: [
{ label: 'Documentation', icon: ICONS.info, action: () => log('menubar', 'Docs') },
{ label: 'About', action: () => log('menubar', 'About') },
]},
],
});
document.getElementById('menubar-result').textContent = 'Menubar shown at top of page';
}
function hideMenubar() {
document.querySelectorAll('puter-menubar').forEach(el => el.remove());
document.getElementById('menubar-result').textContent = 'Menubar removed';
@@ -16,6 +16,8 @@
<button id="set-menubar-btn" disabled>Set Menubar</button>
<button id="ctx-trigger" disabled>Show Context Menu</button>
<button id="set-menubar-dark-btn" disabled>Set Menubar (dark)</button>
<button id="ctx-trigger-dark" disabled>Show Context Menu (dark)</button>
<div id="log" data-testid="log"></div>
@@ -36,6 +38,8 @@
statusEl.textContent = `Ready (env=${puter.env})`;
document.getElementById('set-menubar-btn').disabled = false;
document.getElementById('ctx-trigger').disabled = false;
document.getElementById('set-menubar-dark-btn').disabled = false;
document.getElementById('ctx-trigger-dark').disabled = false;
}
function waitForPuter() {
@@ -68,6 +72,35 @@
});
log('ctx:opened');
});
// Themed variants — exercise the `theme` option, which is forwarded as
// the `theme` attribute onto the standalone (env=web) web components.
document.getElementById('set-menubar-dark-btn').addEventListener('click', () => {
puter.ui.setMenubar({
theme: 'dark',
items: [
{ label: 'TestFile', action: () => log('menubar:TestFile') },
{ label: 'TestEdit', items: [
{ label: 'TestUndo', action: () => log('menubar:TestUndo') },
{ label: 'TestRedo', action: () => log('menubar:TestRedo') },
]},
],
});
log('menubar:set:dark');
});
document.getElementById('ctx-trigger-dark').addEventListener('click', () => {
puter.ui.contextMenu({
theme: 'dark',
items: [
{ label: 'CtxAlpha', action: () => log('ctx:CtxAlpha') },
{ label: 'CtxBeta', action: () => log('ctx:CtxBeta') },
'-',
{ label: 'CtxGamma', action: () => log('ctx:CtxGamma') },
],
});
log('ctx:opened:dark');
});
</script>
</body>
</html>
@@ -0,0 +1,44 @@
import { test, expect } from '@playwright/test';
import { FIXTURE_URL } from '../helpers/testApp.js';
// The `theme` option on setMenubar()/contextMenu() only applies when puter.js
// runs standalone (puter.env === 'web'). Loading the fixture directly on its
// own origin (rather than as an app inside the Puter desktop) puts the SDK in
// env=web, which renders the web components locally — exactly the path that
// reads spec.theme and forwards it as the `theme` attribute.
test.describe('puter.ui setMenubar/contextMenu theme option (env=web)', () => {
test.beforeEach(async ({ page }) => {
await page.goto(FIXTURE_URL);
await page.locator('body.ready').waitFor({ timeout: 30_000 });
// Sanity: confirm we are actually in the standalone web environment.
const env = await page.evaluate(() => window.puter?.env);
expect(env).toBe('web');
});
test('setMenubar({ theme: "dark" }) forwards theme to <puter-menubar>', async ({ page }) => {
await page.locator('#set-menubar-dark-btn').click();
const menubar = page.locator('puter-menubar');
await expect(menubar).toHaveAttribute('theme', 'dark');
// The base component resolves theme → toggles .puter-theme-dark on the host.
await expect(menubar).toHaveClass(/puter-theme-dark/);
});
test('contextMenu({ theme: "dark" }) forwards theme to <puter-context-menu>', async ({ page }) => {
await page.locator('#ctx-trigger-dark').click();
const menu = page.locator('puter-context-menu').last();
await expect(menu).toHaveAttribute('theme', 'dark');
await expect(menu).toHaveClass(/puter-theme-dark/);
});
test('omitting theme leaves no forced theme attribute on the menubar', async ({ page }) => {
await page.locator('#set-menubar-btn').click();
const menubar = page.locator('puter-menubar');
await expect(menubar).toBeAttached();
// No explicit theme → the component follows the system preference rather
// than a forced one, so the attribute must be absent.
await expect(menubar).not.toHaveAttribute('theme', /.*/);
});
});
+12
View File
@@ -53,6 +53,12 @@ export type WindowIdentifier = string | WindowHandle;
export interface ContextMenuOptions {
/** Menu items and separators. Use the string `'-'` to insert a separator. */
items: (ContextMenuItem | '-')[];
/**
* Forces the rendered menu's color theme. Only applies when running standalone
* (`puter.env === 'web'`); ignored inside the Puter desktop (`puter.env === 'app'`).
* When unset, the menu follows the system color-scheme preference.
*/
theme?: 'dark' | 'light';
}
/** Options that configure a window created by `createWindow()`. */
@@ -113,6 +119,12 @@ export interface ThemeData {
export interface MenubarOptions {
/** Menu items and separators. Use the string `'-'` to insert a separator. */
items: (MenuItem | '-')[];
/**
* Forces the rendered menubar's color theme. Only applies when running standalone
* (`puter.env === 'web'`); ignored inside the Puter desktop (`puter.env === 'app'`).
* When unset, the menubar follows the system color-scheme preference.
*/
theme?: 'dark' | 'light';
}
/** A single item in a menubar menu. The string `'-'` may be used in place of an item to render a separator. */