yummy-guide-generic-administrate 0.8.3 → 0.8.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a6e8b69f0347b22f47b72cf96c4f99c0c398d58a8f118809735f7bafaee65e20
4
- data.tar.gz: 37decc40a9672713935cd76fdea50ff20992e6e35298a8096c1ab7625209060b
3
+ metadata.gz: 18c6b70d901d111ee883c8217e15eb52d5f3b7d8d6c41ef95d371ac552a78afd
4
+ data.tar.gz: f6de376c7ebe6876384278c8e4993ceb23485267e30c6bcedc1087b26715e278
5
5
  SHA512:
6
- metadata.gz: b3d6976869de3d91b3affb029c77195053e541513f5f8ebbe4279543dfea132cf2536ec0880d555e2112ea63178ac61140eea4bf43fb3b365f58951e9335aae9
7
- data.tar.gz: 81db24d807a65056e6b9ce35c5f5240d30c370b11def34fb2ae6664c7404e850458cd9a6119de8602faf259fcbfe0d42872da16373a0154cdc81c28b0c5da496
6
+ metadata.gz: 16d507455a42dd056a00470296f2387ad9725f7c2fa89e9a92b42abbe825b6063eb48d34bd91e7ba468cea86458b0baaf8d6a92f3d8185cd80395d901619effc
7
+ data.tar.gz: 99b43545b341e94587d5f59ed3c2ab85a70759806c3048d37bc587ff25ec2c56bcd376ea68ee5b7f38fcc3d0f3368db9ae5f55a8d567e16a4cfc4785666e8eaf
data/README.md CHANGED
@@ -43,6 +43,8 @@ bundle install
43
43
  - datetime フィルターや checkbox group の組み立てを補助する helper
44
44
  - `YummyGuide::Administrate::FilterControlsHelper`
45
45
  - dashboard の Field 型フィルター定義から Filter ボタンとモーダルフォームを描画する helper
46
+ - `YummyGuide::Administrate::TooltipHelper`
47
+ - 管理画面内で補足説明 tooltip を描画する helper
46
48
  - `YummyGuide::Administrate::DatetimeInputHelper`
47
49
  - 管理画面フォーム用の date + time 入力 helper
48
50
  - `YummyGuide::Administrate::NumberInputHelper`
@@ -58,6 +60,7 @@ bundle install
58
60
  - `filter_form.js`
59
61
  - `sticky_left_columns.js`
60
62
  - `sticky_table_headers.js`
63
+ - `tooltips.js`
61
64
  - `components.css`
62
65
  - 共通 field
63
66
  - `YummyGuide::Administrate::Fields::JsonPrettyField`
@@ -109,12 +112,15 @@ class Admin::ApplicationController < Administrate::ApplicationController
109
112
  helper YummyGuide::Administrate::FilterControlsHelper
110
113
  helper YummyGuide::Administrate::FilterFormHelper
111
114
  helper YummyGuide::Administrate::NumberInputHelper
115
+ helper YummyGuide::Administrate::TooltipHelper
112
116
  end
