diff --git a/src/puter-js/src/ui/PuterWebComponent.js b/src/puter-js/src/ui/PuterWebComponent.js index a369e2144..f7b2a7e1e 100644 --- a/src/puter-js/src/ui/PuterWebComponent.js +++ b/src/puter-js/src/ui/PuterWebComponent.js @@ -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 = `${this.render()}`; diff --git a/src/puter-js/src/ui/components.md b/src/puter-js/src/ui/components.md index f41cfe792..555eafa6b 100644 --- a/src/puter-js/src/ui/components.md +++ b/src/puter-js/src/ui/components.md @@ -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: ``, ``, ``, ``, ``. +By default, components that ship dark-mode styles auto-adapt to the OS setting (`prefers-color-scheme: dark`): ``, ``, ``, ``, ``. + +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 + + + + + +``` + +The attribute is live — changing it at runtime re-paints the component immediately. For `` and ``, the `theme` is forwarded to any dropdowns and submenus they spawn, so the whole tree stays in sync. ## Responsive / mobile diff --git a/src/puter-js/src/ui/components/PuterColorPicker.js b/src/puter-js/src/ui/components/PuterColorPicker.js index f8311386d..13024ea5c 100644 --- a/src/puter-js/src/ui/components/PuterColorPicker.js +++ b/src/puter-js/src/ui/components/PuterColorPicker.js @@ -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; + } `; } diff --git a/src/puter-js/src/ui/components/PuterContextMenu.js b/src/puter-js/src/ui/components/PuterContextMenu.js index 989cdcb7a..3e4446736 100644 --- a/src/puter-js/src/ui/components/PuterContextMenu.js +++ b/src/puter-js/src/ui/components/PuterContextMenu.js @@ -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); } diff --git a/src/puter-js/src/ui/components/PuterFontPicker.js b/src/puter-js/src/ui/components/PuterFontPicker.js index d158dcc71..dfecaa8f0 100644 --- a/src/puter-js/src/ui/components/PuterFontPicker.js +++ b/src/puter-js/src/ui/components/PuterFontPicker.js @@ -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; + } `; } diff --git a/src/puter-js/src/ui/components/PuterMenubar.js b/src/puter-js/src/ui/components/PuterMenubar.js index 2b9b8324f..0104d678a 100644 --- a/src/puter-js/src/ui/components/PuterMenubar.js +++ b/src/puter-js/src/ui/components/PuterMenubar.js @@ -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) { diff --git a/src/puter-js/src/ui/components/PuterNotification.js b/src/puter-js/src/ui/components/PuterNotification.js index 215232686..9fc72f5a3 100644 --- a/src/puter-js/src/ui/components/PuterNotification.js +++ b/src/puter-js/src/ui/components/PuterNotification.js @@ -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);