Skip to content

Commit

Permalink
Anonymous and persistent modals
Browse files Browse the repository at this point in the history
  • Loading branch information
sfnelson committed Oct 13, 2023
1 parent a9543c5 commit a938ef2
Show file tree
Hide file tree
Showing 21 changed files with 486 additions and 88 deletions.
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ end
group :test do
gem "capybara"
gem "cuprite", github: "rubycdp/cuprite"
gem "rails-controller-testing"
end
5 changes: 5 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,10 @@ GEM
activesupport (= 7.1.0)
bundler (>= 1.15.0)
railties (= 7.1.0)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1)
activesupport (>= 5.0.1.rc1)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
Expand Down Expand Up @@ -350,6 +354,7 @@ DEPENDENCIES
propshaft
puma
rails
rails-controller-testing
rake
rspec-rails
rubocop-katalyst
Expand Down
51 changes: 41 additions & 10 deletions app/assets/javascripts/controllers/kpop/frame_controller.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Controller } from "@hotwired/stimulus";

const DEBUG = true;
const DEBUG = false;

export default class Kpop__FrameController extends Controller {
static outlets = ["scrim"];
Expand All @@ -16,34 +16,34 @@ export default class Kpop__FrameController extends Controller {
if (this.openValue) return;

// Capture the scrim and then show the content
if (this.hasModalTarget) {
scrim.show().then(() => (this.openValue = true));
if (this.hasModalOutlet) {
this.#openModal(scrim, this.modalOutlet);
}
}

modalTargetConnected() {
modalOutletConnected(modal) {
if (DEBUG) console.debug("modal connected");

// When switching modals a target may connect while scrim is already open
if (this.openValue) return;

// Capture the scrim and then show the content if the scrim is ready
this.scrimOutlet?.show()?.then(() => (this.openValue = true));
if (this.hasScrimOutlet) {
this.#openModal(this.scrimOutlet, modal);
}
}

modalTargetDisconnected() {
modalOutletDisconnected(_) {
if (DEBUG) console.debug("modal disconnect");

// When switching modals there may still be content to show
if (this.hasModalTarget) return;
if (this.hasModalOutlet) return;

this.openValue = false;
this.scrimOutlet?.hide();
}

openValueChanged(open) {
if (DEBUG) console.debug("state:", open ? "open" : "close");

this.element.style.display = open ? "flex" : "none";
}

Expand All @@ -52,7 +52,11 @@ export default class Kpop__FrameController extends Controller {

if (!this.hasModalTarget || !this.openValue) return;

this.#clear();
this.modalOutlet?.dismiss();
}

async #openModal(scrim, modal) {
scrim.show(modal.scrimConfig).then(() => (this.openValue = true));
}

#clear() {
Expand All @@ -61,4 +65,31 @@ export default class Kpop__FrameController extends Controller {
this.element.removeAttribute("src");
this.element.innerHTML = "";
}

// Stimulus 3.2.2 has an issue where outlets do not fire disconnect callbacks
// when the element is removed from the DOM. We're using targets to work
// around this issue, but could use outlets in the future when this is
// resolved.
modalOutletFor(element) {
return this.application.getControllerForElementAndIdentifier(
element,
"kpop--modal",
);
}

get modalOutlet() {
return this.modalOutletFor(this.modalTarget);
}

get hasModalOutlet() {
return this.hasModalTarget;
}

modalTargetConnected(element) {
this.modalOutletConnected(this.modalOutletFor(element));
}

modalTargetDisconnected(element) {
this.modalOutletDisconnected(this.modalOutletFor(element));
}
}
65 changes: 64 additions & 1 deletion app/assets/javascripts/controllers/kpop/modal_controller.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,75 @@
import { Controller } from "@hotwired/stimulus";

const DEBUG = false;

export default class Kpop__ModalController extends Controller {
static values = {
temporary: { type: Boolean, default: true },
};

get scrimConfig() {
return { temporary: this.temporaryValue };
}

connect() {
// Set the modal content to temporary to ensure its omitted when caching the page
if (DEBUG) console.debug("modal connect");

this.element.toggleAttribute("data-turbo-temporary", this.temporaryValue);

// Push a new history entry. This will be popped or replaced when the modal is dismissed.
// Persistent modals push a history entry via Turbo.
if (this.temporaryValue) {
window.history.pushState({}, "", window.location);
}

// Capture pops so we can dismiss the modal.
window.addEventListener("popstate", this.popstate);

// Capture visits so we can replace history.
window.addEventListener("turbo:before-visit", this.beforeVisit);
}

disconnect() {
if (DEBUG) console.debug("modal disconnect");

window.removeEventListener("popstate", this.popstate);
window.removeEventListener("turbo:before-visit", this.beforeVisit);

if (!this.popped) {
this.dismiss();
}
}

dismiss() {
if (DEBUG) console.debug("modal dismiss");

if (!this.popped) {
this.popped = true;
window.history.back();
}
}

popstate = () => {
this.popped = true;

if (DEBUG) console.debug("modal popped");

// Remove the modal from the DOM if it's a temporary modal.
if (this.temporaryValue) {
this.element.remove();
}
};

beforeVisit = (e) => {
if (this.popped) return;

// visit fires when persistent modals load, so we need to ignore those.
if (e.detail.url === this.element.closest("turbo-frame").src) return;

if (DEBUG) console.debug("visiting", e.detail.url);

e.preventDefault();
this.popped = true;
Turbo.visit(e.detail.url, { action: "replace" });
};
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Controller } from "@hotwired/stimulus";
import { Turbo } from "@hotwired/turbo";
import { Turbo } from "@hotwired/turbo-rails";

export default class Kpop__RedirectController extends Controller {
static values = {
Expand Down
17 changes: 13 additions & 4 deletions app/assets/javascripts/controllers/scrim_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,20 @@ export default class ScrimController extends Controller {
open: Boolean,
captive: Boolean,
zIndex: Number,
temporary: { type: Boolean, default: true },
};

connect() {
this.defaultZIndexValue = this.zIndexValue;
this.defaultCaptiveValue = this.captiveValue;
this.defaultTemporaryValue = this.temporaryValue;
}

async show({
captive = this.defaultCaptiveValue,
zIndex = this.defaultZIndexValue,
top = window.scrollY,
temporary = this.defaultTemporaryValue,
} = {}) {
if (DEBUG) console.debug("request show scrim");

Expand All @@ -46,7 +49,7 @@ export default class ScrimController extends Controller {
if (DEBUG) console.debug("show scrim");

// update state, perform style updates
return this.#show(captive, zIndex, top);
return this.#show(captive, zIndex, top, temporary);
}

async hide() {
Expand All @@ -66,22 +69,27 @@ export default class ScrimController extends Controller {
return this.#hide();
}

beforeCache() {
if (this.temporaryValue) this.hide();
}

dismiss(event) {
if (!this.captiveValue) this.hide(event);
if (!this.captiveValue) this.hide();
}

escape(event) {
if (event.key === "Escape" && !this.captiveValue && !event.defaultPrevented)
this.hide(event);
this.hide();
}

/**
* Clips body to viewport size and sets the z-index
*/
async #show(captive, zIndex, top) {
async #show(captive, zIndex, top, temporary) {
this.captiveValue = captive;
this.zIndexValue = zIndex;
this.scrollY = top;
this.temporaryValue = temporary;

this.previousPosition = document.body.style.position;
this.previousTop = document.body.style.top;
Expand All @@ -97,6 +105,7 @@ export default class ScrimController extends Controller {
async #hide() {
this.captiveValue = this.defaultCaptiveValue;
this.zIndexValue = this.defaultZIndexValue;
this.temporaryValue = this.defaultTemporaryValue;

resetStyle(this.element, "z-index", null);
resetStyle(document.body, "position", null);
Expand Down
9 changes: 6 additions & 3 deletions app/components/kpop/modal_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ class ModalComponent < ViewComponent::Base
renders_one :header
renders_one :footer

def initialize(title:, captive: false, **)
def initialize(title:, captive: false, temporary: true, **)
super

@temporary = temporary

# Generate a title bar. This can be overridden by calling title_bar again.
with_title(title:, captive:) if title.present?
end
Expand All @@ -25,8 +27,9 @@ def default_html_attributes
{
class: "kpop-modal",
data: {
controller: "kpop--modal",
"kpop--frame-target": "modal",
controller: "kpop--modal",
"kpop--frame-target": "modal",
"kpop--modal-temporary-value": @temporary,
},
}
end
Expand Down
2 changes: 1 addition & 1 deletion app/components/scrim_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ class ScrimComponent < ViewComponent::Base
keyup@window->scrim#escape
scrim:request:hide@window->scrim#hide
scrim:request:show@window->scrim#show
turbo:before-cache@document->scrim#hide
turbo:before-cache@document->scrim#beforeCache
].freeze

def initialize(id: "scrim", z_index: 40)
Expand Down
1 change: 1 addition & 0 deletions lib/kpop/matchers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ def initialize(id: "kpop")

require "kpop/matchers/redirect_to"
require "kpop/matchers/render_kpop"
require "kpop/matchers/kpop_dismiss"

RSpec.configure do |config|
config.include Kpop::Matchers, type: :component
Expand Down
43 changes: 43 additions & 0 deletions lib/kpop/matchers/kpop_dismiss.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# frozen_string_literal: true

require "kpop/matchers"

module Kpop
module Matchers
class RedirectFinder < CapybaraMatcher
def initialize
super("[data-controller='kpop--redirect']")
end
end

# @api private
class RedirectMatcher < BaseMatcher
def match(expected, actual)
actual["data-kpop--redirect-path-value"].to_s.match?(expected)
end

def description
"kpop redirect to #{expected.inspect}"
end

def failure_message
"expected a kpop redirect to #{expected.inspect} but received #{actual.native.to_html.inspect} instead"
end

def failure_message_when_negated
"expected not to find a kpop redirect to #{expected.inspect}"
end
end

# @api public
# Passes if `response` contains a turbo response with a kpop dismiss action.
#
# @example
# expect(response).to kpop_dismiss
def kpop_dismiss(id: "kpop")
ChainedMatcher.new(ResponseMatcher.new,
CapybaraParser,
StreamMatcher.new(id:, action: "update"))
end
end
end
2 changes: 1 addition & 1 deletion spec/dummy/app/controllers/home_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
class HomeController < ApplicationController
def index; end

def show; end
def test; end
end
Loading

0 comments on commit a938ef2

Please sign in to comment.