113
117
  ```
114
118
 
115
119
  `NumberInputHelper` は `Administrate::ApplicationController` の view helper として
116
120
  自動適用されます。`Administrate::ApplicationController` を継承しない独自 admin
117
121
  controller で同じ挙動が必要な場合だけ、上記のように明示的に読み込んでください。
122
+ `TooltipHelper` も `Administrate::ApplicationController` の view helper として
123
+ 自動適用されます。
118
124
 
119
125
  ### Collection partial
120
126
 
@@ -461,6 +467,30 @@ Admin/Administrate 画面では、`number_field` / `number_field_tag` を
461
467
  `range_field` / `range_field_tag` は対象外で、従来どおり `type="range"` として描画
462
468
  されます。raw HTML の `<input type="number">` は helper を通らないため対象外です。
463
469
 
470
+ ### Tooltip helper
471
+
472
+ ラベルやボタンの横に補足説明用の tooltip アイコンを表示する場合は、
473
+ `admin_tooltip` を使います。PC では hover / focus 時に表示し、モバイルではタップで
474
+ 表示・非表示を切り替えます。
475
+
476
+ ```erb
477
+ <%= f.label :published_at %>
478
+ <%= admin_tooltip("公開日時を過ぎると公開ページに表示されます。") %>
479
+ ```
480
+
481
+ 説明本文に改行などの HTML を含める場合は block を渡します。`text` 引数と block を
482
+ 同時に指定した場合は block が優先されます。
483
+
484
+ ```erb
485
+ <%= admin_tooltip do %>
486
+ Add - 調整レコードを追加表示するようにします。<br>
487
+ Only - 調整レコードのみを表示します。
488
+ <% end %>
489
+ ```
490
+
491
+ block を使わない場合、tooltip 本文はテキストとして扱われ、HTML は描画しません。
492
+ 見た目と表示制御には `components.css` と `tooltips.js` の読み込みが必要です。
493
+
464
494
  ### Asset の読み込み
465
495
 
466
496
  この engine の asset はホストアプリ側で明示的に読み込んでください。
@@ -473,6 +503,7 @@ Admin/Administrate 画面では、`number_field` / `number_field_tag` を
473
503
  //= require yummy_guide_administrate/filter_form
474
504
  //= require yummy_guide_administrate/sticky_left_columns
475
505
  //= require yummy_guide_administrate/sticky_table_headers
506
+ //= require yummy_guide_administrate/tooltips
476
507
  ```
477
508
 
