diff --git a/app/assets/stylesheets/reset.css b/app/assets/stylesheets/reset.css index d333a38dfa..2442e1938b 100644 --- a/app/assets/stylesheets/reset.css +++ b/app/assets/stylesheets/reset.css @@ -83,18 +83,31 @@ /* Remove all animations and transitions for people that prefer not to see them */ @media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { + :root:not([data-motion="animate"]) { + & *, + & *::before, + & *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + + scroll-behavior: initial; + } + } + + html[data-motion="reduce"] { + & *, + & *::before, + & *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } - html { - scroll-behavior: initial; - } + scroll-behavior: initial; } dialog { diff --git a/app/javascript/controllers/motion_controller.js b/app/javascript/controllers/motion_controller.js new file mode 100644 index 0000000000..1c4e8683e1 --- /dev/null +++ b/app/javascript/controllers/motion_controller.js @@ -0,0 +1,69 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["autoButton", "reduceButton", "animateButton"] + + connect() { + this.#updateButtons() + } + + setAuto() { + this.#motion = "auto" + } + + setReduce() { + this.#motion = "reduce" + } + + setAnimate() { + this.#motion = "animate" + } + + get #storedMotion() { + return localStorage.getItem("motion") || "auto" + } + + set #motion(motion) { + localStorage.setItem("motion", motion) + + if (motion === "reduce") { + document.documentElement.dataset.motion = "reduce" + this.#removeViewTransitionMeta() + } else if (motion === "animate") { + document.documentElement.dataset.motion = "animate" + this.#restoreViewTransitionMeta() + } else { + delete document.documentElement.dataset.motion // transiently absent so the matchMedia patch reads the real OS value + const reduced = this.#osPreferReducedMotion + document.documentElement.dataset.motion = reduced ? "reduce" : "animate" + reduced ? this.#removeViewTransitionMeta() : this.#restoreViewTransitionMeta() + } + + this.#updateButtons() + } + + get #osPreferReducedMotion() { + return window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches + } + + #removeViewTransitionMeta() { + document.querySelector('meta[name="view-transition"]')?.remove() + } + + #restoreViewTransitionMeta() { + if (!document.querySelector('meta[name="view-transition"]')) { + const meta = document.createElement("meta") + meta.name = "view-transition" + meta.content = "same-origin" + document.head.appendChild(meta) + } + } + + #updateButtons() { + const stored = this.#storedMotion + + if (this.hasAutoButtonTarget) { this.autoButtonTarget.checked = (stored === "auto") } + if (this.hasReduceButtonTarget) { this.reduceButtonTarget.checked = (stored === "reduce") } + if (this.hasAnimateButtonTarget) { this.animateButtonTarget.checked = (stored === "animate") } + } +} diff --git a/app/views/layouts/_motion_preference.html.erb b/app/views/layouts/_motion_preference.html.erb new file mode 100644 index 0000000000..52f18d54c9 --- /dev/null +++ b/app/views/layouts/_motion_preference.html.erb @@ -0,0 +1,30 @@ +<%= javascript_tag nonce: true do %> + function prefersReducedMotion() { + const pref = localStorage.getItem("motion") + if (pref === "reduce") return true + if (pref === "animate") return false + return window.matchMedia("(prefers-reduced-motion: reduce)").matches + } + + const reduced = prefersReducedMotion() + document.documentElement.dataset.motion = reduced ? "reduce" : "animate" + + if (reduced) document.querySelector('meta[name="view-transition"]')?.remove() + + const originalMatchMedia = window.matchMedia.bind(window) + window.matchMedia = function(query) { + if (!query.includes("prefers-reduced-motion")) return originalMatchMedia(query) + + const pref = document.documentElement.dataset.motion + if (pref !== "reduce" && pref !== "animate") return originalMatchMedia(query) + + return new Proxy(originalMatchMedia(query), { + get(target, prop) { + if (prop === "matches") return pref === "reduce" + + const value = target[prop] + return typeof value === "function" ? value.bind(target) : value + } + }) + } +<% end %> diff --git a/app/views/layouts/shared/_head.html.erb b/app/views/layouts/shared/_head.html.erb index 9ef153df2b..d918e9a2e3 100644 --- a/app/views/layouts/shared/_head.html.erb +++ b/app/views/layouts/shared/_head.html.erb @@ -16,6 +16,7 @@ <% turbo_refreshes_with method: :morph, scroll: :preserve %> <%= render "layouts/theme_preference" %> + <%= render "layouts/motion_preference" %> <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> <%= javascript_importmap_tags %> diff --git a/app/views/users/_theme.html.erb b/app/views/users/_theme.html.erb index 47300abef5..29d0076742 100644 --- a/app/views/users/_theme.html.erb +++ b/app/views/users/_theme.html.erb @@ -23,3 +23,29 @@ + +
+
+

Motion

+
+ +
+ + + + + +
+