Skip to content

Commit 5226c3e

Browse files
Add l_ui_modal helper and external-trigger support (#82)
Wraps the modal dialog/controller in a builder-style helper with a colocated `m.trigger` and supports opening modals from anywhere via `data-l-ui-modal-open="<id>"`. Also fixes `l-ui-modal__body` to fill the dialog height.
1 parent 463e78d commit 5226c3e

11 files changed

Lines changed: 525 additions & 54 deletions

File tree

.claude/skills/layered-ui-rails/references/HELPERS.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,46 @@ l_ui_field_error_id(record, attribute) # Error element ID for aria-describedby
246246
l_ui_field_hint_id(record, attribute) # Hint element ID for aria-describedby
247247
```
248248

249+
## Modal
250+
251+
```ruby
252+
l_ui_modal(title:, id: nil, heading_level: :h3, container: {}, &block)
253+
```
254+
255+
- `title` (String) - heading shown in the modal header and used for `aria-labelledby`
256+
- `id` (String, optional) - DOM id for the `<dialog>`; defaults to an auto-generated id
257+
- `heading_level` (Symbol, optional) - heading tag for the title (e.g. `:h2`, `:h3`). Defaults to `:h3`
258+
- `container` (Hash, optional) - extra HTML attributes for the wrapping `<div>` (e.g. `class:`)
259+
- `&block` - the block's content is the modal body; call `m.trigger(**options, &block)` inside it to render a colocated trigger button
260+
261+
```erb
262+
<%= l_ui_modal(title: "Socials") do |m| %>
263+
<% m.trigger(class: "l-ui-button l-ui-button--outline") do %>
264+
Open socials
265+
<% end %>
266+
<p>Body content.</p>
267+
<% end %>
268+
```
269+
270+
Renders the trigger `<button>` (if `m.trigger` is called), the `<dialog class="l-ui-modal">` with a header (title + close button), and wires the `l-ui--modal` Stimulus controller, including backdrop-click close.
271+
272+
If the trigger contains only an icon (no visible text), pass `aria-label:` to `m.trigger` so the button has an accessible name.
273+
274+
`m.trigger` renders a colocated trigger button. To open the same modal from elsewhere on the page (or to have multiple triggers), give the modal a known `id:` and add `data-l-ui-modal-open="<that id>"` to any button:
275+
276+
```erb
277+
<%= l_ui_modal(title: "Confirm", id: "confirm-modal") do |m| %>
278+
<% m.trigger(class: "l-ui-button") do %>Open<% end %>
279+
<p>Body content.</p>
280+
<% end %>
281+
282+
<button type="button" data-l-ui-modal-open="confirm-modal">Open from elsewhere</button>
283+
```
284+
285+
The button does not need to be inside the helper's wrapper, and no `data-controller` or `data-action` is required - the `l-ui--modal` controller listens at the document level for clicks on `[data-l-ui-modal-open]` matching its dialog id.
286+
287+
Calling `dialog.showModal()` directly is not supported - it bypasses the `l-ui--modal` controller and skips scroll lock, focus restoration, open-count tracking, and the screen-reader announcement.
288+
249289
## Authentication
250290

251291
```ruby

app/assets/tailwind/layered/ui/styles.css

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1787,6 +1787,10 @@ pre.l-ui-surface {
17871787
border-0 md:border md:border-border rounded-none md:rounded-sm;
17881788
}
17891789

1790+
.l-ui-modal[open] {
1791+
@apply flex flex-col;
1792+
}
1793+
17901794
.l-ui-modal::backdrop {
17911795
@apply backdrop-blur-sm;
17921796
}
@@ -1798,7 +1802,7 @@ pre.l-ui-surface {
17981802
}
17991803

18001804
.l-ui-modal__body {
1801-
@apply overflow-y-auto
1805+
@apply flex-1 min-h-0 overflow-y-auto
18021806
px-5 py-4;
18031807
}
18041808

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
module Layered
2+
module Ui
3+
module ModalHelper
4+
# Renders a modal as a <dialog> wired up to the +l-ui--modal+ Stimulus
5+
# controller. The block's content is the modal body; call +m.trigger+ to
6+
# render a colocated trigger button.
7+
#
8+
# <%= l_ui_modal(title: "Socials") do |m| %>
9+
# <% m.trigger(class: "l-ui-button l-ui-button--outline") do %>
10+
# Open socials
11+
# <% end %>
12+
# <p>Body content.</p>
13+
# <% end %>
14+
#
15+
# To open the modal from elsewhere on the page (or to have multiple
16+
# triggers), give it a known +id:+ and add +data-l-ui-modal-open="<id>"+
17+
# to any button. The button does not need to live inside the helper's
18+
# wrapper; the +l-ui--modal+ controller listens at the document level
19+
# for matching clicks.
20+
#
21+
# <button type="button" data-l-ui-modal-open="confirm-modal">Open</button>
22+
#
23+
# Calling +dialog.showModal()+ directly is not supported: it bypasses the
24+
# +l-ui--modal+ controller and skips scroll lock, focus restoration, open-
25+
# count tracking, and the screen-reader announcement.
26+
#
27+
# Note: use +<% m.trigger %>+ (without the equals sign) so its content is
28+
# captured by the builder rather than written to the body buffer.
29+
#
30+
# Options:
31+
# title: (String) Required. Modal heading; also used for aria-labelledby.
32+
# id: (String) DOM id for the <dialog>; defaults to an auto-generated id.
33+
# heading_level: (Symbol) Heading tag for the title (e.g. :h2, :h3). Defaults to :h3.
34+
# container: (Hash) Extra HTML attributes for the wrapping <div>.
35+
def l_ui_modal(title:, id: nil, heading_level: :h3, container: {}, &block)
36+
id ||= "l-ui-modal-#{SecureRandom.hex(4)}"
37+
builder = ModalBuilder.new(self, title: title, id: id, heading_level: heading_level)
38+
body_content = capture { block.call(builder) }
39+
40+
container_attrs = container.deep_dup
41+
container_data = container_attrs[:data] || {}
42+
existing_controller = container_data.delete(:controller) || container_data.delete("controller")
43+
container_data[:controller] = [existing_controller, "l-ui--modal"].compact.reject(&:empty?).join(" ")
44+
container_attrs[:data] = container_data
45+
46+
tag.div(**container_attrs) do
47+
safe_join([
48+
builder.trigger_html || ActiveSupport::SafeBuffer.new,
49+
render_modal_dialog(builder, body_content)
50+
])
51+
end
52+
end
53+
54+
class ModalBuilder
55+
attr_reader :title, :id, :heading_level, :trigger_html
56+
57+
def initialize(view, title:, id:, heading_level: :h3)
58+
@view = view
59+
@title = title
60+
@id = id
61+
@heading_level = heading_level
62+
end
63+
64+
def trigger(**options, &block)
65+
options = options.deep_dup
66+
options[:type] ||= "button"
67+
data = options[:data] || {}
68+
existing_action = data.delete(:action) || data.delete("action")
69+
data[:action] = [existing_action, "l-ui--modal#open"].compact.reject(&:empty?).join(" ")
70+
options[:data] = data
71+
content = @view.capture(&block)
72+
@trigger_html = @view.tag.button(content, **options)
73+
nil
74+
end
75+
end
76+
77+
private
78+
79+
def render_modal_dialog(builder, body_content)
80+
heading_id = "#{builder.id}-heading"
81+
82+
dialog_attrs = {
83+
id: builder.id,
84+
class: "l-ui-modal",
85+
data: {
86+
"l-ui--modal-target" => "dialog",
87+
"action" => "click->l-ui--modal#closeOnBackdrop"
88+
},
89+
aria: { labelledby: heading_id }
90+
}
91+
92+
tag.dialog(**dialog_attrs) do
93+
safe_join([
94+
tag.div(class: "l-ui-modal__header") do
95+
safe_join([
96+
content_tag(builder.heading_level, builder.title, id: heading_id),
97+
tag.button(
98+
image_tag("layered_ui/icon_close.svg", alt: "", class: "l-ui-icon l-ui-icon--sm", aria: { hidden: true }),
99+
class: "l-ui-button l-ui-button--icon",
100+
type: "button",
101+
data: { action: "l-ui--modal#close" },
102+
"aria-label" => "Close"
103+
)
104+
])
105+
end,
106+
tag.div(body_content, class: "l-ui-modal__body")
107+
])
108+
end
109+
end
110+
end
111+
end
112+
end

app/javascript/layered_ui/controllers/l_ui/modal_controller.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ export default class extends Controller {
5757
}
5858
}
5959

60-
// Handle the close event on the dialog
60+
// Handle the close event on the dialog and document-level clicks from
61+
// external triggers (any button with data-l-ui-modal-open="<dialog id>").
6162
dialogTargetConnected(element) {
6263
this._closeHandler = () => {
6364
this.constructor.openCount = Math.max(0, this.constructor.openCount - 1)
@@ -71,11 +72,21 @@ export default class extends Controller {
7172
announce("Dialog closed", this)
7273
}
7374
element.addEventListener("close", this._closeHandler)
75+
76+
this._externalOpenHandler = (event) => {
77+
if (!element.id) return
78+
const trigger = event.target.closest(`[data-l-ui-modal-open="${CSS.escape(element.id)}"]`)
79+
if (!trigger) return
80+
event.preventDefault()
81+
this.open()
82+
}
83+
document.addEventListener("click", this._externalOpenHandler)
7484
}
7585

76-
// Remove the close event listener on the dialog
86+
// Remove listeners
7787
dialogTargetDisconnected(element) {
7888
element.removeEventListener("close", this._closeHandler)
89+
document.removeEventListener("click", this._externalOpenHandler)
7990
}
8091

8192
disconnect() {

lib/layered/ui/engine.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class Engine < ::Rails::Engine
3333
helper Layered::Ui::TableHelper
3434
helper Layered::Ui::TitleBarHelper
3535
helper Layered::Ui::FormHelper
36+
helper Layered::Ui::ModalHelper
3637
helper Layered::Ui::RansackHelper
3738
end
3839
end

test/dummy/app/controllers/pages_controller.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ def layout_icons
6161
def tabs
6262
end
6363

64+
def modals
65+
end
66+
6467
def conversations
6568
end
6669

test/dummy/app/views/layouts/application.html.erb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
<%= l_ui_navigation_item "Badges", badges_path %>
2828
<%= l_ui_navigation_item "Notices", notices_path %>
2929
<%= l_ui_navigation_item "Tabs", tabs_path %>
30+
<%= l_ui_navigation_item "Modals", modals_path %>
3031
<%= l_ui_navigation_item "Conversations", conversations_path %>
3132
<%= l_ui_navigation_item "Pagination", pagination_path %>
3233
<% end %>

test/dummy/app/views/pages/_socials.html.erb

Lines changed: 41 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -42,64 +42,54 @@
4242
<% end %>
4343
</div>
4444

45-
<div class="lg:hidden mt-4" data-controller="l-ui--modal">
46-
<button type="button" class="l-ui-button l-ui-button--outline l-ui-button--full" data-action="l-ui--modal#open">
45+
<%= l_ui_modal(title: "Socials", id: "modal-socials", container: { class: "lg:hidden mt-4" }) do |m| %>
46+
<% m.trigger(class: "l-ui-button l-ui-button--outline l-ui-button--full") do %>
4747
<div class="flex items-center justify-center gap-2">
4848
<%= image_tag "layered_ui/icon_globe.svg", class: "l-ui-icon l-ui-icon--xs", alt: "", aria: { hidden: true } %>
4949
<span>Socials</span>
5050
</div>
51-
</button>
52-
<dialog class="l-ui-modal" data-l-ui--modal-target="dialog" data-action="click->l-ui--modal#closeOnBackdrop" aria-labelledby="modal-socials-heading">
53-
<div class="l-ui-modal__header">
54-
<h3 id="modal-socials-heading">Socials</h3>
55-
<button class="l-ui-button l-ui-button--icon" data-action="l-ui--modal#close" aria-label="Close">
56-
<%= image_tag "layered_ui/icon_close.svg", alt: "", class: "l-ui-icon l-ui-icon--sm", aria: { hidden: true } %>
57-
</button>
58-
</div>
59-
<div class="l-ui-modal__body">
60-
<div class="flex flex-col gap-4">
61-
<%= link_to "https://www.layered.ai/", target: "_blank", rel: "noopener noreferrer", class: "l-ui-button l-ui-button--outline" do %>
62-
<div class="flex items-center gap-2">
63-
<%= image_tag "layered_ui/icon_globe.svg", class: "l-ui-icon l-ui-icon--xs", alt: "", aria: { hidden: true } %>
64-
<span>Website</span>
65-
</div>
66-
<% end %>
51+
<% end %>
52+
<div class="flex flex-col gap-4">
53+
<%= link_to "https://www.layered.ai/", target: "_blank", rel: "noopener noreferrer", class: "l-ui-button l-ui-button--outline" do %>
54+
<div class="flex items-center gap-2">
55+
<%= image_tag "layered_ui/icon_globe.svg", class: "l-ui-icon l-ui-icon--xs", alt: "", aria: { hidden: true } %>
56+
<span>Website</span>
57+
</div>
58+
<% end %>
6759

68-
<%= link_to "https://github.com/layered-ai-public/layered-ui-rails", target: "_blank", rel: "noopener noreferrer", class: "l-ui-button l-ui-button--outline" do %>
69-
<div class="flex items-center gap-2">
70-
<%= image_tag "layered_ui/icon_github.svg", class: "l-ui-icon l-ui-icon--xs", alt: "", aria: { hidden: true } %>
71-
<span>GitHub</span>
72-
</div>
73-
<% end %>
60+
<%= link_to "https://github.com/layered-ai-public/layered-ui-rails", target: "_blank", rel: "noopener noreferrer", class: "l-ui-button l-ui-button--outline" do %>
61+
<div class="flex items-center gap-2">
62+
<%= image_tag "layered_ui/icon_github.svg", class: "l-ui-icon l-ui-icon--xs", alt: "", aria: { hidden: true } %>
63+
<span>GitHub</span>
64+
</div>
65+
<% end %>
7466

75-
<%= link_to "https://www.youtube.com/@UseLayeredAi", target: "_blank", rel: "noopener noreferrer", class: "l-ui-button l-ui-button--outline" do %>
76-
<div class="flex items-center gap-2">
77-
<%= image_tag "layered_ui/icon_youtube.svg", class: "l-ui-icon l-ui-icon--xs", alt: "", aria: { hidden: true } %>
78-
<span>YouTube</span>
79-
</div>
80-
<% end %>
67+
<%= link_to "https://www.youtube.com/@UseLayeredAi", target: "_blank", rel: "noopener noreferrer", class: "l-ui-button l-ui-button--outline" do %>
68+
<div class="flex items-center gap-2">
69+
<%= image_tag "layered_ui/icon_youtube.svg", class: "l-ui-icon l-ui-icon--xs", alt: "", aria: { hidden: true } %>
70+
<span>YouTube</span>
71+
</div>
72+
<% end %>
8173

82-
<%= link_to "https://discord.gg/aCGqz9Bx", target: "_blank", rel: "noopener noreferrer", class: "l-ui-button l-ui-button--outline" do %>
83-
<div class="flex items-center gap-2">
84-
<%= image_tag "layered_ui/icon_discord.svg", class: "l-ui-icon l-ui-icon--xs", alt: "", aria: { hidden: true } %>
85-
<span>Discord</span>
86-
</div>
87-
<% end %>
74+
<%= link_to "https://discord.gg/aCGqz9Bx", target: "_blank", rel: "noopener noreferrer", class: "l-ui-button l-ui-button--outline" do %>
75+
<div class="flex items-center gap-2">
76+
<%= image_tag "layered_ui/icon_discord.svg", class: "l-ui-icon l-ui-icon--xs", alt: "", aria: { hidden: true } %>
77+
<span>Discord</span>
78+
</div>
79+
<% end %>
8880

89-
<%= link_to "https://x.com/UseLayeredAi", target: "_blank", rel: "noopener noreferrer", class: "l-ui-button l-ui-button--outline" do %>
90-
<div class="flex items-center gap-2">
91-
<%= image_tag "layered_ui/icon_x.svg", class: "l-ui-icon l-ui-icon--xs", alt: "", aria: { hidden: true } %>
92-
<span>X</span>
93-
</div>
94-
<% end %>
81+
<%= link_to "https://x.com/UseLayeredAi", target: "_blank", rel: "noopener noreferrer", class: "l-ui-button l-ui-button--outline" do %>
82+
<div class="flex items-center gap-2">
83+
<%= image_tag "layered_ui/icon_x.svg", class: "l-ui-icon l-ui-icon--xs", alt: "", aria: { hidden: true } %>
84+
<span>X</span>
85+
</div>
86+
<% end %>
9587

96-
<%= link_to "https://www.linkedin.com/company/uselayeredai/", target: "_blank", rel: "noopener noreferrer", class: "l-ui-button l-ui-button--outline" do %>
97-
<div class="flex items-center gap-2">
98-
<%= image_tag "layered_ui/icon_linkedin.svg", class: "l-ui-icon l-ui-icon--xs", alt: "", aria: { hidden: true } %>
99-
<span>LinkedIn</span>
100-
</div>
101-
<% end %>
88+
<%= link_to "https://www.linkedin.com/company/uselayeredai/", target: "_blank", rel: "noopener noreferrer", class: "l-ui-button l-ui-button--outline" do %>
89+
<div class="flex items-center gap-2">
90+
<%= image_tag "layered_ui/icon_linkedin.svg", class: "l-ui-icon l-ui-icon--xs", alt: "", aria: { hidden: true } %>
91+
<span>LinkedIn</span>
10292
</div>
103-
</div>
104-
</dialog>
105-
</div>
93+
<% end %>
94+
</div>
95+
<% end %>

0 commit comments

Comments
 (0)