diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index c7f541c..1638e8d 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -1080,6 +1080,49 @@ dialog::backdrop { background: rgba(0,0,0,.5); } +@media (prefers-reduced-motion: no-preference) { + dialog:not(.facts-modal)[open] { + translate: 0 0; + transition: translate var(--dur-base) var(--ease-out); + } + + @starting-style { + dialog:not(.facts-modal)[open] { + translate: 0 100dvh; + } + } + + dialog:not(.facts-modal)[open].is-leaving, + dialog:not(.facts-modal)[open].is-confirming { + pointer-events: none; + transition-timing-function: var(--ease-in); + } + + dialog:not(.facts-modal)[open].is-leaving { + translate: 0 100dvh; + } + + dialog:not(.facts-modal)[open].is-confirming { + translate: 0 -100dvh; + } + + dialog:not(.facts-modal)[open]::backdrop { + opacity: 1; + transition: opacity var(--dur-base) var(--ease-out); + } + + @starting-style { + dialog:not(.facts-modal)[open]::backdrop { + opacity: 0; + } + } + + dialog:not(.facts-modal)[open].is-leaving::backdrop, + dialog:not(.facts-modal)[open].is-confirming::backdrop { + opacity: 0; + } +} + .header-label { text-transform: uppercase; font: var(--td-s); diff --git a/app/javascript/chance_controller.js b/app/javascript/chance_controller.js index 6081a0c..65be9bf 100644 --- a/app/javascript/chance_controller.js +++ b/app/javascript/chance_controller.js @@ -4,28 +4,51 @@ export default class extends Controller { static targets = ["dialog"] confirm(event) { - // The second pass — after the player confirmed — falls through to Turbo. + // The second pass -- after the player confirmed -- falls through to Turbo. if (this.confirmed) return event.preventDefault() this.pendingForm = event.target + this.dialogTarget.classList.remove("is-confirming", "is-leaving") + this.leaving = false this.dialogTarget.showModal() } - ok() { - this.dialogTarget.close() + // The dialog leaves upwards before the answer is sent, so the wait is however + // long that slide takes -- and nothing at all where the slide doesn't exist. + async ok() { + if (this.leaving) return + await this.#slideOut("is-confirming") + // requestSubmit(), not submit(): the native call skips the submit event, so // Turbo would miss it and navigate the whole page out of the game frame. this.confirmed = true this.pendingForm?.requestSubmit() } - cancel() { - this.dialogTarget.close() + async cancel() { + if (this.leaving) return + await this.#slideOut("is-leaving") this.pendingForm = null } backdropClick(event) { if (event.target === this.dialogTarget) this.cancel() } + + // The slide runs while the dialog is still open, so close() only ever lands + // once it is off screen: nothing is left behind for the browser to lay out, + // which is what Safari does with a dialog that outlives its top layer. + async #slideOut(state) { + this.leaving = true + this.dialogTarget.classList.add(state) + + // Reading a style forces the class change to resolve, so the transition + // exists by the time we ask for it. + getComputedStyle(this.dialogTarget).translate + const running = this.dialogTarget.getAnimations({ subtree: false }) + await Promise.all(running.map((animation) => animation.finished)).catch(() => {}) + + this.dialogTarget.close() + } }