Skip to content

Commit 2fb60cc

Browse files
author
github-actions
committed
update with project-syncing action
1 parent 8411e71 commit 2fb60cc

File tree

2 files changed

+180
-3
lines changed

2 files changed

+180
-3
lines changed

app/assets/stylesheets/application.css

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,3 @@
1010
*/
1111

1212
@import "custom-image.css";
13-
@import "bootstrap-utilities.css";
14-
@import "pico.pagy.css";
15-
@import "pico.firstdraft.css";
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
3+
// Modal Controller for Stimulus
4+
//
5+
// This controller manages modal dialogs using the native HTML <dialog> element
6+
// combined with Pico CSS framework's modal styling classes.
7+
//
8+
// Expected HTML Structure:
9+
// ------------------------
10+
// <div data-controller="modal">
11+
// <!-- Button to open the modal -->
12+
// <button data-action="click->modal#open">Open Modal</button>
13+
//
14+
// <!-- The modal dialog itself -->
15+
// <dialog data-modal-target="dialog">
16+
// <article>
17+
// <header>
18+
// <!-- Close button (X) in the header -->
19+
// <button aria-label="Close" rel="prev" data-action="click->modal#close"></button>
20+
// <h3>Modal Title</h3>
21+
// </header>
22+
//
23+
// <!-- Modal content goes here -->
24+
// <p>Your content...</p>
25+
//
26+
// <footer>
27+
// <!-- Cancel and confirm buttons -->
28+
// <button type="button" data-action="click->modal#close">Cancel</button>
29+
// <button type="submit" data-action="click->modal#confirm">Confirm</button>
30+
// </footer>
31+
// </article>
32+
// </dialog>
33+
// </div>
34+
35+
export default class extends Controller {
36+
// Stimulus targets - defines which elements this controller can access
37+
// In the HTML, use data-modal-target="dialog" on the <dialog> element
38+
static targets = ["dialog"]
39+
40+
// Stimulus values - configurable options for the controller
41+
// closeOnBackdrop: whether clicking outside the modal closes it (default: true)
42+
// Can be set in HTML with data-modal-close-on-backdrop-value="false"
43+
static values = { closeOnBackdrop: { type: Boolean, default: true } }
44+
45+
// Called when the controller is connected to the DOM
46+
connect() {
47+
// Create bound versions of event handlers to maintain the correct 'this' context
48+
// This ensures we can properly remove these exact listeners later
49+
this._boundHandleBackdrop = this._handleBackdrop.bind(this)
50+
this._boundHandleCancel = this._handleCancel.bind(this)
51+
}
52+
53+
// Opens the modal - triggered by data-action="click->modal#open"
54+
open(event) {
55+
// Prevent default action (e.g., form submission or link navigation)
56+
event?.preventDefault()
57+
58+
// Store the currently focused element so we can return focus after closing
59+
// This is important for accessibility
60+
this._lastFocused = document.activeElement
61+
62+
// Clear any leftover closing animation class from previous modal interactions
63+
// This prevents animation conflicts
64+
document.documentElement.classList.remove("modal-is-closing")
65+
66+
// Add Pico CSS framework classes to the <html> element:
67+
// - modal-is-open: locks scrolling on the page behind the modal
68+
// - modal-is-opening: triggers the opening animation
69+
document.documentElement.classList.add("modal-is-open", "modal-is-opening")
70+
71+
// Get the dialog element using Stimulus targets
72+
const d = this.dialogTarget
73+
74+
// Clean up any existing event listeners before adding new ones
75+
// This prevents duplicate listeners if the modal is opened multiple times
76+
this._removeEventListeners()
77+
78+
// Add event listeners:
79+
// - "cancel": fired when user presses ESC key
80+
// - "click": to detect clicks on the backdrop (outside the modal content)
81+
d.addEventListener("cancel", this._boundHandleCancel)
82+
d.addEventListener("click", this._boundHandleBackdrop)
83+
84+
// Use the native HTML dialog showModal() method
85+
// This creates a modal with a backdrop and makes the rest of the page inert
86+
d.showModal()
87+
88+
// Remove the opening animation class after the animation completes (400ms)
89+
// This matches Pico CSS's animation duration
90+
setTimeout(() => {
91+
document.documentElement.classList.remove("modal-is-opening")
92+
}, 400)
93+
94+
// Focus management for accessibility:
95+
// First try to focus an element with [autofocus] attribute
96+
// If none exists, focus the first interactive element
97+
const focusable = d.querySelector("[autofocus]") ||
98+
d.querySelector("button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])")
99+
focusable?.focus()
100+
}
101+
102+
// Closes the modal - triggered by data-action="click->modal#close"
103+
close(event) {
104+
// Prevent default action
105+
event?.preventDefault()
106+
107+
// Safety check: don't try to close if already closed
108+
if (!this.dialogTarget.open) return
109+
110+
const d = this.dialogTarget
111+
112+
// Add closing animation class to trigger Pico CSS closing animation
113+
document.documentElement.classList.add("modal-is-closing")
114+
115+
// Wait for closing animation to complete (400ms), then:
116+
setTimeout(() => {
117+
// Remove all modal-related classes from <html>
118+
document.documentElement.classList.remove("modal-is-closing", "modal-is-open")
119+
120+
// Use native dialog close() method
121+
d.close()
122+
123+
// Clean up event listeners to prevent memory leaks
124+
this._removeEventListeners()
125+
126+
// Return focus to the element that opened the modal (accessibility)
127+
this._lastFocused?.focus()
128+
}, 400)
129+
}
130+
131+
// Handles the confirm action - triggered by data-action="click->modal#confirm"
132+
// This is useful for forms or confirmation dialogs
133+
confirm(event) {
134+
event?.preventDefault()
135+
136+
// Close the modal
137+
this.close()
138+
139+
// Dispatch a custom 'confirm' event that other parts of your app can listen for
140+
// Example: document.querySelector('[data-controller="modal"]').addEventListener('modal:confirm', (e) => {...})
141+
this.dispatch("confirm")
142+
}
143+
144+
// Private method: Handles clicks on the modal backdrop
145+
_handleBackdrop(e) {
146+
// The <dialog> element covers the entire viewport when modal
147+
// The <article> inside it is the actual modal content
148+
// If the click target is NOT inside the article, it's on the backdrop
149+
const article = this.dialogTarget.querySelector("article")
150+
151+
// Only close if:
152+
// 1. Click was outside the article (on the backdrop)
153+
// 2. closeOnBackdrop setting is true
154+
if (!article.contains(e.target) && this.closeOnBackdropValue) {
155+
this.close()
156+
}
157+
}
158+
159+
// Private method: Handles the ESC key press (cancel event)
160+
_handleCancel(e) {
161+
// Prevent the default dialog closing behavior
162+
// We want to use our own close() method to handle animations
163+
e.preventDefault()
164+
this.close()
165+
}
166+
167+
// Private method: Removes event listeners to prevent memory leaks
168+
_removeEventListeners() {
169+
const d = this.dialogTarget
170+
// Remove the bound listeners (must use the same references created in connect())
171+
d.removeEventListener("cancel", this._boundHandleCancel)
172+
d.removeEventListener("click", this._boundHandleBackdrop)
173+
}
174+
175+
// Called when the controller is disconnected from the DOM
176+
disconnect() {
177+
// Clean up any remaining event listeners
178+
this._removeEventListeners()
179+
}
180+
}

0 commit comments

Comments
 (0)