mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-27 11:55:50 +00:00
Merge pull request #3105 from HeyPuter/puter-js-menubar-fixes
Puter JS UI Component updates
This commit is contained in:
@@ -12,9 +12,64 @@ class PuterWebComponent extends (globalThis.HTMLElement || Object) {
|
||||
}
|
||||
|
||||
connectedCallback () {
|
||||
this._setupThemeWatchers();
|
||||
this._applyTheme();
|
||||
this._rerender();
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this._teardownThemeWatchers();
|
||||
}
|
||||
|
||||
_setupThemeWatchers () {
|
||||
if ( typeof globalThis.MutationObserver !== 'undefined' && ! this._themeObserver ) {
|
||||
this._themeObserver = new globalThis.MutationObserver(() => this._applyTheme());
|
||||
this._themeObserver.observe(this, { attributes: true, attributeFilter: ['theme'] });
|
||||
}
|
||||
if ( typeof globalThis.matchMedia === 'function' && ! this._themeMediaQuery ) {
|
||||
this._themeMediaQuery = globalThis.matchMedia('(prefers-color-scheme: dark)');
|
||||
this._themeMediaListener = () => this._applyTheme();
|
||||
if ( this._themeMediaQuery.addEventListener ) {
|
||||
this._themeMediaQuery.addEventListener('change', this._themeMediaListener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_teardownThemeWatchers () {
|
||||
if ( this._themeObserver ) {
|
||||
this._themeObserver.disconnect();
|
||||
this._themeObserver = null;
|
||||
}
|
||||
if ( this._themeMediaQuery && this._themeMediaListener && this._themeMediaQuery.removeEventListener ) {
|
||||
this._themeMediaQuery.removeEventListener('change', this._themeMediaListener);
|
||||
}
|
||||
this._themeMediaQuery = null;
|
||||
this._themeMediaListener = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the effective theme from the `theme` attribute and the system
|
||||
* preference, then toggles a `.puter-theme-dark` class on the host so
|
||||
* components can style with `:host(.puter-theme-dark) ...`.
|
||||
* theme="dark" → always dark
|
||||
* theme="light" → always light
|
||||
* unset / other → follow system (prefers-color-scheme)
|
||||
*/
|
||||
_applyTheme () {
|
||||
if ( typeof this.getAttribute !== 'function' ) return;
|
||||
const themeAttr = this.getAttribute('theme');
|
||||
let isDark;
|
||||
if ( themeAttr === 'dark' ) {
|
||||
isDark = true;
|
||||
} else if ( themeAttr === 'light' ) {
|
||||
isDark = false;
|
||||
} else {
|
||||
isDark = typeof globalThis.matchMedia === 'function'
|
||||
&& globalThis.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
this.classList.toggle('puter-theme-dark', isDark);
|
||||
}
|
||||
|
||||
_rerender () {
|
||||
if ( ! this.shadowRoot ) return;
|
||||
this.shadowRoot.innerHTML = `<style>${this.getStyles()}</style>${this.render()}`;
|
||||
|
||||
@@ -340,7 +340,25 @@ All components render inside Shadow DOM and match puter.com's native GUI appeara
|
||||
|
||||
## Dark mode
|
||||
|
||||
Components with `@media (prefers-color-scheme: dark)` rules auto-adapt when the OS is in dark mode: `<puter-notification>`, `<puter-context-menu>`, `<puter-menubar>`, `<puter-color-picker>`, `<puter-font-picker>`.
|
||||
By default, components that ship dark-mode styles auto-adapt to the OS setting (`prefers-color-scheme: dark`): `<puter-notification>`, `<puter-context-menu>`, `<puter-menubar>`, `<puter-color-picker>`, `<puter-font-picker>`.
|
||||
|
||||
To force a specific theme regardless of the OS preference, set the `theme` attribute on the component:
|
||||
|
||||
| Value | Behavior |
|
||||
|-------|----------|
|
||||
| `dark` | Always render in dark theme |
|
||||
| `light` | Always render in light theme |
|
||||
| _unset_ | Follow the system `prefers-color-scheme` (default) |
|
||||
|
||||
```html
|
||||
<!-- Force dark, even in an OS configured for light mode -->
|
||||
<puter-menubar theme="dark"></puter-menubar>
|
||||
|
||||
<!-- Force light, even when the OS is in dark mode -->
|
||||
<puter-notification theme="light" title="Hello"></puter-notification>
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Responsive / mobile
|
||||
|
||||
|
||||
@@ -176,6 +176,46 @@ class PuterColorPicker extends PuterWebComponent {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
:host(.puter-theme-dark) .picker-body {
|
||||
background-color: rgba(40, 44, 52, .95);
|
||||
color: #e6e6e6;
|
||||
box-shadow: 0px 0px 15px #000000aa;
|
||||
}
|
||||
:host(.puter-theme-dark) .preview {
|
||||
border-color: #555;
|
||||
}
|
||||
:host(.puter-theme-dark) .header-label {
|
||||
color: #aaa;
|
||||
}
|
||||
:host(.puter-theme-dark) .hex-input {
|
||||
background-color: #1f1f1f;
|
||||
border-color: #555;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
:host(.puter-theme-dark) .native-color-row {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: #555;
|
||||
}
|
||||
:host(.puter-theme-dark) .native-color-row label {
|
||||
color: #aaa;
|
||||
}
|
||||
:host(.puter-theme-dark) input[type="color"] {
|
||||
border-color: #555;
|
||||
}
|
||||
:host(.puter-theme-dark) .swatch {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
:host(.puter-theme-dark) .btn {
|
||||
color: #e6e6e6;
|
||||
border-color: #555;
|
||||
background: linear-gradient(#4a4a4a, #3a3a3a);
|
||||
box-shadow: inset 0px 1px 0px rgb(255 255 255 / 8%), 0 1px 2px rgb(0 0 0 / 25%);
|
||||
}
|
||||
:host(.puter-theme-dark) .btn:active {
|
||||
background-color: #333;
|
||||
border-color: #444;
|
||||
color: #999;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,15 +47,16 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
font-family: sans-serif;
|
||||
background: #FFF;
|
||||
color: #333;
|
||||
border-radius: 2px;
|
||||
border-radius: 4px;
|
||||
padding: 3px 0;
|
||||
min-width: 200px;
|
||||
background-color: rgb(255 255 255 / 92%);
|
||||
backdrop-filter: blur(3px);
|
||||
border: 1px solid #e6e4e466;
|
||||
box-shadow: 0px 0px 15px #00000066;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
box-shadow: 0px 3px 10px #00000044;
|
||||
margin-top: 5px;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
user-select: none;
|
||||
@@ -88,7 +89,11 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Active item turns all children white */
|
||||
/* Active item turns all children white.
|
||||
For .has-open-submenu the :hover branch above already covers
|
||||
the hovered case; in the non-hovered grey state we want the
|
||||
children to keep their default colors, so we don't include
|
||||
.has-open-submenu here. */
|
||||
.menu-item:hover:not(.disabled):not(.divider) .icon,
|
||||
.menu-item:hover:not(.disabled):not(.divider) .check,
|
||||
.menu-item:hover:not(.disabled):not(.divider) .submenu-arrow,
|
||||
@@ -98,46 +103,51 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
.menu-item.focused:not(.disabled):not(.divider) .check,
|
||||
.menu-item.focused:not(.disabled):not(.divider) .submenu-arrow,
|
||||
.menu-item.focused:not(.disabled):not(.divider) .label,
|
||||
.menu-item.focused:not(.disabled):not(.divider) .shortcut,
|
||||
.menu-item.has-open-submenu .icon,
|
||||
.menu-item.has-open-submenu .check,
|
||||
.menu-item.has-open-submenu .submenu-arrow,
|
||||
.menu-item.has-open-submenu .label,
|
||||
.menu-item.has-open-submenu .shortcut {
|
||||
.menu-item.focused:not(.disabled):not(.divider) .shortcut {
|
||||
color: white;
|
||||
}
|
||||
.menu-item:hover:not(.disabled):not(.divider) .icon svg,
|
||||
.menu-item.focused:not(.disabled):not(.divider) .icon svg,
|
||||
.menu-item.has-open-submenu .icon svg {
|
||||
.menu-item.focused:not(.disabled):not(.divider) .icon svg {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
.menu-item:hover:not(.disabled):not(.divider) .icon img,
|
||||
.menu-item.focused:not(.disabled):not(.divider) .icon img,
|
||||
.menu-item.has-open-submenu .icon img {
|
||||
.menu-item.focused:not(.disabled):not(.divider) .icon img {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
/* Safe-triangle: while the cursor traces a diagonal path toward
|
||||
an open submenu, suppress :hover highlight on intermediate
|
||||
items so they don't flash blue. .focused and .has-open-submenu
|
||||
(managed by JS) still highlight normally. */
|
||||
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) {
|
||||
items so they don't flash blue.
|
||||
Keyboard-nav: after a keyboard navigation, suppress :hover on
|
||||
the (now stale) item the mouse is still resting on so only
|
||||
the keyboard-focused item highlights. Cleared on next
|
||||
mousemove. .focused and .has-open-submenu (managed by JS)
|
||||
still highlight normally. */
|
||||
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider),
|
||||
.context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) {
|
||||
background-color: transparent;
|
||||
color: #333;
|
||||
}
|
||||
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon,
|
||||
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .check,
|
||||
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .submenu-arrow,
|
||||
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .label {
|
||||
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .label,
|
||||
.context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon,
|
||||
.context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .check,
|
||||
.context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .submenu-arrow,
|
||||
.context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .label {
|
||||
color: #333;
|
||||
}
|
||||
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .shortcut {
|
||||
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .shortcut,
|
||||
.context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .shortcut {
|
||||
color: #999;
|
||||
}
|
||||
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon svg {
|
||||
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon svg,
|
||||
.context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon svg {
|
||||
filter: none;
|
||||
}
|
||||
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon img {
|
||||
.context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon img,
|
||||
.context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon img {
|
||||
filter: drop-shadow(0px 0px 0.3px rgb(51, 51, 51));
|
||||
}
|
||||
|
||||
@@ -175,7 +185,6 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
padding-bottom: 5px;
|
||||
cursor: default;
|
||||
height: auto;
|
||||
pointer-events: none;
|
||||
}
|
||||
.divider hr {
|
||||
border: none;
|
||||
@@ -335,6 +344,102 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Dark theme — applied when system prefers dark and no light
|
||||
override is set, or when theme="dark" is forced. The base
|
||||
class toggles .puter-theme-dark on the host accordingly. */
|
||||
:host(.puter-theme-dark) .context-menu {
|
||||
background: #2d2d2d;
|
||||
background-color: rgb(45 45 45 / 94%);
|
||||
color: #e6e6e6;
|
||||
border-color: #00000080;
|
||||
box-shadow: 0px 0px 15px #000000aa;
|
||||
}
|
||||
:host(.puter-theme-dark) .menu-item {
|
||||
color: #e6e6e6;
|
||||
}
|
||||
/* Inactive items: icon/check/shortcut/arrow tones */
|
||||
:host(.puter-theme-dark) .icon,
|
||||
:host(.puter-theme-dark) .check {
|
||||
color: #e6e6e6;
|
||||
}
|
||||
:host(.puter-theme-dark) .submenu-arrow {
|
||||
color: #b0b0b0;
|
||||
}
|
||||
:host(.puter-theme-dark) .shortcut {
|
||||
color: #888;
|
||||
}
|
||||
:host(.puter-theme-dark) .icon img {
|
||||
filter: drop-shadow(0px 0px 0.3px rgb(230, 230, 230));
|
||||
}
|
||||
/* Inactive icon SVGs use currentColor already; nothing to invert */
|
||||
|
||||
/* Safe-triangle / keyboard-nav: non-active hover restored colors should match dark */
|
||||
:host(.puter-theme-dark) .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider),
|
||||
:host(.puter-theme-dark) .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) {
|
||||
color: #e6e6e6;
|
||||
}
|
||||
:host(.puter-theme-dark) .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon,
|
||||
:host(.puter-theme-dark) .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .check,
|
||||
:host(.puter-theme-dark) .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .submenu-arrow,
|
||||
:host(.puter-theme-dark) .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .label,
|
||||
:host(.puter-theme-dark) .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon,
|
||||
:host(.puter-theme-dark) .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .check,
|
||||
:host(.puter-theme-dark) .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .submenu-arrow,
|
||||
:host(.puter-theme-dark) .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .label {
|
||||
color: #e6e6e6;
|
||||
}
|
||||
:host(.puter-theme-dark) .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .shortcut,
|
||||
:host(.puter-theme-dark) .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .shortcut {
|
||||
color: #888;
|
||||
}
|
||||
:host(.puter-theme-dark) .context-menu.safe-traverse .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon img,
|
||||
:host(.puter-theme-dark) .context-menu.keyboard-nav .menu-item:hover:not(.has-open-submenu):not(.focused):not(.disabled):not(.divider) .icon img {
|
||||
filter: drop-shadow(0px 0px 0.3px rgb(230, 230, 230));
|
||||
}
|
||||
|
||||
/* Submenu-open parent (no hover): subtle dark highlight */
|
||||
:host(.puter-theme-dark) .menu-item.has-open-submenu:not(:hover) {
|
||||
background-color: #3f3f3f;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
:host(.puter-theme-dark) .menu-item.has-open-submenu:not(:hover) .icon,
|
||||
:host(.puter-theme-dark) .menu-item.has-open-submenu:not(:hover) .icon svg,
|
||||
:host(.puter-theme-dark) .menu-item.has-open-submenu:not(:hover) .icon img {
|
||||
color: #e6e6e6;
|
||||
}
|
||||
|
||||
/* Danger items */
|
||||
:host(.puter-theme-dark) .menu-item.danger,
|
||||
:host(.puter-theme-dark) .menu-item.danger .icon {
|
||||
color: #ff7b72;
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
:host(.puter-theme-dark) .divider hr {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
/* Sheet mode (mobile) */
|
||||
:host(.puter-theme-dark.sheet-mode) .context-menu {
|
||||
background-color: rgb(40 40 40 / 96%);
|
||||
box-shadow: 0 -6px 24px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
:host(.puter-theme-dark.sheet-mode) .menu-item:hover:not(.disabled):not(.divider) {
|
||||
background-color: rgba(0, 122, 255, 0.22);
|
||||
}
|
||||
:host(.puter-theme-dark.sheet-mode) .menu-item:active:not(.disabled):not(.divider) {
|
||||
background-color: rgba(0, 122, 255, 0.35);
|
||||
}
|
||||
:host(.puter-theme-dark.sheet-mode) .menu-item:hover:not(.disabled):not(.divider) .label,
|
||||
:host(.puter-theme-dark.sheet-mode) .menu-item:hover:not(.disabled):not(.divider) .icon,
|
||||
:host(.puter-theme-dark.sheet-mode) .menu-item:active:not(.disabled):not(.divider) .label,
|
||||
:host(.puter-theme-dark.sheet-mode) .menu-item:active:not(.disabled):not(.divider) .icon {
|
||||
color: #e6e6e6;
|
||||
}
|
||||
:host(.puter-theme-dark.sheet-mode) .divider hr {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -475,6 +580,32 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
this.#mouseTracker = null;
|
||||
}
|
||||
|
||||
// Hovering a separator should drop the focus highlight from the
|
||||
// previously-focused item — the cursor has clearly moved past it.
|
||||
// It should also close any submenu opened from an item above, so
|
||||
// the parent's .has-open-submenu highlight goes away too.
|
||||
// Exception: if the cursor is on a diagonal path toward the open
|
||||
// submenu, treat the divider like any other safe-traverse cell so
|
||||
// we don't tear down the submenu mid-drag.
|
||||
this.$$('.menu-item.divider').forEach((el) => {
|
||||
el.addEventListener('mouseenter', () => {
|
||||
if ( this.#activeSubmenu && this._isMouseHeadingToSubmenu(this.#activeSubmenu.element) ) {
|
||||
this._setSafeTraverse(true);
|
||||
if ( this.#submenuCloseTimer ) {
|
||||
clearTimeout(this.#submenuCloseTimer);
|
||||
this.#submenuCloseTimer = null;
|
||||
}
|
||||
this.#submenuCloseTimer = setTimeout(() => this._submenuCloseCheck(), 100);
|
||||
return;
|
||||
}
|
||||
this._setSafeTraverse(false);
|
||||
this._clearFocus();
|
||||
clearTimeout(this.#submenuTimeout);
|
||||
this._cancelSubmenuClose();
|
||||
this._hideActiveSubmenu();
|
||||
});
|
||||
});
|
||||
|
||||
const menuItems = this.$$('.menu-item:not(.divider):not(.disabled)');
|
||||
|
||||
menuItems.forEach((el) => {
|
||||
@@ -565,10 +696,12 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
document.addEventListener('pointerdown', this._outsideClickHandler, true);
|
||||
}, 0);
|
||||
|
||||
// Track mouse for safe-triangle submenu hover
|
||||
// Track mouse for safe-triangle submenu hover. Also exits
|
||||
// keyboard-nav mode: once the cursor moves, :hover should win again.
|
||||
this.#mouseTracker = (e) => {
|
||||
this.#mouseLocs.push({ x: e.clientX, y: e.clientY });
|
||||
if ( this.#mouseLocs.length > 3 ) this.#mouseLocs.shift();
|
||||
this._setKeyboardNav(false);
|
||||
};
|
||||
document.addEventListener('mousemove', this.#mouseTracker);
|
||||
|
||||
@@ -577,6 +710,10 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
if ( this.#activeSubmenu ) return; // let the deeper submenu handle
|
||||
const consumed = this._handleKey(e);
|
||||
if ( consumed ) {
|
||||
// A handled key (nav, typeahead, etc.) means the user is
|
||||
// driving by keyboard — suppress any stale :hover until the
|
||||
// mouse next moves.
|
||||
this._setKeyboardNav(true);
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
@@ -595,9 +732,24 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
case 'ArrowDown':
|
||||
this._moveFocus(+1);
|
||||
return true;
|
||||
case 'ArrowUp':
|
||||
case 'ArrowUp': {
|
||||
// Root dropdown: ArrowUp on the first focusable item bubbles
|
||||
// to the menubar, which closes the dropdown and re-focuses
|
||||
// the parent menubar button.
|
||||
if ( ! this._parentMenu ) {
|
||||
const f = this._focusableIndices();
|
||||
if ( f.length && this.#focusedIndex === f[0] ) {
|
||||
this.dispatchEvent(new CustomEvent('puter-menu-navigate', {
|
||||
detail: { direction: 'up' },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
this._moveFocus(-1);
|
||||
return true;
|
||||
}
|
||||
case 'Home': {
|
||||
const f = this._focusableIndices();
|
||||
if ( f.length ) this._setFocusIndex(f[0]);
|
||||
@@ -708,14 +860,23 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
if ( ! el ) return false;
|
||||
clearTimeout(this.#submenuTimeout);
|
||||
this._cancelSubmenuClose();
|
||||
this._showSubmenu(el, item.items);
|
||||
// Focus first item in submenu
|
||||
// Don't re-open an already-open submenu for this parent — it would
|
||||
// wipe the user's focused item inside it. We only auto-focus the
|
||||
// first sub-item when the submenu is freshly opened by this call.
|
||||
const wasNewlyOpened = !this.#activeSubmenu || this.#activeSubmenu.parentEl !== el;
|
||||
if ( wasNewlyOpened ) {
|
||||
this._showSubmenu(el, item.items);
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
const sub = this.#activeSubmenu && this.#activeSubmenu.element;
|
||||
if ( sub ) {
|
||||
if ( ! sub ) return;
|
||||
if ( wasNewlyOpened ) {
|
||||
const f = sub._focusableIndices();
|
||||
if ( f.length ) sub._setFocusIndex(f[0]);
|
||||
}
|
||||
if ( typeof sub._setKeyboardNav === 'function' ) {
|
||||
sub._setKeyboardNav(true);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -761,10 +922,18 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
this._cancelSubmenuClose();
|
||||
|
||||
parentEl.classList.add('has-open-submenu');
|
||||
// The parent now "owns" an open submenu; drop the .focused class so
|
||||
// it paints in the dim has-open-submenu state instead of the bright
|
||||
// focused/hover blue. :hover still keeps it blue when the cursor is
|
||||
// directly over it. #focusedIndex is preserved for ArrowLeft restore.
|
||||
parentEl.classList.remove('focused');
|
||||
|
||||
const submenu = document.createElement('puter-context-menu');
|
||||
submenu.setAttribute('data-submenu', '');
|
||||
submenu.setAttribute('data-parent-managed', '');
|
||||
// Forward any forced theme so the submenu paints in the same theme.
|
||||
const themeAttr = this.getAttribute('theme');
|
||||
if ( themeAttr ) submenu.setAttribute('theme', themeAttr);
|
||||
submenu.items = items;
|
||||
submenu._parentMenu = this;
|
||||
submenu._parentItemEl = parentEl;
|
||||
@@ -898,6 +1067,11 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
if ( menu ) menu.classList.toggle('safe-traverse', on);
|
||||
}
|
||||
|
||||
_setKeyboardNav (on) {
|
||||
const menu = this.$('.context-menu');
|
||||
if ( menu ) menu.classList.toggle('keyboard-nav', on);
|
||||
}
|
||||
|
||||
_pointInRect (p, r) {
|
||||
return p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom;
|
||||
}
|
||||
@@ -1014,6 +1188,7 @@ class PuterContextMenu extends PuterWebComponent {
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
super.disconnectedCallback();
|
||||
if ( this._outsideClickHandler ) {
|
||||
document.removeEventListener('pointerdown', this._outsideClickHandler, true);
|
||||
}
|
||||
|
||||
@@ -185,6 +185,49 @@ class PuterFontPicker extends PuterWebComponent {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
:host(.puter-theme-dark) .picker-body {
|
||||
background-color: rgba(40, 44, 52, .95);
|
||||
color: #e6e6e6;
|
||||
box-shadow: 0px 0px 15px #000000aa;
|
||||
}
|
||||
:host(.puter-theme-dark) .title {
|
||||
color: #e6e6e6;
|
||||
text-shadow: none;
|
||||
}
|
||||
:host(.puter-theme-dark) .search {
|
||||
background-color: #1f1f1f;
|
||||
border-color: #555;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
:host(.puter-theme-dark) .font-list {
|
||||
background-color: #1f1f1f;
|
||||
border-color: #555;
|
||||
}
|
||||
:host(.puter-theme-dark) .font-item {
|
||||
color: #e6e6e6;
|
||||
}
|
||||
:host(.puter-theme-dark) .font-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
:host(.puter-theme-dark) .font-name-label {
|
||||
color: #888;
|
||||
}
|
||||
:host(.puter-theme-dark) .preview {
|
||||
background: #1f1f1f;
|
||||
border-color: #555;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
:host(.puter-theme-dark) .btn {
|
||||
color: #e6e6e6;
|
||||
border-color: #555;
|
||||
background: linear-gradient(#4a4a4a, #3a3a3a);
|
||||
box-shadow: inset 0px 1px 0px rgb(255 255 255 / 8%), 0 1px 2px rgb(0 0 0 / 25%);
|
||||
}
|
||||
:host(.puter-theme-dark) .btn:active {
|
||||
background-color: #333;
|
||||
border-color: #444;
|
||||
color: #999;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,11 @@ class PuterMenubar extends PuterWebComponent {
|
||||
.menu-button.focused {
|
||||
background-color: #e2e2e2;
|
||||
}
|
||||
/* Suppress stale :hover during keyboard nav so only the focused
|
||||
button highlights. Cleared on the next mousemove. */
|
||||
.menubar.keyboard-nav .menu-button:hover:not(.focused):not(.active) {
|
||||
background-color: transparent;
|
||||
}
|
||||
.menu-button:focus { outline: none; }
|
||||
@media (max-width: 480px) {
|
||||
.menubar {
|
||||
@@ -86,6 +91,24 @@ class PuterMenubar extends PuterWebComponent {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
/* Dark theme — applied when system prefers dark and no light
|
||||
override is set, or when theme="dark" is forced. The base
|
||||
class toggles .puter-theme-dark on the host accordingly. */
|
||||
:host(.puter-theme-dark) .menubar {
|
||||
background-color: #2a2a2a;
|
||||
border-bottom-color: #3a3a3a;
|
||||
}
|
||||
:host(.puter-theme-dark) .menu-button {
|
||||
color: #e6e6e6;
|
||||
}
|
||||
:host(.puter-theme-dark) .menu-button:hover,
|
||||
:host(.puter-theme-dark) .menu-button.active,
|
||||
:host(.puter-theme-dark) .menu-button.focused {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
:host(.puter-theme-dark) .menubar.keyboard-nav .menu-button:hover:not(.focused):not(.active) {
|
||||
background-color: transparent;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -108,6 +131,9 @@ class PuterMenubar extends PuterWebComponent {
|
||||
if ( this._docPointerDownHandler ) {
|
||||
document.removeEventListener('pointerdown', this._docPointerDownHandler, true);
|
||||
}
|
||||
if ( this._mouseMoveHandler ) {
|
||||
document.removeEventListener('mousemove', this._mouseMoveHandler);
|
||||
}
|
||||
|
||||
const buttons = this.$$('.menu-button');
|
||||
buttons.forEach((btn) => {
|
||||
@@ -168,6 +194,11 @@ class PuterMenubar extends PuterWebComponent {
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', this._docPointerDownHandler, true);
|
||||
|
||||
// Once the user actually moves the mouse, exit keyboard-nav mode so
|
||||
// :hover styling on menubar buttons works normally again.
|
||||
this._mouseMoveHandler = () => this._setKeyboardNav(false);
|
||||
document.addEventListener('mousemove', this._mouseMoveHandler);
|
||||
}
|
||||
|
||||
_onGlobalKeyDown (e) {
|
||||
@@ -244,12 +275,14 @@ class PuterMenubar extends PuterWebComponent {
|
||||
this.#menubarActive = true;
|
||||
this.#focusedIndex = 0;
|
||||
this._renderButtonFocus();
|
||||
this._setKeyboardNav(true);
|
||||
}
|
||||
|
||||
_deactivateMenubar () {
|
||||
this.#menubarActive = false;
|
||||
this.#focusedIndex = null;
|
||||
this._renderButtonFocus();
|
||||
this._setKeyboardNav(false);
|
||||
}
|
||||
|
||||
_renderButtonFocus () {
|
||||
@@ -259,6 +292,11 @@ class PuterMenubar extends PuterWebComponent {
|
||||
});
|
||||
}
|
||||
|
||||
_setKeyboardNav (on) {
|
||||
const menubar = this.$('.menubar');
|
||||
if ( menubar ) menubar.classList.toggle('keyboard-nav', on);
|
||||
}
|
||||
|
||||
_moveButtonFocus (delta, { swapDropdown = true } = {}) {
|
||||
if ( ! this.#items.length ) return;
|
||||
const n = this.#items.length;
|
||||
@@ -266,6 +304,7 @@ class PuterMenubar extends PuterWebComponent {
|
||||
const next = (cur + delta + n) % n;
|
||||
this.#focusedIndex = next;
|
||||
this._renderButtonFocus();
|
||||
this._setKeyboardNav(true);
|
||||
|
||||
// If a dropdown is already open, swap to the new button's dropdown
|
||||
if ( swapDropdown && this.#activeDropdown ) {
|
||||
@@ -291,6 +330,9 @@ class PuterMenubar extends PuterWebComponent {
|
||||
if ( dd && typeof dd._focusableIndices === 'function' ) {
|
||||
const f = dd._focusableIndices();
|
||||
if ( f.length ) dd._setFocusIndex(f[0]);
|
||||
if ( typeof dd._setKeyboardNav === 'function' ) {
|
||||
dd._setKeyboardNav(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -311,6 +353,9 @@ class PuterMenubar extends PuterWebComponent {
|
||||
const rect = buttonEl.getBoundingClientRect();
|
||||
const dropdown = document.createElement('puter-context-menu');
|
||||
dropdown.setAttribute('data-submenu', ''); // skip mobile sheet behavior
|
||||
// Forward any forced theme so the dropdown paints in the same theme.
|
||||
const themeAttr = this.getAttribute('theme');
|
||||
if ( themeAttr ) dropdown.setAttribute('theme', themeAttr);
|
||||
dropdown.items = item.items;
|
||||
dropdown.setAttribute('x', String(rect.left));
|
||||
dropdown.setAttribute('y', String(rect.bottom));
|
||||
@@ -331,12 +376,23 @@ class PuterMenubar extends PuterWebComponent {
|
||||
this._deactivateMenubar();
|
||||
}
|
||||
});
|
||||
// Keyboard navigate request bubbling from the context menu
|
||||
// Keyboard navigate request bubbling from the context menu.
|
||||
// Arrow-left/right at the menubar level closes the current dropdown
|
||||
// and moves button focus only — the user must press ArrowDown (or
|
||||
// Enter/Space) to open the adjacent dropdown.
|
||||
// Arrow-up at the dropdown's first item closes it and returns
|
||||
// focus to the same menubar button (which can re-open with ArrowDown).
|
||||
dropdown.addEventListener('puter-menu-navigate', (e) => {
|
||||
if ( ! e.detail ) return;
|
||||
if ( e.detail.direction === 'up' ) {
|
||||
this._closeDropdown();
|
||||
this._renderButtonFocus();
|
||||
this._setKeyboardNav(true);
|
||||
return;
|
||||
}
|
||||
const delta = e.detail.direction === 'right' ? +1 : -1;
|
||||
this._closeDropdown();
|
||||
this._moveButtonFocus(delta, { swapDropdown: false });
|
||||
this._openFocusedButton(true);
|
||||
});
|
||||
|
||||
document.body.appendChild(dropdown);
|
||||
@@ -358,6 +414,7 @@ class PuterMenubar extends PuterWebComponent {
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
super.disconnectedCallback();
|
||||
this._closeDropdown();
|
||||
clearTimeout(this._suppressClickTimer);
|
||||
this._suppressClickFor = null;
|
||||
@@ -370,6 +427,9 @@ class PuterMenubar extends PuterWebComponent {
|
||||
if ( this._docPointerDownHandler ) {
|
||||
document.removeEventListener('pointerdown', this._docPointerDownHandler, true);
|
||||
}
|
||||
if ( this._mouseMoveHandler ) {
|
||||
document.removeEventListener('mousemove', this._mouseMoveHandler);
|
||||
}
|
||||
}
|
||||
|
||||
_escapeHTML (str) {
|
||||
|
||||
@@ -175,6 +175,29 @@ class PuterNotification extends PuterWebComponent {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
:host(.puter-theme-dark) .notification {
|
||||
background: #2d2d2dcd;
|
||||
border-color: #3a3a3a;
|
||||
box-shadow: 0px 0px 17px -6px #000;
|
||||
}
|
||||
:host(.puter-theme-dark) .close-btn {
|
||||
background: #3a3a3a;
|
||||
color: #ccc;
|
||||
filter: drop-shadow(0px 0px 0.5px rgb(230, 230, 230));
|
||||
}
|
||||
:host(.puter-theme-dark) .close-btn:hover {
|
||||
background: #4a4a4a;
|
||||
color: #fff;
|
||||
}
|
||||
:host(.puter-theme-dark) .icon-area {
|
||||
filter: drop-shadow(0px 0px 0.5px rgb(230, 230, 230));
|
||||
}
|
||||
:host(.puter-theme-dark) .title {
|
||||
color: #e6e6e6;
|
||||
}
|
||||
:host(.puter-theme-dark) .text {
|
||||
color: #b0b0b0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -255,6 +278,7 @@ class PuterNotification extends PuterWebComponent {
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
super.disconnectedCallback();
|
||||
if ( this._dismissTimer ) clearTimeout(this._dismissTimer);
|
||||
const idx = activeNotifications.indexOf(this);
|
||||
if ( idx !== -1 ) activeNotifications.splice(idx, 1);
|
||||
|
||||
Reference in New Issue
Block a user