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.
@@ -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__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # bundler以外の追加セットアップが必要な場合はここに追記してください