478
509
  ```scss
@@ -0,0 +1,214 @@
1
+ (function() {
2
+ var TRIGGER_SELECTOR = "[data-admin-tooltip-trigger='true']";
3
+ var TOOLTIP_ID = "admin-tooltip";
4
+ var TOOLTIP_CLASS = "admin-tooltip";
5
+ var VISIBLE_CLASS = "admin-tooltip--visible";
6
+ var MOBILE_MEDIA_QUERY = "(max-width: 767px)";
7
+ var EDGE_GAP = 8;
8
+ var OFFSET = 8;
9
+ var activeTrigger = null;
10
+ var hoveredTrigger = null;
11
+ var focusedTrigger = null;
12
+ var tooltipElement = null;
13
+
14
+ function isMobile() {
15
+ return window.matchMedia && window.matchMedia(MOBILE_MEDIA_QUERY).matches;
16
+ }
17
+
18
+ function closestTrigger(target) {
19
+ if (!target || !target.closest) return null;
20
+
21
+ return target.closest(TRIGGER_SELECTOR);
22
+ }
23
+
24
+ function clamp(value, min, max) {
25
+ return Math.min(Math.max(value, min), max);
26
+ }
27
+
28
+ function getTooltipElement() {
29
+ if (tooltipElement) return tooltipElement;
30
+
31
+ tooltipElement = document.createElement("div");
32
+ tooltipElement.id = TOOLTIP_ID;
33
+ tooltipElement.className = TOOLTIP_CLASS;
34
+ tooltipElement.setAttribute("role", "tooltip");
35
+ document.body.appendChild(tooltipElement);
36
+
37
+ return tooltipElement;
38
+ }
39
+
40
+ function tooltipContent(trigger) {
41
+ var contentId = trigger.getAttribute("data-admin-tooltip-content-id");
42
+ var template = contentId ? document.getElementById(contentId) : null;
43
+ if (template) {
44
+ return {
45
+ html: template.innerHTML,
46
+ text: ""
47
+ };
48
+ }
49
+
50
+ var text = trigger.getAttribute("data-admin-tooltip-text") || "";
51
+ return {
52
+ html: "",
53
+ text: text.trim() ? text : ""
54
+ };
55
+ }
56
+
57
+ function setTriggerState(trigger, expanded) {
58
+ if (!trigger) return;
59
+
60
+ trigger.setAttribute("aria-expanded", expanded ? "true" : "false");
61
+
62
+ if (expanded) {
63
+ trigger.setAttribute("aria-describedby", TOOLTIP_ID);
64
+ } else {
65
+ trigger.removeAttribute("aria-describedby");
66
+ }
67
+ }
68
+
69
+ function positionTooltip() {
70
+ if (!activeTrigger || !tooltipElement || !tooltipElement.classList.contains(VISIBLE_CLASS)) return;
71
+
72
+ var targetRect = activeTrigger.getBoundingClientRect();
73
+ tooltipElement.style.left = "0px";
74
+ tooltipElement.style.top = "0px";
75
+
76
+ var tooltipRect = tooltipElement.getBoundingClientRect();
77
+ var viewportWidth = document.documentElement.clientWidth || window.innerWidth;
78
+ var viewportHeight = document.documentElement.clientHeight || window.innerHeight;
79
+ var top = targetRect.top - tooltipRect.height - OFFSET;
80
+ var placement = "top";
81
+
82
+ if (top < EDGE_GAP) {
83
+ top = targetRect.bottom + OFFSET;
84
+ placement = "bottom";
85
+ }
86
+
87
+ top = clamp(top, EDGE_GAP, Math.max(EDGE_GAP, viewportHeight - tooltipRect.height - EDGE_GAP));
88
+
89
+ var left = targetRect.left + (targetRect.width / 2) - (tooltipRect.width / 2);
90
+ left = clamp(left, EDGE_GAP, Math.max(EDGE_GAP, viewportWidth - tooltipRect.width - EDGE_GAP));
91
+
92
+ var arrowMax = Math.max(10, tooltipRect.width - 10);
93
+ var arrowLeft = clamp(targetRect.left + (targetRect.width / 2) - left - 4, 10, arrowMax);
94
+
95
+ tooltipElement.style.left = Math.round(left) + "px";
96
+ tooltipElement.style.top = Math.round(top) + "px";
97
+ tooltipElement.style.setProperty("--admin-tooltip-arrow-left", Math.round(arrowLeft) + "px");
98
+ tooltipElement.setAttribute("data-admin-tooltip-placement", placement);
99
+ }
100
+
101
+ function showTooltip(trigger) {
102
+ var content = tooltipContent(trigger);
103
+ if (!content.html && !content.text) return;
104
+
105
+ if (activeTrigger && activeTrigger !== trigger) {
106
+ hideTooltip();
107
+ }
108
+
109
+ activeTrigger = trigger;
110
+ tooltipElement = getTooltipElement();
111
+ if (content.html) {
112
+ tooltipElement.innerHTML = content.html;
113
+ } else {
114
+ tooltipElement.textContent = content.text;
115
+ }
116
+ tooltipElement.classList.add(VISIBLE_CLASS);
117
+ setTriggerState(trigger, true);
118
+
119
+ if (window.requestAnimationFrame) {
120
+ window.requestAnimationFrame(positionTooltip);
121
+ } else {
122
+ positionTooltip();
123
+ }
124
+ }
125
+
126
+ function hideTooltip(trigger) {
127
+ if (trigger && activeTrigger !== trigger) return;
128
+
129
+ setTriggerState(activeTrigger, false);
130
+
131
+ if (tooltipElement) {
132
+ tooltipElement.classList.remove(VISIBLE_CLASS);
133
+ }
134
+
135
+ activeTrigger = null;
136
+ }
137
+
138
+ function toggleTooltip(trigger) {
139
+ if (activeTrigger === trigger && tooltipElement && tooltipElement.classList.contains(VISIBLE_CLASS)) {
140
+ hideTooltip(trigger);
141
+ return;
142
+ }
143
+
144
+ showTooltip(trigger);
145
+ }
146
+
147
+ document.addEventListener("mouseover", function(event) {
148
+ var trigger = closestTrigger(event.target);
149
+ if (!trigger || isMobile() || (event.relatedTarget && trigger.contains(event.relatedTarget))) return;
150
+
151
+ hoveredTrigger = trigger;
152
+ showTooltip(trigger);
153
+ });
154
+
155
+ document.addEventListener("mouseout", function(event) {
156
+ var trigger = closestTrigger(event.target);
157
+ if (!trigger || isMobile() || (event.relatedTarget && trigger.contains(event.relatedTarget))) return;
158
+
159
+ if (hoveredTrigger === trigger) {
160
+ hoveredTrigger = null;
161
+ }
162
+ if (focusedTrigger === trigger) return;
163
+
164
+ hideTooltip(trigger);
165
+ });
166
+
167
+ document.addEventListener("focusin", function(event) {
168
+ var trigger = closestTrigger(event.target);
169
+ if (!trigger || isMobile()) return;
170
+
171
+ focusedTrigger = trigger;
172
+ showTooltip(trigger);
173
+ });
174
+
175
+ document.addEventListener("focusout", function(event) {
176
+ var trigger = closestTrigger(event.target);
177
+ if (!trigger || isMobile()) return;
178
+
179
+ if (focusedTrigger === trigger) {
180
+ focusedTrigger = null;
181
+ }
182
+ if (hoveredTrigger === trigger) return;
183
+
184
+ hideTooltip(trigger);
185
+ });
186
+
187
+ document.addEventListener("click", function(event) {
188
+ var trigger = closestTrigger(event.target);
189
+
190
+ if (trigger) {
191
+ if (!isMobile()) return;
192
+
193
+ event.preventDefault();
194
+ event.stopPropagation();
195
+ toggleTooltip(trigger);
196
+ return;
197
+ }
198
+
199
+ if (activeTrigger && isMobile()) {
200
+ hideTooltip();
201
+ }
202
+ });
203
+
204
+ document.addEventListener("keydown", function(event) {
205
+ if (event.key !== "Escape") return;
206
+
207
+ hideTooltip();
208
+ });
209
+
210
+ window.addEventListener("scroll", positionTooltip, true);
211
+ window.addEventListener("resize", function() {
212
+ hideTooltip();
213
+ });
214
+ })();
@@ -0,0 +1,85 @@
1
+ .admin-tooltip-trigger {
2
+ display: inline-flex;
3
+ align-items: center;
4
+ justify-content: center;
5
+ width: 1.25rem;
6
+ min-width: 1.25rem;
7
+ height: 1.25rem;
8
+ margin: 0 0 0 0.25rem;
9
+ padding: 0;
10
+ border: 1px solid #7a8594;
11
+ border-radius: 50%;
12
+ background: #fff;
13
+ color: #4b5563;
14
+ cursor: help;
15
+ font-size: 0.75rem;
16
+ font-weight: 700;
17
+ line-height: 1;
18
+ vertical-align: middle;
19
+ }
20
+
21
+ .admin-tooltip-trigger:hover,
22
+ .admin-tooltip-trigger:focus-visible,
23
+ .admin-tooltip-trigger[aria-expanded="true"] {
24
+ border-color: #0d6efd;
25
+ background: #f8fbff;
26
+ color: #0d6efd;
27
+ box-shadow: none;
28
+ }
29
+
30
+ .admin-tooltip-trigger__icon {
31
+ pointer-events: none;
32
+ }
33
+
34
+ .admin-tooltip {
35
+ position: fixed;
36
+ z-index: 1100;
37
+ max-width: 280px;
38
+ padding: 8px 10px;
39
+ border-radius: 4px;
40
+ background: #1f2933;
41
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
42
+ color: #fff;
43
+ font-size: 0.82rem;
44
+ line-height: 1.45;
45
+ opacity: 0;
46
+ overflow-wrap: anywhere;
47
+ pointer-events: none;
48
+ transform: translateY(2px);
49
+ transition: opacity 0.12s ease, transform 0.12s ease;
50
+ white-space: normal;
51
+ }
52
+
53
+ .admin-tooltip--visible {
54
+ opacity: 1;
55
+ transform: translateY(0);
56
+ }
57
+
58
+ .admin-tooltip::before {
59
+ content: "";
60
+ position: absolute;
61
+ left: var(--admin-tooltip-arrow-left, 50%);
62
+ width: 8px;
63
+ height: 8px;
64
+ background: #1f2933;
65
+ transform: rotate(45deg);
66
+ }
67
+
68
+ .admin-tooltip[data-admin-tooltip-placement="top"]::before {
69
+ bottom: -4px;
70
+ }
71
+
72
+ .admin-tooltip[data-admin-tooltip-placement="bottom"]::before {
73
+ top: -4px;
74
+ }
75
+
76
+ @media screen and (max-width: 767px) {
77
+ .admin-tooltip-trigger {
78
+ cursor: pointer;
79
+ }
80
+
81
+ .admin-tooltip {
82
+ max-width: calc(100vw - 24px);
83
+ font-size: 0.85rem;
84
+ }
85
+ }
@@ -1,6 +1,7 @@
1
1
  @import "fixed_submit_actions";
2
2
  @import "datetime_input";
3
3
  @import "column_resizer";
4
+ @import "tooltips";
4
5
 
5
6
  @media screen and (min-width: 768px) {
6
7
  .app-container {
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YummyGuide
4
+ module Administrate
5
+ module TooltipHelper
6
+ def admin_tooltip(text = nil, aria_label: "説明を表示", class: nil, data: {}, &block)
7
+ return if text.blank? && !block_given?
8
+
9
+ custom_class = binding.local_variable_get(:class)
10
+ content_id = admin_tooltip_content_id if block_given?
11
+
12
+ safe_join([
13
+ content_tag(
14
+ :button,
15
+ type: "button",
16
+ class: token_list("admin-tooltip-trigger", custom_class),
17
+ data: admin_tooltip_data_attributes(data, text: text.to_s, content_id: content_id),
18
+ aria: {
19
+ label: aria_label,
20
+ expanded: "false"
21
+ }
22
+ ) do
23
+ tag.span("?", class: "admin-tooltip-trigger__icon", aria: { hidden: true })
24
+ end,
25
+ (content_tag(:template, capture(&block), id: content_id) if content_id)
26
+ ].compact)
27
+ end
28
+
29
+ private
30
+
31
+ def admin_tooltip_content_id
32
+ @admin_tooltip_index ||= 0
33
+ @admin_tooltip_index += 1
34
+ "admin-tooltip-content-#{@admin_tooltip_index}"
35
+ end
36
+
37
+ def admin_tooltip_data_attributes(data, text:, content_id:)
38
+ attributes = (data || {}).to_h.deep_dup
39
+ attributes.delete(:admin_tooltip_trigger)
40
+ attributes.delete("admin_tooltip_trigger")
41
+ attributes.delete(:admin_tooltip_text)
42
+ attributes.delete("admin_tooltip_text")
43
+ attributes.delete(:admin_tooltip_content_id)
44
+ attributes.delete("admin_tooltip_content_id")
45
+
46
+ attributes[:admin_tooltip_trigger] = true
47
+ if content_id
48
+ attributes[:admin_tooltip_content_id] = content_id
49
+ else
50
+ attributes[:admin_tooltip_text] = text
51
+ end
52
+
53
+ attributes
54
+ end
55
+ end
56
+ end
57
+ end
@@ -15,6 +15,7 @@ module YummyGuide
15
15
  yummy_guide_administrate/filter_controls.js
16
16
  yummy_guide_administrate/filter_form.js
17
17
  yummy_guide_administrate/sticky_left_columns.js
18
+ yummy_guide_administrate/tooltips.js
18
19
  yummy_guide_administrate/resizable_navigation.js
19
20
  yummy_guide_administrate/sticky_table_headers.js
20
21
  ]
@@ -27,6 +28,7 @@ module YummyGuide
27
28
  ::Administrate::ApplicationController.helper YummyGuide::Administrate::NumberInputHelper
28
29
  ::Administrate::ApplicationController.helper YummyGuide::Administrate::FilterFormHelper
29
30
  ::Administrate::ApplicationController.helper YummyGuide::Administrate::FilterControlsHelper
31
+ ::Administrate::ApplicationController.helper YummyGuide::Administrate::TooltipHelper
30
32
  end
31
33
  end
32
34
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module YummyGuide
4
4
  module Administrate
5
- VERSION = "0.8.3"
5
+ VERSION = "0.8.4"
6
6
  end
7
7
  end
@@ -6,6 +6,7 @@ require "administrate"
6
6
  require_relative "../../app/helpers/yummy_guide/administrate/number_input_helper"
7
7
  require_relative "../../app/helpers/yummy_guide/administrate/filter_form_helper"
8
8
  require_relative "../../app/helpers/yummy_guide/administrate/filter_controls_helper"
9
+ require_relative "../../app/helpers/yummy_guide/administrate/tooltip_helper"
9
10
  require_relative "administrate/version"
10
11
  require_relative "administrate/filters"
11
12
  require_relative "administrate/engine"
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe "tooltip assets" do
4
+ let(:javascript_source) do
5
+ File.read(File.expand_path("../../../app/assets/javascripts/yummy_guide_administrate/tooltips.js", __dir__))
6
+ end
7
+
8
+ let(:stylesheet_source) do
9
+ File.read(File.expand_path("../../../app/assets/stylesheets/yummy_guide_administrate/_tooltips.scss", __dir__))
10
+ end
11
+
12
+ let(:engine_source) do
13
+ File.read(File.expand_path("../../../lib/yummy_guide/administrate/engine.rb", __dir__))
14
+ end
15
+
16
+ # PC では hover と keyboard focus で tooltip を表示するイベント契約を持つことを静的に確認する
17
+ it "supports desktop hover and focus interactions" do
18
+ expect(javascript_source).to include('document.addEventListener("mouseover"')
19
+ expect(javascript_source).to include('document.addEventListener("mouseout"')
20
+ expect(javascript_source).to include('document.addEventListener("focusin"')
21
+ expect(javascript_source).to include('document.addEventListener("focusout"')
22
+ expect(javascript_source).to include("showTooltip(trigger)")
23
+ end
24
+
25
+ # モバイルではクリックで tooltip 表示をトグルする契約を持つことを静的に確認する
26
+ it "supports mobile click toggling" do
27
+ expect(javascript_source).to include('MOBILE_MEDIA_QUERY = "(max-width: 767px)"')
28
+ expect(javascript_source).to include("window.matchMedia")
29
+ expect(javascript_source).to include('document.addEventListener("click"')
30
+ expect(javascript_source).to include("toggleTooltip(trigger)")
31
+ expect(javascript_source).to include("event.stopPropagation()")
32
+ end
33
+
34
+ # Esc / 外側クリック / resize で表示中 tooltip を閉じられることを静的に確認する
35
+ it "closes the active tooltip from common dismissal events" do
36
+ expect(javascript_source).to include('event.key !== "Escape"')
37
+ expect(javascript_source).to include("if (activeTrigger && isMobile())")
38
+ expect(javascript_source).to include('window.addEventListener("resize"')
39
+ expect(javascript_source).to include("hideTooltip()")
40
+ end
41
+
42
+ # block本文用のtemplateがある場合はHTML本文としてtooltipへ描画することを静的に確認する
43
+ it "renders template content for block tooltip bodies" do
44
+ expect(javascript_source).to include('getAttribute("data-admin-tooltip-content-id")')
45
+ expect(javascript_source).to include("template.innerHTML")
46
+ expect(javascript_source).to include("tooltipElement.innerHTML = content.html")
47
+ expect(javascript_source).to include("tooltipElement.textContent = content.text")
48
+ end
49
+
50
+ # tooltip の吹き出しと表示状態に必要な CSS class が定義されることを静的に確認する
51
+ it "defines tooltip trigger and bubble styles" do
52
+ expect(stylesheet_source).to include(".admin-tooltip-trigger")
53
+ expect(stylesheet_source).to include(".admin-tooltip")
54
+ expect(stylesheet_source).to include(".admin-tooltip--visible")
55
+ expect(stylesheet_source).to include('data-admin-tooltip-placement="top"')
56
+ expect(stylesheet_source).to include("max-width: calc(100vw - 24px)")
57
+ end
58
+
59
+ # tooltip の JS asset が engine の precompile 対象に入ることを確認する
60
+ it "precompiles the tooltip javascript asset" do
61
+ expect(engine_source).to include("yummy_guide_administrate/tooltips.js")
62
+ end
63
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "nokogiri"
5
+
6
+ RSpec.describe YummyGuide::Administrate::TooltipHelper do
7
+ subject(:helper_host) do
8
+ Class.new do
9
+ include ActionView::Context
10
+ include ActionView::Helpers::CaptureHelper
11
+ include ActionView::Helpers::OutputSafetyHelper
12
+ include ActionView::Helpers::TagHelper
13
+ include YummyGuide::Administrate::TooltipHelper
14
+ end.new
15
+ end
16
+
17
+ def fragment(html)
18
+ Nokogiri::HTML.fragment(html)
19
+ end
20
+
21
+ describe "#admin_tooltip" do
22
+ # tooltip 表示用の button と JS が参照する data 属性を描画することを確認する
23
+ it "renders an icon button tooltip trigger" do
24
+ document = fragment(helper_host.admin_tooltip("公開ページに表示されます。"))
25
+ button = document.at_css("button.admin-tooltip-trigger")
26
+
27
+ expect(button["type"]).to eq("button")
28
+ expect(button["data-admin-tooltip-trigger"]).to eq("true")
29
+ expect(button["data-admin-tooltip-text"]).to eq("公開ページに表示されます。")
30
+ expect(button["aria-label"]).to eq("説明を表示")
31
+ expect(button["aria-expanded"]).to eq("false")
32
+ expect(button.at_css(".admin-tooltip-trigger__icon").text).to eq("?")
33
+ expect(button.at_css(".admin-tooltip-trigger__icon")["aria-hidden"]).to eq("true")
34
+ end
35
+
36
+ # tooltip 本文に HTML が含まれても子要素として展開されず data 属性へ escape されることを確認する
37
+ it "escapes tooltip text instead of rendering it as HTML" do
38
+ html = helper_host.admin_tooltip(%(<strong onclick="alert(1)">説明</strong>))
39
+ document = fragment(html)
40
+ button = document.at_css("button.admin-tooltip-trigger")
41
+
42
+ expect(document.at_css("strong")).to be_nil
43
+ expect(button["data-admin-tooltip-text"]).to eq(%(<strong onclick="alert(1)">説明</strong>))
44
+ expect(html).to include("&lt;strong")
45
+ end
46
+
47
+ # block を渡した場合は説明本文として template に描画し、text 引数より優先されることを確認する
48
+ it "renders block content as the tooltip body before text content" do
49
+ document = fragment(
50
+ helper_host.admin_tooltip("Fallback text") do
51
+ helper_host.safe_join([
52
+ helper_host.content_tag(:span, "Add - 調整レコードを追加表示するようにします。"),
53
+ helper_host.tag.br,
54
+ helper_host.content_tag(:span, "Only - 調整レコードのみを表示します。")
55
+ ])
56
+ end
57
+ )
58
+ button = document.at_css("button.admin-tooltip-trigger")
59
+ template = document.at_css("template")
60
+
61
+ expect(button["data-admin-tooltip-text"]).to be_nil
62
+ expect(button["data-admin-tooltip-content-id"]).to eq(template["id"])
63
+ expect(template.text).to include("Add - 調整レコードを追加表示するようにします。")
64
+ expect(template.text).to include("Only - 調整レコードのみを表示します。")
65
+ expect(template.at_css("br")).to be_present
66
+ expect(template.text).not_to include("Fallback text")
67
+ end
68
+
69
+ # 呼び出し側が指定した class / data / aria label を維持しつつ tooltip 用 data を優先することを確認する
70
+ it "merges custom attributes without allowing tooltip data overrides" do
71
+ document = fragment(
72
+ helper_host.admin_tooltip(
73
+ "ステータスの補足です。",
74
+ aria_label: "補足を開く",
75
+ class: "status-tooltip",
76
+ data: {
77
+ tracking_key: "status",
78
+ admin_tooltip_text: "上書きされない説明"
79
+ }
80
+ )
81
+ )
82
+ button = document.at_css("button.admin-tooltip-trigger")
83
+
84
+ expect(button["class"]).to include("admin-tooltip-trigger", "status-tooltip")
85
+ expect(button["aria-label"]).to eq("補足を開く")
86
+ expect(button["data-tracking-key"]).to eq("status")
87
+ expect(button["data-admin-tooltip-trigger"]).to eq("true")
88
+ expect(button["data-admin-tooltip-text"]).to eq("ステータスの補足です。")
89
+ end
90
+
91
+ # 空の説明文では空ボタンを描画しないことを確認する
92
+ it "does not render a trigger for blank text" do
93
+ expect(helper_host.admin_tooltip("")).to be_nil
94
+ end
95
+ end
96
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yummy-guide-generic-administrate
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.3
4
+ version: 0.8.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - akatsuki-kk
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-08 00:00:00.000000000 Z
11
+ date: 2026-06-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: administrate
@@ -99,10 +99,12 @@ files:
99
99
  - app/assets/javascripts/yummy_guide_administrate/resizable_navigation.js
100
100
  - app/assets/javascripts/yummy_guide_administrate/sticky_left_columns.js
101
101
  - app/assets/javascripts/yummy_guide_administrate/sticky_table_headers.js
102
+ - app/assets/javascripts/yummy_guide_administrate/tooltips.js
102
103
  - app/assets/stylesheets/yummy_guide_administrate/_column_resizer.scss
103
104
  - app/assets/stylesheets/yummy_guide_administrate/_datetime_input.scss
104
105
  - app/assets/stylesheets/yummy_guide_administrate/_fixed_submit_actions.scss
105
106
  - app/assets/stylesheets/yummy_guide_administrate/_resizable_navigation.scss
107
+ - app/assets/stylesheets/yummy_guide_administrate/_tooltips.scss
106
108
  - app/assets/stylesheets/yummy_guide_administrate/components.scss
107
109
  - app/controllers/concerns/yummy_guide/administrate/datetime_filter_parameters.rb
108
110
  - app/controllers/concerns/yummy_guide/administrate/default_sorting.rb
@@ -116,6 +118,7 @@ files:
116
118
  - app/helpers/yummy_guide/administrate/filter_controls_helper.rb
117
119
  - app/helpers/yummy_guide/administrate/filter_form_helper.rb
118
120
  - app/helpers/yummy_guide/administrate/number_input_helper.rb
121
+ - app/helpers/yummy_guide/administrate/tooltip_helper.rb
119
122
  - app/views/fields/number/_form.html.erb
120
123
  - app/views/fields/yummy_guide_administrate/area/picture/_form.html.erb
121
124
  - app/views/fields/yummy_guide_administrate/area/picture/_index.html.erb
@@ -152,6 +155,8 @@ files:
152
155
  - spec/yummy_guide/administrate/filter_form_helper_spec.rb
153
156
  - spec/yummy_guide/administrate/filters_spec.rb
154
157
  - spec/yummy_guide/administrate/number_input_helper_spec.rb
158
+ - spec/yummy_guide/administrate/tooltip_asset_spec.rb
159
+ - spec/yummy_guide/administrate/tooltip_helper_spec.rb
155
160
  - yummy-guide-generic-administrate.gemspec
156
161
  homepage: https://github.com/yummy-guide/generic-administrate
157
162
  licenses: