bootstrap5-rails-extensions 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +15 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +221 -0
- data/LICENSE.txt +21 -0
- data/README.md +192 -0
- data/Rakefile +6 -0
- data/app/helpers/bootstrap5_rails_extensions/card_helper.rb +143 -0
- data/app/helpers/bootstrap5_rails_extensions/modal_helper.rb +227 -0
- data/app/helpers/bootstrap5_rails_extensions/offcanvas_helper.rb +67 -0
- data/app/helpers/bootstrap5_rails_extensions/table_helper.rb +93 -0
- data/app/helpers/bootstrap5_rails_extensions/toast_helper.rb +33 -0
- data/app/javascript/bootstrap5_rails_extensions/modal_controller.js +88 -0
- data/app/javascript/bootstrap5_rails_extensions/offcanvas_controller.js +62 -0
- data/app/javascript/bootstrap5_rails_extensions/toast_controller.js +31 -0
- data/app/views/bootstrap5_rails_extensions/_modal.html.erb +6 -0
- data/app/views/bootstrap5_rails_extensions/_offcanvas.html.erb +15 -0
- data/app/views/bootstrap5_rails_extensions/_toast.html.erb +15 -0
- data/bin/console +12 -0
- data/bin/setup +8 -0
- data/bootstrap5_rails_extensions.gemspec +46 -0
- data/lib/bootstrap5/rails/extensions.rb +3 -0
- data/lib/bootstrap5_rails_extensions/engine.rb +32 -0
- data/lib/bootstrap5_rails_extensions/turbo_stream_toast.rb +42 -0
- data/lib/bootstrap5_rails_extensions/version.rb +5 -0
- data/lib/bootstrap5_rails_extensions.rb +5 -0
- metadata +83 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Bootstrap5RailsExtensions
|
|
5
|
+
module ModalHelper
|
|
6
|
+
# Stripeダッシュボードを意識したBootstrap 5.3モーダルのDSLを提供するヘルパー。
|
|
7
|
+
#
|
|
8
|
+
# 使い方:
|
|
9
|
+
# <%= render_modal id: "exampleModal", title: "タイトル" do |modal| %>
|
|
10
|
+
# <% modal.body do %>
|
|
11
|
+
# 本文...
|
|
12
|
+
# <% end %>
|
|
13
|
+
# <% end %>
|
|
14
|
+
#
|
|
15
|
+
# フォームを1枚で扱う場合:
|
|
16
|
+
# <%= render_modal id: "userModal", title: "ユーザー追加",
|
|
17
|
+
# form: { model: User.new, url: users_path } do |modal| %>
|
|
18
|
+
# <% modal.body do |f| %>
|
|
19
|
+
# <%= f.text_field :name %>
|
|
20
|
+
# <% end %>
|
|
21
|
+
# <% modal.footer do |f| %>
|
|
22
|
+
# <%= f.submit "保存" %>
|
|
23
|
+
# <% end %>
|
|
24
|
+
# <% end %>
|
|
25
|
+
#
|
|
26
|
+
# オプション:
|
|
27
|
+
# dialog: { size: :sm|:lg|:xl|:fullscreen, centered: true/false, scrollable: true/false }
|
|
28
|
+
# data: data-*属性をマージ(controller: "modal" をデフォルト付与)
|
|
29
|
+
# form: form_withにそのまま渡すオプション(Hash)。指定時はブロックにフォームビルダーを渡す。
|
|
30
|
+
def render_modal(title_or_nil = nil, id: nil, title: nil, dialog: {}, data: {}, form: nil, &block)
|
|
31
|
+
raise ArgumentError, _("モーダルにはブロックが必要です") unless block_given?
|
|
32
|
+
|
|
33
|
+
title ||= title_or_nil if title.nil? && title_or_nil.is_a?(String)
|
|
34
|
+
raise ArgumentError, _("モーダルIDを指定してください") if id.nil? || id.to_s.empty?
|
|
35
|
+
raise ArgumentError, _("モーダルタイトルを指定してください") if title.nil? || title.to_s.empty?
|
|
36
|
+
|
|
37
|
+
dialog_classes = ["modal-dialog"]
|
|
38
|
+
case dialog[:size]&.to_sym
|
|
39
|
+
when :sm then dialog_classes << "modal-sm"
|
|
40
|
+
when :lg then dialog_classes << "modal-lg"
|
|
41
|
+
when :xl then dialog_classes << "modal-xl"
|
|
42
|
+
when :fullscreen then dialog_classes << "modal-fullscreen"
|
|
43
|
+
end
|
|
44
|
+
dialog_classes << "modal-dialog-centered" if dialog[:centered]
|
|
45
|
+
dialog_classes << "modal-dialog-scrollable" if dialog[:scrollable]
|
|
46
|
+
|
|
47
|
+
modal_data = { controller: "modal" }.merge(data || {})
|
|
48
|
+
|
|
49
|
+
builder = ModalBuilder.new(self, modal_id: id, default_title: title)
|
|
50
|
+
|
|
51
|
+
content_markup = if form.present?
|
|
52
|
+
form_options = form.to_h.symbolize_keys
|
|
53
|
+
capture do
|
|
54
|
+
form_with(**form_options) do |form_builder|
|
|
55
|
+
builder.with_form(form_builder) { yield(builder) }
|
|
56
|
+
concat(builder.render_modal_content)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
else
|
|
60
|
+
builder.with_form(nil) { yield(builder) }
|
|
61
|
+
builder.render_modal_content
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
render(
|
|
65
|
+
partial: "bootstrap5_rails_extensions/modal",
|
|
66
|
+
locals: {
|
|
67
|
+
id: id,
|
|
68
|
+
dialog_class: dialog_classes.join(" "),
|
|
69
|
+
data: modal_data,
|
|
70
|
+
content: content_markup,
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# モーダルの各セクションを構築する小さなDSL用ビルダー。
|
|
76
|
+
class ModalBuilder
|
|
77
|
+
def initialize(view_context, modal_id:, default_title:)
|
|
78
|
+
@view = view_context
|
|
79
|
+
@modal_id = modal_id
|
|
80
|
+
@default_title = default_title
|
|
81
|
+
@header_html = nil
|
|
82
|
+
@header_full = false
|
|
83
|
+
@body_fragments = []
|
|
84
|
+
@body_full = nil
|
|
85
|
+
@footer_fragments = []
|
|
86
|
+
@footer_full = nil
|
|
87
|
+
@current_form_builder = nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def with_form(form_builder)
|
|
91
|
+
previous = @current_form_builder
|
|
92
|
+
@current_form_builder = form_builder
|
|
93
|
+
yield(self)
|
|
94
|
+
ensure
|
|
95
|
+
@current_form_builder = previous
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def header(content = nil, full: false, &block)
|
|
99
|
+
raise ArgumentError, _("モーダルヘッダーは一度だけ定義できます") if @header_html
|
|
100
|
+
|
|
101
|
+
html = extract_content(content, &block)
|
|
102
|
+
@header_full = full
|
|
103
|
+
@header_html = html
|
|
104
|
+
self
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def body(content = nil, full: false, &block)
|
|
108
|
+
html = extract_content(content, &block)
|
|
109
|
+
|
|
110
|
+
if full
|
|
111
|
+
raise ArgumentError, _("モーダル本文はfull: trueでは一度だけ設定できます") if @body_full || @body_fragments.any?
|
|
112
|
+
|
|
113
|
+
@body_full = html
|
|
114
|
+
else
|
|
115
|
+
@body_fragments << html
|
|
116
|
+
end
|
|
117
|
+
self
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def footer(content = nil, full: false, &block)
|
|
121
|
+
html = extract_content(content, &block)
|
|
122
|
+
|
|
123
|
+
if full
|
|
124
|
+
raise ArgumentError, _("モーダルフッターはfull: trueでは一度だけ設定できます") if @footer_full || @footer_fragments.any?
|
|
125
|
+
|
|
126
|
+
@footer_full = html
|
|
127
|
+
else
|
|
128
|
+
@footer_fragments << html
|
|
129
|
+
end
|
|
130
|
+
self
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def render_modal_content
|
|
134
|
+
body_markup = build_body_markup
|
|
135
|
+
|
|
136
|
+
header_markup = build_header_markup
|
|
137
|
+
footer_markup = build_footer_markup
|
|
138
|
+
|
|
139
|
+
@view.content_tag(:div, class: "modal-content") do
|
|
140
|
+
@view.concat(header_markup) if header_markup
|
|
141
|
+
@view.concat(body_markup)
|
|
142
|
+
@view.concat(footer_markup) if footer_markup
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
def extract_content(content, &block)
|
|
149
|
+
if block_given?
|
|
150
|
+
if block.arity.positive?
|
|
151
|
+
raise ArgumentError, _("form_withオプションを指定した場合のみフォームビルダーを利用できます") if @current_form_builder.nil?
|
|
152
|
+
|
|
153
|
+
@view.capture(@current_form_builder, &block)
|
|
154
|
+
else
|
|
155
|
+
@view.capture(&block)
|
|
156
|
+
end
|
|
157
|
+
else
|
|
158
|
+
content
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def build_header_markup
|
|
163
|
+
if @header_html
|
|
164
|
+
return @header_html if @header_full
|
|
165
|
+
|
|
166
|
+
@view.content_tag(:div, class: "modal-header") do
|
|
167
|
+
@view.concat(@header_html)
|
|
168
|
+
@view.concat(default_close_button)
|
|
169
|
+
end
|
|
170
|
+
else
|
|
171
|
+
default_header_markup
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def build_body_markup
|
|
176
|
+
if @body_full
|
|
177
|
+
raise ArgumentError, _("モーダル本文が空です") if blank_html?(@body_full)
|
|
178
|
+
|
|
179
|
+
@body_full
|
|
180
|
+
else
|
|
181
|
+
raise ArgumentError, _("モーダル本文を定義してください") if @body_fragments.empty? || blank_html?(@view.safe_join(@body_fragments))
|
|
182
|
+
|
|
183
|
+
@view.content_tag(:div, @view.safe_join(@body_fragments), class: "modal-body")
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def build_footer_markup
|
|
188
|
+
if @footer_full
|
|
189
|
+
return if blank_html?(@footer_full)
|
|
190
|
+
|
|
191
|
+
@footer_full
|
|
192
|
+
elsif @footer_fragments.present?
|
|
193
|
+
fragment_html = @view.safe_join(@footer_fragments)
|
|
194
|
+
return if blank_html?(fragment_html)
|
|
195
|
+
|
|
196
|
+
@view.content_tag(:div, fragment_html, class: "modal-footer")
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def default_header_markup
|
|
201
|
+
@view.content_tag(:div, class: "modal-header") do
|
|
202
|
+
@view.concat(
|
|
203
|
+
@view.content_tag(
|
|
204
|
+
:h5,
|
|
205
|
+
@default_title,
|
|
206
|
+
class: "modal-title",
|
|
207
|
+
id: header_label_id,
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
@view.concat(default_close_button)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def default_close_button
|
|
215
|
+
@view.button_tag("", type: "button", class: "btn-close", data: { action: "click->modal#hide" }, aria: { label: "閉じる" })
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def header_label_id
|
|
219
|
+
"#{@modal_id}Label"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def blank_html?(html)
|
|
223
|
+
html.respond_to?(:blank?) ? html.blank? : html.to_s.strip.empty?
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
module Bootstrap5RailsExtensions
|
|
2
|
+
module OffcanvasHelper
|
|
3
|
+
# Renders a Bootstrap 5.3 Offcanvas using a shared partial. Works with Stimulus/Turbo.
|
|
4
|
+
#
|
|
5
|
+
# TODO: The host app currently coordinates opening Offcanvas + Turbo Frame
|
|
6
|
+
# loads via custom `data-offcanvas-*` attributes handled in a Stimulus controller.
|
|
7
|
+
# Consider standardizing this interface (e.g., provide a helper to render trigger
|
|
8
|
+
# links that play nicely with Turbo without introducing custom attributes) or
|
|
9
|
+
# embracing Bootstrap's data API by adding a reliable integration layer.
|
|
10
|
+
# This is a compromise and should be revisited.
|
|
11
|
+
#
|
|
12
|
+
# Example:
|
|
13
|
+
# <%= render_offcanvas id: "exampleOffcanvas", title: "プレビュー" do %>
|
|
14
|
+
# <turbo-frame id="offcanvas-frame">...</turbo-frame>
|
|
15
|
+
# <% end %>
|
|
16
|
+
# # もしくはタイトルを第1引数で指定(render_modalと同様のインタフェース)
|
|
17
|
+
# <%= render_offcanvas "プレビュー", id: "exampleOffcanvas" do %>
|
|
18
|
+
# ...
|
|
19
|
+
# <% end %>
|
|
20
|
+
#
|
|
21
|
+
# Options:
|
|
22
|
+
# placement: :start|:end|:top|:bottom (default: :end)
|
|
23
|
+
# footer: String/HTML or -> { ... } (Proc)
|
|
24
|
+
# data: additional data-* for the offcanvas root (controller "offcanvas" added by default)
|
|
25
|
+
#
|
|
26
|
+
# 引数:
|
|
27
|
+
# - render_offcanvas id: "...", title: "..." do ... end
|
|
28
|
+
# - render_offcanvas "タイトル", id: "..." do ... end
|
|
29
|
+
def render_offcanvas(title_or_nil = nil, id: nil, title: nil, placement: :end, footer: nil, data: {}, &block)
|
|
30
|
+
# ブロック必須
|
|
31
|
+
raise ArgumentError, "block required for offcanvas body" unless block_given?
|
|
32
|
+
|
|
33
|
+
# 2つの呼び出しシグネチャに対応
|
|
34
|
+
title ||= title_or_nil if title.nil? && title_or_nil.is_a?(String)
|
|
35
|
+
raise ArgumentError, "id is required" if id.nil? || id.to_s.empty?
|
|
36
|
+
raise ArgumentError, "title is required" if title.nil? || title.to_s.empty?
|
|
37
|
+
|
|
38
|
+
placement_class = case placement.to_s
|
|
39
|
+
when "start" then "offcanvas-start"
|
|
40
|
+
when "end" then "offcanvas-end"
|
|
41
|
+
when "top" then "offcanvas-top"
|
|
42
|
+
when "bottom" then "offcanvas-bottom"
|
|
43
|
+
else "offcanvas-end"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Stimulusコントローラをデフォルト付与(上書きも許可)
|
|
47
|
+
offcanvas_data = { controller: "offcanvas" }.merge(data || {})
|
|
48
|
+
|
|
49
|
+
body_html = capture(&block)
|
|
50
|
+
footer_html = if footer.respond_to?(:call)
|
|
51
|
+
capture(&footer)
|
|
52
|
+
else
|
|
53
|
+
footer
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
render partial: "bootstrap5_rails_extensions/offcanvas",
|
|
57
|
+
locals: {
|
|
58
|
+
id: id,
|
|
59
|
+
title: title,
|
|
60
|
+
placement_class: placement_class,
|
|
61
|
+
body: body_html,
|
|
62
|
+
footer: footer_html,
|
|
63
|
+
data: offcanvas_data
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bootstrap5RailsExtensions
|
|
4
|
+
module TableHelper
|
|
5
|
+
# Bootstrapのtableコンポーネント向け簡易DSL。
|
|
6
|
+
#
|
|
7
|
+
# 使い方:
|
|
8
|
+
# <%= render_table class: "table-striped" do |table| %>
|
|
9
|
+
# <%= table.thead class: "table-light" do %>
|
|
10
|
+
# <tr><th>見出し</th></tr>
|
|
11
|
+
# <% end %>
|
|
12
|
+
# <%= table.tbody do %>
|
|
13
|
+
# <tr><td>内容</td></tr>
|
|
14
|
+
# <% end %>
|
|
15
|
+
# <% end %>
|
|
16
|
+
def render_table(data: {}, wrapper_class: nil, wrapper_html: {}, **html_options)
|
|
17
|
+
raise ArgumentError, "theadかtbodyを定義してください" unless block_given?
|
|
18
|
+
|
|
19
|
+
builder = TableBuilder.new(self)
|
|
20
|
+
yield(builder)
|
|
21
|
+
|
|
22
|
+
raise ArgumentError, "theadかtbodyを最低1つは定義してください" if builder.empty?
|
|
23
|
+
|
|
24
|
+
html_options[:class] = build_table_class(html_options[:class])
|
|
25
|
+
html_options[:data] = merge_data_attributes(html_options[:data], data) if data.present?
|
|
26
|
+
|
|
27
|
+
wrapper_options = build_wrapper_options(wrapper_html, wrapper_class)
|
|
28
|
+
|
|
29
|
+
content_tag(:div, **wrapper_options) do
|
|
30
|
+
content_tag(:table, builder.render, **html_options)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def build_wrapper_options(wrapper_html, wrapper_class)
|
|
37
|
+
options = wrapper_html.present? ? wrapper_html.deep_dup : {}
|
|
38
|
+
options[:class] = build_wrapper_class(options[:class], wrapper_class)
|
|
39
|
+
options
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def build_table_class(custom_class)
|
|
43
|
+
classes = ["table", "text-nowrap"]
|
|
44
|
+
classes << custom_class if custom_class.present?
|
|
45
|
+
classes.join(" ")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def build_wrapper_class(custom_class, additional_class)
|
|
49
|
+
classes = ["table-responsive"]
|
|
50
|
+
classes << additional_class if additional_class.present?
|
|
51
|
+
classes << custom_class if custom_class.present?
|
|
52
|
+
classes.join(" ")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def merge_data_attributes(existing, additional)
|
|
56
|
+
(existing || {}).merge(additional)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class TableBuilder
|
|
60
|
+
def initialize(view_context)
|
|
61
|
+
@view = view_context
|
|
62
|
+
@sections = []
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def thead(**options, &block)
|
|
66
|
+
append_section(:thead, options, block)
|
|
67
|
+
self
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def tbody(**options, &block)
|
|
71
|
+
append_section(:tbody, options, block)
|
|
72
|
+
self
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def render
|
|
76
|
+
@view.safe_join(@sections)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def empty?
|
|
80
|
+
@sections.empty?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def append_section(tag_name, options, block)
|
|
86
|
+
raise ArgumentError, "#{tag_name}にはブロックを渡してください" unless block
|
|
87
|
+
|
|
88
|
+
content = @view.capture(&block)
|
|
89
|
+
@sections << @view.content_tag(tag_name, content, **options)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Bootstrap5RailsExtensions
|
|
2
|
+
module ToastHelper
|
|
3
|
+
# コンテナだけを描画(右上固定)。この時点ではStimulusは起動しない。
|
|
4
|
+
# レイアウトで <%= render_toast %> を配置しておき、表示時は turbo_stream.toast で要素を差し込む。
|
|
5
|
+
FLASH_TOAST_COLORS = {
|
|
6
|
+
notice: :success,
|
|
7
|
+
alert: :danger,
|
|
8
|
+
error: :danger,
|
|
9
|
+
warning: :warning,
|
|
10
|
+
info: :info,
|
|
11
|
+
success: :success
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def render_toast(id: "toast-root", position: "top-0 end-0", flash_messages: nil)
|
|
15
|
+
flash_nodes = build_flash_toasts(flash_messages)
|
|
16
|
+
content_tag(:div, safe_join(flash_nodes), id: id, class: "toast-container position-fixed #{position} p-3")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def build_flash_toasts(flash_messages)
|
|
22
|
+
return [] unless flash_messages.respond_to?(:each)
|
|
23
|
+
|
|
24
|
+
flash_messages.each_with_object([]) do |(type, messages), nodes|
|
|
25
|
+
color = FLASH_TOAST_COLORS[type.to_sym] || :secondary
|
|
26
|
+
Array(messages).each do |message|
|
|
27
|
+
nodes << render(partial: "bootstrap5_rails_extensions/toast",
|
|
28
|
+
locals: { msg: { text: message, color: color } })
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { Modal } from "bootstrap"
|
|
3
|
+
|
|
4
|
+
// Stimulusコントローラー(Bootstrap.Modalの薄いラッパー)
|
|
5
|
+
//
|
|
6
|
+
// 目的:
|
|
7
|
+
// - 画面上の任意のトリガーで、モーダルのオープンとTurbo Frameの読み込みを同時に制御する
|
|
8
|
+
// 契約:
|
|
9
|
+
// - トリガー属性:
|
|
10
|
+
// data-modal-target="#modalId" // 開きたいモーダルのセレクタ(必須)
|
|
11
|
+
// data-turbo-frame="frameId" // Turbo FrameのID(指定時はTurboに任せて読み込み)
|
|
12
|
+
// href // 読み込むURL(任意。未指定ならフレーム読み込みはスキップ)
|
|
13
|
+
// 備考:
|
|
14
|
+
// - Bootstrapのdata APIと競合させないため、documentのcapture段階で先取りして手動でshow()する
|
|
15
|
+
export default class extends Controller {
|
|
16
|
+
static targets = ["form"]
|
|
17
|
+
|
|
18
|
+
connect() {
|
|
19
|
+
// この要素自身のモーダルインスタンスを確保
|
|
20
|
+
this.modal = Modal.getOrCreateInstance(this.element)
|
|
21
|
+
|
|
22
|
+
// 委譲クリックでモーダルを開き、必要ならFrameへ読み込み
|
|
23
|
+
// data-modal-target が付いた要素のクリックをフック
|
|
24
|
+
this.handleDelegatedClick = (event) => {
|
|
25
|
+
const trigger = event.target?.closest?.('[data-modal-target]')
|
|
26
|
+
if (!trigger) return
|
|
27
|
+
|
|
28
|
+
// 修飾クリックや既に処理済みはスキップ
|
|
29
|
+
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
|
|
30
|
+
|
|
31
|
+
const targetSelector = trigger.getAttribute('data-modal-target')
|
|
32
|
+
if (!targetSelector) return
|
|
33
|
+
|
|
34
|
+
const targetEl = document.querySelector(targetSelector)
|
|
35
|
+
if (!targetEl) return
|
|
36
|
+
// 自身が対象モーダルでなければ無視(複数モーダルが同時に存在する場合の重複実行を防止)
|
|
37
|
+
if (targetEl !== this.element) return
|
|
38
|
+
|
|
39
|
+
// モーダルをプログラムで開く
|
|
40
|
+
this.modal.show()
|
|
41
|
+
}
|
|
42
|
+
// captureでBootstrapのdata APIより先に処理する
|
|
43
|
+
document.addEventListener('click', this.handleDelegatedClick, true)
|
|
44
|
+
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
disconnect() {
|
|
48
|
+
document.removeEventListener('click', this.handleDelegatedClick, true)
|
|
49
|
+
if (this.modal) {
|
|
50
|
+
this.modal.hide()
|
|
51
|
+
this.modal.dispose()
|
|
52
|
+
this.modal = null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 手動制御用のアクション
|
|
57
|
+
open() { this.modal?.show() }
|
|
58
|
+
hide(event) {
|
|
59
|
+
// Turboイベント以外(クリックなど detail が number)のケースはそのまま閉じる
|
|
60
|
+
if (!event?.detail || typeof event.detail === 'number') {
|
|
61
|
+
this.modal?.hide()
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Turbo submit で失敗 (422) した場合のみ閉じない
|
|
66
|
+
if (event.detail.success === false) return
|
|
67
|
+
|
|
68
|
+
this.modal?.hide()
|
|
69
|
+
}
|
|
70
|
+
toggle() { this.modal?.toggle() }
|
|
71
|
+
|
|
72
|
+
formSubmit(event) {
|
|
73
|
+
event?.preventDefault()
|
|
74
|
+
|
|
75
|
+
const form = this.hasFormTarget ? this.formTarget : this.element.querySelector("form")
|
|
76
|
+
|
|
77
|
+
if (!form) {
|
|
78
|
+
console.warn("[modal] フォームが見つかりませんでした。")
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (typeof form.requestSubmit === "function") {
|
|
83
|
+
form.requestSubmit()
|
|
84
|
+
} else {
|
|
85
|
+
form.submit()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { Offcanvas } from "bootstrap"
|
|
3
|
+
|
|
4
|
+
// Stimulusコントローラー(Bootstrap.Offcanvasの薄いラッパー)
|
|
5
|
+
//
|
|
6
|
+
// 目的:
|
|
7
|
+
// - 任意のトリガークリックで「オフキャンバスを開く + Turbo FrameへURLを読み込む」を同時に行う
|
|
8
|
+
// 契約:
|
|
9
|
+
// - トリガー属性:
|
|
10
|
+
// data-offcanvas-target="#offcanvasId" // 開きたいOffcanvasのセレクタ(必須)
|
|
11
|
+
// data-turbo-frame="frameId" // Turbo FrameのID(指定時はTurboに任せて読み込み)
|
|
12
|
+
// href // 読み込むURL(任意。未指定ならフレーム読み込みはスキップ)
|
|
13
|
+
// 備考:
|
|
14
|
+
// - Bootstrapのdata APIと競合させないため、documentのcapture段階で先取りして手動でshow()する
|
|
15
|
+
|
|
16
|
+
export default class extends Controller {
|
|
17
|
+
|
|
18
|
+
connect() {
|
|
19
|
+
this.offcanvas = Offcanvas.getOrCreateInstance(this.element)
|
|
20
|
+
|
|
21
|
+
// 委譲クリックでオープン+Frame読み込み
|
|
22
|
+
this.handleDelegatedClick = (event) => {
|
|
23
|
+
const trigger = event.target?.closest?.('[data-offcanvas-target]')
|
|
24
|
+
if (!trigger) return
|
|
25
|
+
|
|
26
|
+
// 修飾クリック等はスキップ
|
|
27
|
+
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
|
|
28
|
+
|
|
29
|
+
const targetSelector = trigger.getAttribute('data-offcanvas-target')
|
|
30
|
+
if (!targetSelector) return
|
|
31
|
+
|
|
32
|
+
const targetEl = document.querySelector(targetSelector)
|
|
33
|
+
if (!targetEl) return
|
|
34
|
+
// 自分のOffcanvas以外宛は無視
|
|
35
|
+
if (targetEl !== this.element) return
|
|
36
|
+
|
|
37
|
+
// 開く
|
|
38
|
+
this.offcanvas.show()
|
|
39
|
+
}
|
|
40
|
+
document.addEventListener('click', this.handleDelegatedClick, true)
|
|
41
|
+
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
disconnect() {
|
|
45
|
+
document.removeEventListener('click', this.handleDelegatedClick, true)
|
|
46
|
+
if (this.offcanvas) {
|
|
47
|
+
this.offcanvas.hide()
|
|
48
|
+
this.offcanvas.dispose()
|
|
49
|
+
this.offcanvas = null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
open() { this.offcanvas?.show() }
|
|
54
|
+
hide(event) {
|
|
55
|
+
// Turboイベント以外(クリックなど detail が number)のケースはそのまま閉じる
|
|
56
|
+
if (!event?.detail || typeof event.detail === 'number') {
|
|
57
|
+
this.modal?.hide()
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
toggle() { this.offcanvas?.toggle() }
|
|
62
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { Toast } from "bootstrap"
|
|
3
|
+
|
|
4
|
+
// 単一トースト要素用のシンプルなコントローラー
|
|
5
|
+
// 要素自体(.toast)がターゲット。接続時に即showする。
|
|
6
|
+
export default class extends Controller {
|
|
7
|
+
static values = {
|
|
8
|
+
delay: { type: Number, default: 4000 },
|
|
9
|
+
autohide: { type: Boolean, default: true },
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
connect() {
|
|
13
|
+
this.toast = Toast.getOrCreateInstance(this.element, {
|
|
14
|
+
autohide: this.autohideValue,
|
|
15
|
+
delay: this.delayValue,
|
|
16
|
+
})
|
|
17
|
+
this.toast.show()
|
|
18
|
+
|
|
19
|
+
// Turboキャッシュ前に破棄
|
|
20
|
+
this.beforeCache = () => {
|
|
21
|
+
try { this.toast?.hide(); this.toast?.dispose() } catch (_) {}
|
|
22
|
+
}
|
|
23
|
+
document.addEventListener("turbo:before-cache", this.beforeCache)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
disconnect() {
|
|
27
|
+
document.removeEventListener("turbo:before-cache", this.beforeCache)
|
|
28
|
+
try { this.toast?.hide(); this.toast?.dispose() } catch (_) {}
|
|
29
|
+
this.toast = null
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<%# Engine shared partial - expects locals: id:, dialog_class:, data:, content: %>
|
|
2
|
+
<div class="modal fade" id="<%= id %>" tabindex="-1" aria-labelledby="<%= id %>Label" aria-hidden="true" data-controller="<%= data[:controller] %>"<% (data || {}).except(:controller).each do |k, v| %> data-<%= k.to_s.dasherize %>="<%= v %>"<% end %>>
|
|
3
|
+
<div class="<%= dialog_class %>">
|
|
4
|
+
<%= content %>
|
|
5
|
+
</div>
|
|
6
|
+
</div>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<%# Engine shared partial - expects locals: id:, title:, placement_class:, body:, footer:, data: %>
|
|
2
|
+
<div class="offcanvas <%= placement_class %>" tabindex="-1" id="<%= id %>" aria-labelledby="<%= id %>Label" data-controller="<%= data[:controller] %>"<% (data || {}).except(:controller).each do |k, v| %> data-<%= k.to_s.dasherize %>="<%= v %>"<% end %>>
|
|
3
|
+
<div class="offcanvas-header">
|
|
4
|
+
<h5 class="offcanvas-title" id="<%= id %>Label"><%= title %></h5>
|
|
5
|
+
<button type="button" class="btn-close" aria-label="閉じる" data-action="click->offcanvas#hide"></button>
|
|
6
|
+
</div>
|
|
7
|
+
<div class="offcanvas-body">
|
|
8
|
+
<%= body %>
|
|
9
|
+
</div>
|
|
10
|
+
<% if footer.present? %>
|
|
11
|
+
<div class="offcanvas-footer border-top p-3">
|
|
12
|
+
<%= footer %>
|
|
13
|
+
</div>
|
|
14
|
+
<% end %>
|
|
15
|
+
</div>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<%# 単一トースト要素。ローカル: msg = { text:, color: }
|
|
2
|
+
オプション: autohide(Boolean), delay(Number)
|
|
3
|
+
%>
|
|
4
|
+
<div class="toast align-items-center text-bg-<%= msg[:color] %> border-0"
|
|
5
|
+
role="status" aria-live="polite" aria-atomic="true"
|
|
6
|
+
data-controller="toast"
|
|
7
|
+
data-toast-autohide-value="<%= local_assigns.key?(:autohide) ? local_assigns[:autohide] : true %>"
|
|
8
|
+
data-toast-delay-value="<%= local_assigns.key?(:delay) ? local_assigns[:delay] : 4000 %>">
|
|
9
|
+
<div class="d-flex">
|
|
10
|
+
<div class="toast-body">
|
|
11
|
+
<%= msg[:text] %>
|
|
12
|
+
</div>
|
|
13
|
+
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
data/bin/console
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "bootstrap5_rails_extensions"
|
|
6
|
+
|
|
7
|
+
puts "Bootstrap5RailsExtensions console loaded!"
|
|
8
|
+
puts "Try: Rails.application&.config&.eager_load_namespaces"
|
|
9
|
+
puts
|
|
10
|
+
|
|
11
|
+
require "irb"
|
|
12
|
+
IRB.start(__FILE__)
|