-
Notifications
You must be signed in to change notification settings - Fork 24
Open additional story images in an inline lightbox #1550
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| import { Controller } from "@hotwired/stimulus" | ||
|
|
||
| // Connects to data-controller="lightbox" | ||
| // | ||
| // Opens gallery images in an inline modal instead of navigating away, and lets | ||
| // the viewer scroll through all of the gallery's images with next/previous | ||
| // controls, the arrow keys, or by closing with Escape / a backdrop click. | ||
| // | ||
| // Each clickable image is an "item" target whose href points at the full-size | ||
| // image. The modal, image, counter, and nav buttons live in the same scope. | ||
| export default class extends Controller { | ||
| static targets = ["item", "modal", "image", "counter", "prev", "next"] | ||
| static values = { index: Number } | ||
|
|
||
| open(event) { | ||
| const index = this.itemTargets.indexOf(event.currentTarget) | ||
| if (index === -1) return | ||
|
|
||
| this.indexValue = index | ||
| this.modalTarget.classList.remove("hidden") | ||
| this.modalTarget.classList.add("flex") | ||
| document.body.classList.add("overflow-hidden") | ||
| this.modalTarget.focus() | ||
| } | ||
|
|
||
| close() { | ||
| this.modalTarget.classList.add("hidden") | ||
| this.modalTarget.classList.remove("flex") | ||
| document.body.classList.remove("overflow-hidden") | ||
| } | ||
|
|
||
| next() { | ||
| if (this.isClosed) return | ||
| this.indexValue = (this.indexValue + 1) % this.itemTargets.length | ||
| } | ||
|
|
||
| prev() { | ||
| if (this.isClosed) return | ||
| const count = this.itemTargets.length | ||
| this.indexValue = (this.indexValue - 1 + count) % count | ||
| } | ||
|
|
||
| closeOnEscape() { | ||
| if (this.isClosed) return | ||
| this.close() | ||
| } | ||
|
|
||
| backdropClose(event) { | ||
| if (event.target === this.modalTarget) this.close() | ||
| } | ||
|
|
||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The full-size image URL is read from each item's |
||
| indexValueChanged() { | ||
| const item = this.itemTargets[this.indexValue] | ||
| if (!item) return | ||
|
|
||
| this.imageTarget.src = item.getAttribute("href") | ||
| this.imageTarget.alt = item.dataset.caption || "" | ||
|
|
||
| if (this.hasCounterTarget) { | ||
| this.counterTarget.textContent = `${this.indexValue + 1} of ${this.itemTargets.length}` | ||
| } | ||
|
|
||
| const single = this.itemTargets.length <= 1 | ||
| if (this.hasPrevTarget) this.prevTarget.classList.toggle("hidden", single) | ||
| if (this.hasNextTarget) this.nextTarget.classList.toggle("hidden", single) | ||
| } | ||
|
|
||
| disconnect() { | ||
| document.body.classList.remove("overflow-hidden") | ||
| } | ||
|
|
||
| get isClosed() { | ||
| return this.modalTarget.classList.contains("hidden") | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,12 +1,28 @@ | ||
| <!-- Gallery Media --> | ||
| <% gallery_assets = resource.gallery_assets.select { |img| img.file.attached? } %> | ||
| <% align = (defined?(align) && align.present?) ? align.to_s : "center" %> | ||
| <% link = (defined?(link) ? link : nil) %> | ||
| <% if gallery_assets.any? %> | ||
| <div class="<%= resource.class.table_name %>-gallery text-<%= align %> mb-4"> | ||
| <div class="<%= resource.class.table_name %>-gallery text-<%= align %> mb-4" | ||
| data-controller="lightbox" | ||
| data-action="keydown.esc@window->lightbox#closeOnEscape keydown.left@window->lightbox#prev keydown.right@window->lightbox#next"> | ||
|
Comment on lines
+6
to
+8
|
||
| <div class="flex flex-wrap justify-<%= align %> gap-4 <%= 'mx-auto' if align == 'center' %> max-w-4xl"> | ||
| <% gallery_assets.each_with_index do |gallery_assets, idx| %> | ||
| <%= render "assets/display_image", item: gallery_assets, idx: idx, variant: :gallery, link: (defined?(link) ? link : nil) %> | ||
| <% gallery_assets.each_with_index do |gallery_asset, idx| %> | ||
| <% if link && gallery_asset.file.content_type.to_s.start_with?("image/") %> | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Lightbox is gated on |
||
| <a href="<%= url_for(gallery_asset.file) %>" | ||
| class="flex grow" | ||
| data-lightbox-target="item" | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The anchor keeps |
||
| data-caption="<%= gallery_asset.file.filename %>" | ||
| data-action="lightbox#open:prevent"> | ||
| <%= render "assets/display_image", item: gallery_asset, idx: idx, variant: :gallery, link: false %> | ||
| </a> | ||
| <% else %> | ||
| <%= render "assets/display_image", item: gallery_asset, idx: idx, variant: :gallery, link: link %> | ||
| <% end %> | ||
| <% end %> | ||
| </div> | ||
| <% if link %> | ||
| <%= render "assets/lightbox" %> | ||
| <% end %> | ||
| </div> | ||
| <% end %> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| <div class="hidden fixed inset-0 z-50 items-center justify-center bg-black/80 p-4" | ||
| tabindex="-1" | ||
| role="dialog" | ||
| aria-modal="true" | ||
| aria-label="Image gallery" | ||
| data-lightbox-target="modal" | ||
| data-action="click->lightbox#backdropClose"> | ||
| <button type="button" | ||
| class="absolute top-4 right-4 text-white/80 hover:text-white text-3xl leading-none px-2" | ||
| aria-label="Close" | ||
| data-action="lightbox#close"> | ||
| <i class="fa-solid fa-xmark"></i> | ||
| </button> | ||
| <button type="button" | ||
| class="absolute left-2 sm:left-4 top-1/2 -translate-y-1/2 text-white/80 hover:text-white text-4xl leading-none px-2" | ||
| aria-label="Previous image" | ||
| data-lightbox-target="prev" | ||
| data-action="lightbox#prev"> | ||
| <i class="fa-solid fa-chevron-left"></i> | ||
| </button> | ||
| <figure class="flex max-h-full max-w-4xl flex-col items-center"> | ||
| <img class="max-h-[80vh] max-w-full rounded object-contain shadow-lg" | ||
| alt="" | ||
| data-lightbox-target="image"> | ||
| <figcaption class="mt-3 text-center text-sm text-white/80" | ||
| data-lightbox-target="counter"></figcaption> | ||
| </figure> | ||
| <button type="button" | ||
| class="absolute right-2 sm:right-4 top-1/2 -translate-y-1/2 text-white/80 hover:text-white text-4xl leading-none px-2" | ||
| aria-label="Next image" | ||
| data-lightbox-target="next" | ||
| data-action="lightbox#next"> | ||
| <i class="fa-solid fa-chevron-right"></i> | ||
| </button> | ||
| </div> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| require "rails_helper" | ||
|
|
||
| RSpec.describe "Story gallery lightbox", type: :system do | ||
| before { driven_by(:selenium_chrome_headless) } | ||
|
|
||
| it "opens additional images inline and scrolls through them" do | ||
| sign_in create(:user) | ||
|
|
||
| story = create(:story, :published, title: "A story with extra images") | ||
| create(:gallery_asset, :with_file, owner: story) | ||
| create(:gallery_asset, :with_file, owner: story) | ||
|
|
||
| visit story_path(story) | ||
|
|
||
| expect(page).to have_css("[data-controller='lightbox']") | ||
| expect(page).to have_css("[data-lightbox-target='item']", count: 2) | ||
|
|
||
| # Modal starts hidden | ||
| modal = find("[data-lightbox-target='modal']", visible: :all) | ||
| expect(modal[:class]).to include("hidden") | ||
|
|
||
|
|
||
| # Clicking an additional image opens it inline rather than navigating away | ||
| all("[data-lightbox-target='item']").first.click | ||
|
|
||
| expect(page).to have_current_path(story_path(story)) | ||
| expect(modal[:class]).not_to include("hidden") | ||
|
|
||
| expect(page).to have_css("[data-lightbox-target='counter']", text: "1 of 2") | ||
|
|
||
| # Next/previous controls scroll through the set | ||
| find("[data-lightbox-target='next']").click | ||
| expect(page).to have_css("[data-lightbox-target='counter']", text: "2 of 2") | ||
|
|
||
| find("[data-lightbox-target='prev']").click | ||
| expect(page).to have_css("[data-lightbox-target='counter']", text: "1 of 2") | ||
|
|
||
| # Closing dismisses the modal | ||
| find("[data-action='lightbox#close']").click | ||
| expect(modal[:class]).to include("hidden") | ||
|
|
||
| end | ||
| end | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Prev/next wrap around the set (modulo length) so the viewer can keep scrolling in either direction without hitting a dead end.