diff --git a/frontend/src/common/util.js b/frontend/src/common/util.js index 97e40f5f3..7a9a495b1 100644 --- a/frontend/src/common/util.js +++ b/frontend/src/common/util.js @@ -470,6 +470,12 @@ export default class $util { return navigator.maxTouchPoints > 0; } + // shouldOpenOnHover reports whether menus should open on hover: the user's UI + // preference (default on) gated by the device having no touch input. + static shouldOpenOnHover() { + return ($config.getSettings()?.ui?.openOnHover ?? true) && !$util.hasTouch(); + } + // isMobile returns true when the current user agent or touch capability indicates a mobile device. // The `> 2` touch check covers iPads in desktop mode, where the user agent omits the mobile hint. static isMobile() { diff --git a/frontend/src/component/action/menu.vue b/frontend/src/component/action/menu.vue index 2de29a274..206022b4a 100644 --- a/frontend/src/component/action/menu.vue +++ b/frontend/src/component/action/menu.vue @@ -63,7 +63,7 @@ export default { return { visible: false, actions: [], - openOnHover: !this.$util.hasTouch(), + openOnHover: this.$util.shouldOpenOnHover(), }; }, methods: { diff --git a/frontend/src/component/auth/menu.vue b/frontend/src/component/auth/menu.vue index 0212d8465..3dda7ba61 100644 --- a/frontend/src/component/auth/menu.vue +++ b/frontend/src/component/auth/menu.vue @@ -60,7 +60,7 @@ export default { data() { return { open: false, - openOnHover: !this.$util.hasTouch(), + openOnHover: this.$util.shouldOpenOnHover(), instances: [], }; }, diff --git a/frontend/src/component/lightbox/menu.vue b/frontend/src/component/lightbox/menu.vue index ec006c403..fc1b80259 100644 --- a/frontend/src/component/lightbox/menu.vue +++ b/frontend/src/component/lightbox/menu.vue @@ -56,7 +56,7 @@ export default { return { visible: false, actions: [], - openOnHover: !this.$util.hasTouch(), + openOnHover: this.$util.shouldOpenOnHover(), }; }, methods: { diff --git a/frontend/tests/vitest/common/util.test.js b/frontend/tests/vitest/common/util.test.js index db2e1af00..453afbc45 100644 --- a/frontend/tests/vitest/common/util.test.js +++ b/frontend/tests/vitest/common/util.test.js @@ -2,6 +2,7 @@ import { describe, it, expect, afterEach } from "vitest"; import "../fixtures"; import $util from "common/util"; import { tokenRegexp, tokenLength } from "common/util"; +import { $config } from "app/session"; import * as can from "common/can"; import { ContentTypeMp4AvcMain, ContentTypeMp4HvcMain } from "common/media"; @@ -369,4 +370,36 @@ describe("common/util", () => { expect($util.isMobile()).toBe(false); }); }); + + describe("shouldOpenOnHover", () => { + const maxTouchPoints = navigator.maxTouchPoints; + const settings = $config.getSettings(); + const openOnHover = settings.ui.openOnHover; + const stubTouch = (touch) => Object.defineProperty(navigator, "maxTouchPoints", { value: touch, configurable: true }); + afterEach(() => { + stubTouch(maxTouchPoints); + settings.ui.openOnHover = openOnHover; + }); + + it("returns true when enabled and the device has no touch", () => { + stubTouch(0); + settings.ui.openOnHover = true; + expect($util.shouldOpenOnHover()).toBe(true); + }); + it("returns false when the setting is disabled", () => { + stubTouch(0); + settings.ui.openOnHover = false; + expect($util.shouldOpenOnHover()).toBe(false); + }); + it("defaults to true when the setting is absent", () => { + stubTouch(0); + delete settings.ui.openOnHover; + expect($util.shouldOpenOnHover()).toBe(true); + }); + it("returns false on a touch device regardless of the setting", () => { + stubTouch(5); + settings.ui.openOnHover = true; + expect($util.shouldOpenOnHover()).toBe(false); + }); + }); }); diff --git a/internal/api/swagger.json b/internal/api/swagger.json index 1efdfe65e..cc9e0989c 100644 --- a/internal/api/swagger.json +++ b/internal/api/swagger.json @@ -1102,6 +1102,9 @@ "language": { "type": "string" }, + "openOnHover": { + "type": "boolean" + }, "scrollbar": { "type": "boolean" }, diff --git a/internal/config/customize/settings.go b/internal/config/customize/settings.go index 32133a11e..7c338f5ab 100644 --- a/internal/config/customize/settings.go +++ b/internal/config/customize/settings.go @@ -53,12 +53,13 @@ func NewSettings(theme, language, timeZone string) *Settings { return &Settings{ UI: UISettings{ - Scrollbar: true, - Zoom: false, - Theme: theme, - Language: language, - TimeZone: timeZone, - StartPage: DefaultStartPage, + Scrollbar: true, + Zoom: false, + OpenOnHover: true, + Theme: theme, + Language: language, + TimeZone: timeZone, + StartPage: DefaultStartPage, }, Search: SearchSettings{ BatchSize: -1, diff --git a/internal/config/customize/testdata/settings.yml b/internal/config/customize/testdata/settings.yml index 7edf6370d..d026e95a7 100755 --- a/internal/config/customize/testdata/settings.yml +++ b/internal/config/customize/testdata/settings.yml @@ -1,6 +1,7 @@ UI: Scrollbar: true Zoom: false + OpenOnHover: true Theme: onyx Language: de TimeZone: Local diff --git a/internal/config/customize/ui.go b/internal/config/customize/ui.go index 26cfcadf0..e0fbe30bc 100644 --- a/internal/config/customize/ui.go +++ b/internal/config/customize/ui.go @@ -2,10 +2,11 @@ package customize // UISettings represents user interface settings. type UISettings struct { - Scrollbar bool `json:"scrollbar" yaml:"Scrollbar"` - Zoom bool `json:"zoom" yaml:"Zoom"` - Theme string `json:"theme" yaml:"Theme"` - Language string `json:"language" yaml:"Language"` - TimeZone string `json:"timeZone" yaml:"TimeZone"` - StartPage string `json:"startPage" yaml:"StartPage"` + Scrollbar bool `json:"scrollbar" yaml:"Scrollbar"` + Zoom bool `json:"zoom" yaml:"Zoom"` + OpenOnHover bool `json:"openOnHover" yaml:"OpenOnHover"` + Theme string `json:"theme" yaml:"Theme"` + Language string `json:"language" yaml:"Language"` + TimeZone string `json:"timeZone" yaml:"TimeZone"` + StartPage string `json:"startPage" yaml:"StartPage"` }