Skip to content

Commit 122811e

Browse files
committed
Puter JS menubar web component now accepts shortcut attribute, improven submenu ux
1 parent bc98dac commit 122811e

2 files changed

Lines changed: 174 additions & 13 deletions

File tree

src/puter-js/src/ui/components/PuterContextMenu.js

Lines changed: 147 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,28 @@ class PuterContextMenu extends PuterWebComponent {
117117
filter: brightness(0) invert(1);
118118
}
119119
120+
/* Safe-triangle: while the cursor traces a diagonal path toward
121+
an open submenu, suppress :hover highlight on intermediate
122+
items so they don't flash blue. .focused and .has-open-submenu
123+
(managed by JS) still highlight normally. */
124+
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) {
125+
background-color: transparent;
126+
color: #333;
127+
}
128+
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon,
129+
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .check,
130+
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .submenu-arrow,
131+
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .shortcut,
132+
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .label {
133+
color: #333;
134+
}
135+
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon svg {
136+
filter: none;
137+
}
138+
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon img {
139+
filter: drop-shadow(0px 0px 0.3px rgb(51, 51, 51));
140+
}
141+
120142
/* .has-open-context-menu-submenu — line 1738-1739 */
121143
.menu-item.has-open-submenu:not(:hover) {
122144
background-color: #dfdfdf;
@@ -213,6 +235,8 @@ class PuterContextMenu extends PuterWebComponent {
213235
margin-left: 16px;
214236
font-size: 11px;
215237
color: #999;
238+
flex-shrink: 0;
239+
letter-spacing: 0.5px;
216240
}
217241
218242
/* === iOS-style action sheet (mobile) ========================= */
@@ -294,7 +318,7 @@ class PuterContextMenu extends PuterWebComponent {
294318
295319
:host(.sheet-mode) .icon {
296320
width: 24px;
297-
margin-right: 12px;
321+
margin-right: 0px;
298322
}
299323
:host(.sheet-mode) .icon svg,
300324
:host(.sheet-mode) .icon img {
@@ -342,7 +366,7 @@ class PuterContextMenu extends PuterWebComponent {
342366
: '';
343367

344368
const shortcutHTML = item.shortcut
345-
? `<span class="shortcut">${this._escapeHTML(item.shortcut)}</span>`
369+
? `<span class="shortcut">${this._escapeHTML(this._formatShortcut(item.shortcut))}</span>`
346370
: '';
347371

348372
return `
@@ -431,7 +455,7 @@ class PuterContextMenu extends PuterWebComponent {
431455
_bindEvents () {
432456
// Remove any stale document listeners from a prior render
433457
if ( this._outsideClickHandler ) {
434-
document.removeEventListener('click', this._outsideClickHandler, true);
458+
document.removeEventListener('pointerdown', this._outsideClickHandler, true);
435459
}
436460
if ( this._keyHandler ) {
437461
document.removeEventListener('keydown', this._keyHandler, true);
@@ -472,6 +496,7 @@ class PuterContextMenu extends PuterWebComponent {
472496
el.addEventListener('mouseenter', () => {
473497
if ( el.dataset.hasSubmenu === 'true' ) {
474498
this.#pendingFocusIndex = null;
499+
this._setSafeTraverse(false);
475500
this._setFocusIndex(index);
476501
this._cancelSubmenuClose();
477502
clearTimeout(this.#submenuTimeout);
@@ -485,16 +510,25 @@ class PuterContextMenu extends PuterWebComponent {
485510
}
486511
} else if ( this.#activeSubmenu ) {
487512
// Safe-triangle: if cursor is heading toward the submenu,
488-
// defer focus change so intermediate items don't highlight
513+
// defer focus change AND suppress :hover styling on this
514+
// item so it doesn't flash blue mid-traversal.
489515
if ( this._isMouseHeadingToSubmenu(this.#activeSubmenu.element) ) {
490516
this.#pendingFocusIndex = index;
491-
this._cancelSubmenuClose();
517+
this._setSafeTraverse(true);
518+
// Don't call _cancelSubmenuClose — it clears
519+
// pendingFocusIndex. Just clear the close timer.
520+
if ( this.#submenuCloseTimer ) {
521+
clearTimeout(this.#submenuCloseTimer);
522+
this.#submenuCloseTimer = null;
523+
}
492524
this.#submenuCloseTimer = setTimeout(() => this._submenuCloseCheck(), 100);
493525
return;
494526
}
527+
this._setSafeTraverse(false);
495528
this._setFocusIndex(index);
496529
this._scheduleSubmenuClose();
497530
} else {
531+
this._setSafeTraverse(false);
498532
this._setFocusIndex(index);
499533
}
500534
});
@@ -507,14 +541,18 @@ class PuterContextMenu extends PuterWebComponent {
507541
});
508542
});
509543

510-
// Close on outside click
544+
// Close on outside pointerdown — fires the instant the press starts,
545+
// before mouseup/click, so the menu doesn't linger during a drag.
546+
// Submenus are sibling elements appended to <body>, so we explicitly
547+
// walk the submenu chain — a click in a descendant submenu must not
548+
// tear us (and therefore that submenu) down.
511549
this._outsideClickHandler = (e) => {
512-
if ( ! this.contains(e.target) ) {
550+
if ( ! this._isEventInChain(e) ) {
513551
this._closeAll();
514552
}
515553
};
516554
setTimeout(() => {
517-
document.addEventListener('click', this._outsideClickHandler, true);
555+
document.addEventListener('pointerdown', this._outsideClickHandler, true);
518556
}, 0);
519557

520558
// Track mouse for safe-triangle submenu hover
@@ -809,20 +847,26 @@ class PuterContextMenu extends PuterWebComponent {
809847
clearTimeout(this.#submenuCloseTimer);
810848
this.#submenuCloseTimer = null;
811849
}
812-
// User reached the submenu — discard deferred focus
850+
// User reached the submenu — discard deferred focus and end traversal
813851
this.#pendingFocusIndex = null;
852+
this._setSafeTraverse(false);
814853
}
815854

816855
_submenuCloseCheck () {
817856
this.#submenuCloseTimer = null;
818-
if ( ! this.#activeSubmenu ) return;
857+
if ( ! this.#activeSubmenu ) {
858+
this._setSafeTraverse(false);
859+
return;
860+
}
819861

820862
// If cursor is currently over the submenu or the parent item, keep open
821863
const submenu = this.#activeSubmenu.element;
822864
const parentEl = this.#activeSubmenu.parentEl;
823865
const latest = this.#mouseLocs[this.#mouseLocs.length - 1];
824866
if ( latest ) {
825867
if ( this._pointInElement(latest, submenu) || this._pointInRect(latest, parentEl.getBoundingClientRect()) ) {
868+
// Cursor arrived at submenu / parent — end safe-triangle mode
869+
this._setSafeTraverse(false);
826870
return;
827871
}
828872
}
@@ -834,9 +878,16 @@ class PuterContextMenu extends PuterWebComponent {
834878
return;
835879
}
836880

881+
// Trajectory no longer heading to submenu — end traversal mode
882+
this._setSafeTraverse(false);
837883
this._hideActiveSubmenu();
838884
}
839885

886+
_setSafeTraverse (on) {
887+
const menu = this.$('.context-menu');
888+
if ( menu ) menu.classList.toggle('safe-traverse', on);
889+
}
890+
840891
_pointInRect (p, r) {
841892
return p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom;
842893
}
@@ -911,6 +962,7 @@ class PuterContextMenu extends PuterWebComponent {
911962
this._setFocusIndex(this.#pendingFocusIndex);
912963
this.#pendingFocusIndex = null;
913964
}
965+
this._setSafeTraverse(false);
914966
}
915967

916968
_closeAll () {
@@ -924,7 +976,7 @@ class PuterContextMenu extends PuterWebComponent {
924976
const wasHidden = this._sheetHidden;
925977
this._hideActiveSubmenu(false);
926978
if ( this._outsideClickHandler ) {
927-
document.removeEventListener('click', this._outsideClickHandler, true);
979+
document.removeEventListener('pointerdown', this._outsideClickHandler, true);
928980
}
929981
if ( this._keyHandler ) {
930982
document.removeEventListener('keydown', this._keyHandler, true);
@@ -953,7 +1005,7 @@ class PuterContextMenu extends PuterWebComponent {
9531005

9541006
disconnectedCallback () {
9551007
if ( this._outsideClickHandler ) {
956-
document.removeEventListener('click', this._outsideClickHandler, true);
1008+
document.removeEventListener('pointerdown', this._outsideClickHandler, true);
9571009
}
9581010
if ( this._keyHandler ) {
9591011
document.removeEventListener('keydown', this._keyHandler, true);
@@ -980,6 +1032,89 @@ class PuterContextMenu extends PuterWebComponent {
9801032
if ( ! str ) return '';
9811033
return str.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
9821034
}
1035+
1036+
/**
1037+
* Render a keyboard-shortcut string in OS-appropriate form.
1038+
*
1039+
* Mac: modifiers as glyphs, concatenated → ⇧⌘D
1040+
* Win/Linux: text labels joined with '+' → Ctrl+Shift+D
1041+
*
1042+
* Accepts portable tokens (Mod, Cmd, Ctrl, Alt, Option, Shift, Meta) and
1043+
* the literal Mac glyphs (⌘ ⌃ ⌥ ⇧). 'Mod' is the recommended portable
1044+
* name — it maps to Cmd on Mac and Ctrl elsewhere.
1045+
*/
1046+
_formatShortcut (str) {
1047+
if ( ! str ) return '';
1048+
const isMac = PuterContextMenu._isMac();
1049+
1050+
// Inflate any glyphs into named tokens so we can re-emit per OS.
1051+
const normalized = String(str)
1052+
.replace(//g, 'Mod+') // ⌘
1053+
.replace(//g, 'Ctrl+') // ⌃
1054+
.replace(//g, 'Alt+') // ⌥
1055+
.replace(//g, 'Shift+'); // ⇧
1056+
1057+
const tokens = normalized.split('+').map(t => t.trim()).filter(Boolean);
1058+
1059+
const out = tokens.map(t => {
1060+
switch ( t.toLowerCase() ) {
1061+
case 'mod':
1062+
case 'cmd':
1063+
case 'command':
1064+
return isMac ? '⌘' : 'Ctrl';
1065+
case 'ctrl':
1066+
case 'control':
1067+
return isMac ? '⌃' : 'Ctrl';
1068+
case 'alt':
1069+
case 'option':
1070+
case 'opt':
1071+
return isMac ? '⌥' : 'Alt';
1072+
case 'shift':
1073+
return isMac ? '⇧' : 'Shift';
1074+
case 'meta':
1075+
case 'super':
1076+
case 'win':
1077+
return isMac ? '⌘' : 'Win';
1078+
default:
1079+
return t;
1080+
}
1081+
});
1082+
1083+
return isMac ? out.join('') : out.join('+');
1084+
}
1085+
1086+
_getActiveSubmenu () {
1087+
return this.#activeSubmenu;
1088+
}
1089+
1090+
/**
1091+
* Returns true if a document-level event targets this menu or any
1092+
* submenu nested below it. e.target on shadow-DOM-crossing events is
1093+
* the submenu's host element, so we use contains() on each host in the
1094+
* chain.
1095+
*/
1096+
_isEventInChain (e) {
1097+
const target = e.target;
1098+
if ( ! target ) return false;
1099+
if ( this.contains(target) ) return true;
1100+
let cur = this.#activeSubmenu;
1101+
while ( cur && cur.element ) {
1102+
if ( cur.element.contains(target) ) return true;
1103+
cur = cur.element._getActiveSubmenu
1104+
? cur.element._getActiveSubmenu()
1105+
: null;
1106+
}
1107+
return false;
1108+
}
1109+
1110+
static _isMac () {
1111+
if ( typeof navigator === 'undefined' ) return false;
1112+
const uaData = navigator.userAgentData;
1113+
if ( uaData && typeof uaData.platform === 'string' ) {
1114+
return /mac/i.test(uaData.platform);
1115+
}
1116+
return /Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent || '');
1117+
}
9831118
}
9841119

9851120
export default PuterContextMenu;

src/puter-js/src/ui/components/PuterMenubar.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,14 @@ class PuterMenubar extends PuterWebComponent {
114114

115115
btn.addEventListener('click', (e) => {
116116
e.stopPropagation();
117+
// The dropdown closes on outside pointerdown, which fires
118+
// before this click. If the user is pressing the same button
119+
// that just closed, treat the press as a toggle-close —
120+
// don't reopen on the trailing click.
121+
if ( this._suppressClickFor === btn ) {
122+
this._suppressClickFor = null;
123+
return;
124+
}
117125
this.#focusedIndex = index;
118126
this.#menubarActive = true;
119127
if ( this.#activeButtonEl === btn ) {
@@ -124,6 +132,19 @@ class PuterMenubar extends PuterWebComponent {
124132
this._openDropdown(btn, item);
125133
});
126134

135+
// pointerdown on this button while it owns the open dropdown:
136+
// mark it so the outside-pointerdown close (about to fire) and
137+
// the trailing click don't reopen.
138+
btn.addEventListener('pointerdown', () => {
139+
if ( this.#activeButtonEl === btn ) {
140+
this._suppressClickFor = btn;
141+
clearTimeout(this._suppressClickTimer);
142+
this._suppressClickTimer = setTimeout(() => {
143+
this._suppressClickFor = null;
144+
}, 400);
145+
}
146+
});
147+
127148
// Hover-switch when a dropdown is already open
128149
btn.addEventListener('mouseenter', () => {
129150
if ( this.#activeDropdown && this.#activeButtonEl !== btn ) {
@@ -290,11 +311,14 @@ class PuterMenubar extends PuterWebComponent {
290311
this._deactivateMenubar();
291312
});
292313
dropdown.addEventListener('close', () => {
293-
// The context menu closes itself on outside click; sync our state
314+
// The context menu closes itself on outside click / Escape /
315+
// selection. Sync our state and fully deactivate the menubar so
316+
// a stray arrow / Enter / Space keypress doesn't re-open it.
294317
if ( this.#activeDropdown === dropdown ) {
295318
buttonEl.classList.remove('active');
296319
this.#activeDropdown = null;
297320
this.#activeButtonEl = null;
321+
this._deactivateMenubar();
298322
}
299323
});
300324
// Keyboard navigate request bubbling from the context menu
@@ -325,6 +349,8 @@ class PuterMenubar extends PuterWebComponent {
325349

326350
disconnectedCallback () {
327351
this._closeDropdown();
352+
clearTimeout(this._suppressClickTimer);
353+
this._suppressClickFor = null;
328354
if ( this._keyHandler ) {
329355
document.removeEventListener('keydown', this._keyHandler, true);
330356
}

0 commit comments

Comments
 (0)