Browse Source

animation step 1

main
Mattias Bodlund 3 weeks ago
parent
commit
d59739532e
9 changed files with 158 additions and 4 deletions
  1. +44
    -0
      app/assets/stylesheets/application.css
  2. +2
    -0
      app/javascript/application.js
  3. +6
    -0
      app/javascript/carousel_controller.js
  4. +70
    -1
      app/javascript/modal_controller.js
  5. +31
    -0
      app/javascript/start_controller.js
  6. +1
    -1
      app/views/game/_facts_content.html.erb
  7. +2
    -1
      app/views/game/start.html.erb
  8. +1
    -1
      app/views/layouts/application.html.erb
  9. +1
    -0
      config/importmap.rb

+ 44
- 0
app/assets/stylesheets/application.css View File

@ -103,6 +103,8 @@ h2@property --glow-deg {
--td-3xl: 700 4rem/1.2 var(--ff-ikea);
--ease-out: cubic-bezier(0.22, 0.61, 0.36, 1);
--ease-in: cubic-bezier(0.55, 0, 0.68, 0.53);
--dur-fast: 400ms;
--dur-base: 600ms;
--dur-slow: 900ms;
@ -310,6 +312,11 @@ main, .modal-panel {
to { transform: translateX(0); }
}
@keyframes wave-slide-out {
from { transform: translateX(0); }
to { transform: translateX(-100vw); }
}
@media (max-width: 1023.98px) and (prefers-reduced-motion: no-preference) {
.start .hero-container {
overflow: clip;
@ -327,6 +334,43 @@ main, .modal-panel {
.start .hero-container svg.ico-wave {
animation: wave-slide-in var(--dur-base) var(--ease-out) calc(var(--dur-slow) * 0.4) both;
}
.start > article {
transition: transform var(--dur-base) var(--ease-in),
margin-block-end var(--dur-base) var(--ease-in);
}
.start.is-exiting {
overflow: clip;
}
.start.is-exiting > article {
transform: translateY(100%);
margin-block-end: calc(var(--exit-shift, 0px) * -1);
}
.start.is-exiting .hero-container svg.ico-wave {
animation: wave-slide-out var(--dur-fast) var(--ease-in) var(--dur-base) both;
}
.facts-modal .carousel > li {
overflow: clip;
}
.facts-modal .hero-container svg.ico-wave {
animation: wave-slide-in var(--dur-base) var(--ease-out) both;
}
.facts-modal .is-entering {
max-height: 100%;
overflow: clip;
}
.facts-modal .is-entering .carousel,
.facts-modal .is-entering .carousel > li > .hero-container {
flex: 0 0 auto;
transition: height var(--dur-base) var(--ease-out);
}
}
.facts {


+ 2
- 0
app/javascript/application.js View File

@ -8,6 +8,7 @@ import ChanceController from "chance_controller"
import ShareController from "share_controller"
import ModalController from "modal_controller"
import RatingController from "rating_controller"
import StartController from "start_controller"
const application = Application.start()
@ -18,6 +19,7 @@ application.register("chance", ChanceController)
application.register("share", ShareController)
application.register("modal", ModalController)
application.register("rating", RatingController)
application.register("start", StartController)
// The whole game runs inside one turbo-frame so the URL never changes. Frame
// navigations don't reset scroll the way a full page visit does, so each new


+ 6
- 0
app/javascript/carousel_controller.js View File

@ -8,6 +8,9 @@ export default class extends Controller {
this.boundEqualizeHeights = this.equalizeHeights.bind(this)
this.carouselTarget.addEventListener("scroll", this.boundUpdate, { passive: true })
window.addEventListener("resize", this.boundEqualizeHeights)
// Inside a modal the slides have no height until the dialog is open, so the
// first useful measurement can only happen once it announces itself.
window.addEventListener("modal:opened", this.boundEqualizeHeights)
this.equalizeHeights()
this.update()
}
@ -15,8 +18,11 @@ export default class extends Controller {
disconnect() {
this.carouselTarget.removeEventListener("scroll", this.boundUpdate)
window.removeEventListener("resize", this.boundEqualizeHeights)
window.removeEventListener("modal:opened", this.boundEqualizeHeights)
}
// One height for every slide, so the hero below is the same size on each and
// swiping doesn't jog the layout.
equalizeHeights() {
const articles = this.carouselTarget.querySelectorAll(":scope > li > article")
articles.forEach((article) => (article.style.height = "auto"))


+ 70
- 1
app/javascript/modal_controller.js View File

@ -4,7 +4,7 @@ export default class extends Controller {
static targets = ["dialog"]
connect() {
this.dialogTarget.showModal()
this.#openWhenSettled()
}
close() {
@ -18,6 +18,75 @@ export default class extends Controller {
}
remove() {
this.dispatch("closed", { target: window })
this.element.remove()
}
// The screen underneath may still be animating out. The wave is the last
// thing to leave, so waiting on it holds the modal until the exit is done.
async #openWhenSettled() {
const wave = document.querySelector(".is-exiting .hero-container svg.ico-wave")
if (wave) await this.#settle(wave, "animationend")
if (!this.element.isConnected) return
this.dialogTarget.showModal()
this.dispatch("opened", { target: window })
this.#playEntrance()
}
// The hero opens full-bleed, pushing the copy and the markers off the bottom.
// Once the wave has landed, the carousel and the hero shrink back to their
// measured resting heights, which carries everything below up into view.
// Sizing both is what hides the slide's own copy: the li clips whatever the
// hero pushes past it.
async #playEntrance() {
const panel = this.element.querySelector(".modal-panel")
const carousel = panel?.querySelector(".carousel")
if (!carousel) return
const heroes = Array.from(carousel.querySelectorAll(":scope > li > .hero-container"))
const boxes = [carousel, ...heroes]
const rest = new Map(boxes.map((box) => [box, box.offsetHeight]))
const full = this.dialogTarget.clientHeight
panel.classList.add("is-entering")
boxes.forEach((box) => {
box.style.transition = "none"
box.style.height = `${full}px`
})
getComputedStyle(carousel).height
boxes.forEach((box) => (box.style.transition = ""))
const wave = carousel.querySelector(".hero-container svg.ico-wave")
if (wave) await this.#settle(wave, "animationend")
boxes.forEach((box) => (box.style.height = `${rest.get(box)}px`))
await this.#settle(carousel, "transitionend")
panel.classList.remove("is-entering")
boxes.forEach((box) => (box.style.height = ""))
}
// Resolves when the element's animation or transition ends. Safari doesn't
// reliably have one registered at the moment we ask for it, so the declared
// timing is used as a fallback rather than getAnimations().
#settle(element, eventName) {
return new Promise((resolve) => {
const style = getComputedStyle(element)
const seconds =
eventName === "animationend"
? parseFloat(style.animationDuration) + parseFloat(style.animationDelay)
: parseFloat(style.transitionDuration) + parseFloat(style.transitionDelay)
const finish = () => {
element.removeEventListener(eventName, finish)
clearTimeout(timer)
resolve()
}
const timer = setTimeout(finish, (seconds || 0) * 1000 + 50)
element.addEventListener(eventName, finish)
})
}
}

+ 31
- 0
app/javascript/start_controller.js View File

@ -0,0 +1,31 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.reset = this.reset.bind(this)
window.addEventListener("modal:closed", this.reset)
}
disconnect() {
window.removeEventListener("modal:closed", this.reset)
}
// Slides the copy out and lets the hero flex up to fill the viewport. The
// negative margin is what frees the space; the transform carries the article
// the rest of the way off-screen.
exit() {
const article = this.element.querySelector(":scope > article")
if (!article) return
this.element.style.setProperty("--exit-shift", `${article.offsetHeight}px`)
this.element.classList.add("is-exiting")
// Force the transitions into existence now, so a fast turbo_stream response
// still finds something to wait on before it opens the modal.
getComputedStyle(article).marginBlockEnd
}
reset() {
this.element.classList.remove("is-exiting")
}
}

+ 1
- 1
app/views/game/_facts_content.html.erb View File

@ -27,6 +27,6 @@
</div>
<article>
<%= link_to tag.span(t("game.got_it_lets_get_started")), {action: "intro"}, class: "cta" %>
<%= link_to tag.span(t("game.got_it_lets_get_started")), { action: "intro" }, class: "cta" %>
</article>
</div>

+ 2
- 1
app/views/game/start.html.erb View File

@ -1,4 +1,5 @@
<%- content_for :title, node_title(@node) %>
<%- content_for :main_controller, "start" %>
<%- content_for :header do %>
<%= render partial: "language-menu" %>
@ -15,7 +16,7 @@
<%= tag.h1 t("game.intro_title").html_safe %>
<%= tag.p t("game.intro_description") %>
<%= button_to tag.span(t("game.let_me_try")), start_path, class: "cta" %>
<%= button_to tag.span(t("game.let_me_try")), start_path, class: "cta", data: { action: "click->start#exit" } %>
</div>
<%= tag.div class: "play-time" do %>


+ 1
- 1
app/views/layouts/application.html.erb View File

@ -17,7 +17,7 @@
<link rel="icon" sizes="192x192" href="/ikea-favicon-300x300.png">
<%= stylesheet_link_tag "reset", "application" %>
<%= frontend_javascript_importmap_tags %w[application @hotwired/turbo-rails @hotwired/stimulus language_menu_controller carousel_controller intro_controller chance_controller share_controller modal_controller rating_controller] %>
<%= frontend_javascript_importmap_tags %w[application @hotwired/turbo-rails @hotwired/stimulus language_menu_controller carousel_controller intro_controller chance_controller share_controller modal_controller rating_controller start_controller] %>
</head>
<body id="page">
<%= turbo_frame_tag "game" do %>


+ 1
- 0
config/importmap.rb View File

@ -21,3 +21,4 @@ pin "chance_controller", preload: false
pin "share_controller", preload: false
pin "modal_controller", preload: false
pin "rating_controller", preload: false
pin "start_controller", preload: false

Loading…
Cancel
Save