yummy-guide-generic-administrate 0.6.1 → 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3caffae44b6ea10fc063a2cc8fb8a3938e3cc42078c4af3bc2a08d4188cf01a
4
- data.tar.gz: cea155304d262445a29199f9c5e57c574600508dca66206f49b81b3d3db66fb2
3
+ metadata.gz: cef007572ee8fc90b0579c0e6261263bb568d192702eb92ee418976a06404ffc
4
+ data.tar.gz: be42920844cf830f365d2297e21605ad5191257d5a3f37ba2dd9f364ae16c768
5
5
  SHA512:
6
- metadata.gz: 6a2e5a4b98e6cea96bfdc9e0cb026212c6ed37f39518800368329cc8ba1fcff202859acb8b3340754b493544b92f33e191d92606de032575c889e436ae39b789
7
- data.tar.gz: 669b6a383ff6d184527e7324def8875626dc46dd0b5d9c5d55d3b5f18fd8f4e97188d6a943ffa65e4076a70eefd0389e41abbd7b77d97f9e98466a3c580ecc43
6
+ metadata.gz: 1f9203647f13a0974af5bd4b0de54ff23c2a8991ce0eb3e065a3c0d561b1f88898c9bc470642c294bf786f5250eab2f9d5780c91b35d3f6b3cc1da9f36e2a305
7
+ data.tar.gz: b5d15a4f6ca3e708542fab9b1ce320ee76e45f3774bf775394cbff9548f4aca75f945259ef5e1a4c703a5ac5a3baa604a6933a6171868e83633f203698119866
data/README.md CHANGED
@@ -355,13 +355,14 @@ dashboard の `FILTER_PATH` / `FILTER_CLEAR_PATH` より優先されます。
355
355
  ) %>
356
356
  ```
357
357
 
358
- 標準 Field は `Text`, `Select`, `Checkbox`, `RadioGroup`, `CheckboxGroup`,
359
- `DateRange`, `DatetimeRange`, `DatetimeLocalRange`, `Custom` です。
358
+ 標準 Field は `Text`, `Select`, `Checkbox`, `RadioGroup`, `BooleanRadioGroup`,
359
+ `CheckboxGroup`, `DateRange`, `DatetimeRange`, `DatetimeLocalRange`, `Custom` です。
360
360
  主な option は次のとおりです。
361
361
 
362
362
  - 共通: `label`, `default`, `if`, `class`, `id`, `placeholder`, `inputmode`, `pattern`
363
363
  - 選択系: `collection` または `options`, `select_options`
364
364
  - checkbox: `checked_value`, `unchecked_value`
365
+ - boolean radio group: `unspecified_label`, `true_label`, `false_label`
365
366
  - checkbox group: `group`
366
367
  - range: `from`, `to`, `from_default`, `to_default`, `css_class`
367
368
 
@@ -379,6 +380,12 @@ FILTER_ATTRIBUTES = {
379
380
  label: "Status",
380
381
  collection: ->(_view, locals) { locals[:status_collection] },
381
382
  select_options: { include_blank: true }
383
+ ),
384
+ visible: YummyGuide::Administrate::Filters::BooleanRadioGroup.with_options(
385
+ label: "Visible",
386
+ unspecified_label: "-",
387
+ true_label: "Visible",
388
+ false_label: "Hidden"
382
389
  )
383
390
  }.freeze
384
391
  ```
@@ -0,0 +1,242 @@
1
+ (function() {
2
+ var STORAGE_KEY = "yummyGuideAdministrate.navigationWidthPx";
3
+ var LEGACY_STORAGE_KEYS = ["wowTokyo.adminNavigationWidthPx"];
4
+ var WIDTH_VARIABLE = "--admin-navigation-width";
5
+ var NAVIGATION_SELECTOR = ".app-container > .navigation, body.admin-body > aside";
6
+ var HANDLE_CLASS = "admin-navigation-resize-handle";
7
+ var SCROLL_AREA_CLASS = "admin-navigation-scroll-area";
8
+ var TOOLTIP_CLASS = "admin-navigation-tooltip";
9
+ var TOOLTIP_VISIBLE_CLASS = "admin-navigation-tooltip--visible";
10
+ var RESIZING_BODY_CLASS = "admin-navigation-is-resizing";
11
+ var RESIZING_NAVIGATION_CLASS = "admin-navigation--resizing";
12
+ var DESKTOP_MEDIA_QUERY = "(min-width: 768px)";
13
+ var MIN_WIDTH_PX = 25;
14
+ var MAX_WIDTH_PX = 250;
15
+
16
+ function ready(callback) {
17
+ if (document.readyState === "loading") {
18
+ document.addEventListener("DOMContentLoaded", callback, { once: true });
19
+ return;
20
+ }
21
+
22
+ callback();
23
+ }
24
+
25
+ function toArray(nodeList) {
26
+ return Array.prototype.slice.call(nodeList || []);
27
+ }
28
+
29
+ function isDesktop() {
30
+ return window.matchMedia(DESKTOP_MEDIA_QUERY).matches;
31
+ }
32
+
33
+ function parseStoredWidth(value) {
34
+ if (!value) return null;
35
+
36
+ var width = parseFloat(value);
37
+ return Number.isFinite(width) ? width : null;
38
+ }
39
+
40
+ function storedWidthForKey(key) {
41
+ try {
42
+ return parseStoredWidth(window.localStorage.getItem(key));
43
+ } catch (_error) {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ function readStoredWidth() {
49
+ var width = storedWidthForKey(STORAGE_KEY);
50
+ if (width !== null) return width;
51
+
52
+ for (var index = 0; index < LEGACY_STORAGE_KEYS.length; index += 1) {
53
+ width = storedWidthForKey(LEGACY_STORAGE_KEYS[index]);
54
+ if (width !== null) return width;
55
+ }
56
+
57
+ return null;
58
+ }
59
+
60
+ function saveWidth(width) {
61
+ try {
62
+ window.localStorage.setItem(STORAGE_KEY, Math.round(width) + "px");
63
+ } catch (_error) {
64
+ // localStorage may be unavailable in private browsing or restricted contexts.
65
+ }
66
+ }
67
+
68
+ function clampWidth(width) {
69
+ return Math.min(Math.max(width, MIN_WIDTH_PX), MAX_WIDTH_PX);
70
+ }
71
+
72
+ function applyWidth(width, body) {
73
+ var clampedWidth = clampWidth(width);
74
+ body.style.setProperty(WIDTH_VARIABLE, Math.round(clampedWidth) + "px");
75
+
76
+ return clampedWidth;
77
+ }
78
+
79
+ function applyStoredWidth(body) {
80
+ var storedWidth = readStoredWidth();
81
+ if (storedWidth === null) return null;
82
+
83
+ return applyWidth(storedWidth, body);
84
+ }
85
+
86
+ function resizeWidthFromPointer(event, body, navigation) {
87
+ var navigationLeft = navigation.getBoundingClientRect().left;
88
+ return applyWidth(event.clientX - navigationLeft, body);
89
+ }
90
+
91
+ function childWithClass(element, className) {
92
+ return toArray(element.children).find(function(child) {
93
+ return child.classList.contains(className);
94
+ });
95
+ }
96
+
97
+ function setupNavigationScrollArea(navigation) {
98
+ var existingScrollArea = childWithClass(navigation, SCROLL_AREA_CLASS);
99
+ if (existingScrollArea) return existingScrollArea;
100
+
101
+ var scrollArea = document.createElement("div");
102
+ var handle = childWithClass(navigation, HANDLE_CLASS);
103
+
104
+ scrollArea.className = SCROLL_AREA_CLASS;
105
+ toArray(navigation.childNodes).forEach(function(node) {
106
+ if (node === handle) return;
107
+ scrollArea.appendChild(node);
108
+ });
109
+ navigation.insertBefore(scrollArea, handle || null);
110
+
111
+ return scrollArea;
112
+ }
113
+
114
+ function setupNavigationTooltips(container) {
115
+ var tooltipTargets = toArray(container.querySelectorAll("a, button"));
116
+ if (tooltipTargets.length === 0) return;
117
+
118
+ var tooltip = document.createElement("div");
119
+ var activeTarget = null;
120
+
121
+ tooltip.className = TOOLTIP_CLASS;
122
+ tooltip.setAttribute("role", "tooltip");
123
+ document.body.appendChild(tooltip);
124
+
125
+ function hideTooltip() {
126
+ tooltip.classList.remove(TOOLTIP_VISIBLE_CLASS);
127
+ activeTarget = null;
128
+ }
129
+
130
+ function positionTooltip() {
131
+ if (!activeTarget || !isDesktop()) {
132
+ hideTooltip();
133
+ return;
134
+ }
135
+
136
+ var targetRect = activeTarget.getBoundingClientRect();
137
+ var tooltipRect = tooltip.getBoundingClientRect();
138
+ var top = Math.min(
139
+ Math.max(targetRect.top + (targetRect.height / 2) - (tooltipRect.height / 2), 8),
140
+ window.innerHeight - tooltipRect.height - 8
141
+ );
142
+ var left = Math.min(targetRect.right + 8, window.innerWidth - tooltipRect.width - 8);
143
+
144
+ tooltip.style.left = Math.max(left, 8) + "px";
145
+ tooltip.style.top = top + "px";
146
+ }
147
+
148
+ function showTooltip(target) {
149
+ var label = target.textContent.trim();
150
+ if (!label || !isDesktop()) return;
151
+
152
+ activeTarget = target;
153
+ tooltip.textContent = label;
154
+ tooltip.classList.add(TOOLTIP_VISIBLE_CLASS);
155
+ positionTooltip();
156
+ }
157
+
158
+ tooltipTargets.forEach(function(target) {
159
+ target.addEventListener("mouseenter", function() {
160
+ showTooltip(target);
161
+ });
162
+ target.addEventListener("mouseleave", hideTooltip);
163
+ target.addEventListener("focus", function() {
164
+ showTooltip(target);
165
+ });
166
+ target.addEventListener("blur", hideTooltip);
167
+ });
168
+
169
+ window.addEventListener("resize", positionTooltip);
170
+ window.addEventListener("scroll", positionTooltip, true);
171
+ }
172
+
173
+ function addResizeHandle(body, navigation) {
174
+ if (navigation.querySelector("." + HANDLE_CLASS)) return;
175
+
176
+ var handle = document.createElement("div");
177
+ var latestWidth = null;
178
+
179
+ handle.className = HANDLE_CLASS;
180
+ handle.setAttribute("role", "separator");
181
+ handle.setAttribute("aria-orientation", "vertical");
182
+ handle.setAttribute("aria-label", "Resize navigation");
183
+ navigation.appendChild(handle);
184
+
185
+ handle.addEventListener("pointerdown", function(event) {
186
+ if (!isDesktop() || (event.pointerType === "mouse" && event.button !== 0)) return;
187
+
188
+ event.preventDefault();
189
+ latestWidth = navigation.getBoundingClientRect().width;
190
+ body.classList.add(RESIZING_BODY_CLASS);
191
+ navigation.classList.add(RESIZING_NAVIGATION_CLASS);
192
+ handle.setPointerCapture(event.pointerId);
193
+
194
+ function onPointerMove(moveEvent) {
195
+ latestWidth = resizeWidthFromPointer(moveEvent, body, navigation);
196
+ }
197
+
198
+ function stopResize() {
199
+ document.removeEventListener("pointermove", onPointerMove);
200
+ document.removeEventListener("pointerup", stopResize);
201
+ document.removeEventListener("pointercancel", stopResize);
202
+ body.classList.remove(RESIZING_BODY_CLASS);
203
+ navigation.classList.remove(RESIZING_NAVIGATION_CLASS);
204
+
205
+ if (latestWidth !== null) saveWidth(latestWidth);
206
+ latestWidth = null;
207
+ }
208
+
209
+ document.addEventListener("pointermove", onPointerMove);
210
+ document.addEventListener("pointerup", stopResize);
211
+ document.addEventListener("pointercancel", stopResize);
212
+ });
213
+ }
214
+
215
+ function setupResizableNavigation() {
216
+ var body = document.querySelector("body.admin-body") || document.body;
217
+ var navigation = document.querySelector(NAVIGATION_SELECTOR);
218
+ var scrollArea = null;
219
+ var resizeAnimationFrame = null;
220
+
221
+ if (!body || !navigation || navigation.dataset.resizableNavigationInitialized === "true") return;
222
+
223
+ navigation.dataset.resizableNavigationInitialized = "true";
224
+ applyStoredWidth(body);
225
+ scrollArea = setupNavigationScrollArea(navigation);
226
+ setupNavigationTooltips(scrollArea);
227
+ addResizeHandle(body, navigation);
228
+
229
+ window.addEventListener("resize", function() {
230
+ if (resizeAnimationFrame) {
231
+ window.cancelAnimationFrame(resizeAnimationFrame);
232
+ }
233
+
234
+ resizeAnimationFrame = window.requestAnimationFrame(function() {
235
+ applyStoredWidth(body);
236
+ resizeAnimationFrame = null;
237
+ });
238
+ });
239
+ }
240
+
241
+ ready(setupResizableNavigation);
242
+ })();
@@ -0,0 +1,156 @@
1
+ @media screen and (min-width: 768px) {
2
+ .app-container > .navigation,
3
+ body.admin-body > aside {
4
+ --admin-navigation-default-width: 240px;
5
+ align-self: flex-start;
6
+ box-sizing: border-box;
7
+ flex: 0 0 clamp(25px, var(--admin-navigation-width, var(--admin-navigation-default-width)), 250px);
8
+ max-height: calc(100vh - 2rem);
9
+ min-width: 25px;
10
+ max-width: 250px;
11
+ overflow: visible;
12
+ position: sticky;
13
+ top: 1rem;
14
+ width: clamp(25px, var(--admin-navigation-width, var(--admin-navigation-default-width)), 250px);
15
+ }
16
+
17
+ body.admin-body > aside:not(.navigation) {
18
+ --admin-navigation-default-width: 13rem;
19
+ }
20
+
21
+ .app-container > .navigation {
22
+ max-height: calc(100vh - 3em);
23
+ max-height: calc(100dvh - 3em);
24
+ top: 1.5em;
25
+ }
26
+
27
+ .app-container > .main-content,
28
+ body.admin-body > main.admin-main {
29
+ flex: 1 1 auto;
30
+ min-width: 0;
31
+ position: relative;
32
+ z-index: 1;
33
+ width: auto;
34
+ }
35
+
36
+ body.admin-body > main.main-content {
37
+ width: auto;
38
+ }
39
+
40
+ .admin-navigation-resize-handle {
41
+ bottom: 0;
42
+ cursor: col-resize;
43
+ position: absolute;
44
+ right: 0;
45
+ top: 0;
46
+ touch-action: none;
47
+ width: 8px;
48
+ z-index: 2;
49
+
50
+ &::after {
51
+ background-color: transparent;
52
+ bottom: 0;
53
+ content: "";
54
+ left: 3px;
55
+ position: absolute;
56
+ top: 0;
57
+ transition: background-color 0.15s ease;
58
+ width: 2px;
59
+ }
60
+
61
+ &:hover::after {
62
+ background-color: #9aa8ba;
63
+ }
64
+ }
65
+
66
+ .admin-navigation-scroll-area {
67
+ max-height: inherit;
68
+ overflow-x: hidden;
69
+ overflow-y: auto;
70
+ overscroll-behavior: contain;
71
+ }
72
+
73
+ .admin-navigation--resizing .admin-navigation-resize-handle::after {
74
+ background-color: #5d7596;
75
+ }
76
+
77
+ .admin-navigation-scroll-area .menubar,
78
+ .admin-navigation-scroll-area .menubar a,
79
+ .admin-navigation-scroll-area .menubar__group-label,
80
+ .admin-navigation-scroll-area .navigation__link,
81
+ .admin-navigation-scroll-area .navigation__link__secondary {
82
+ white-space: nowrap;
83
+ }
84
+
85
+ .admin-navigation-scroll-area .menubar {
86
+ margin-right: 0;
87
+ }
88
+
89
+ .admin-navigation-scroll-area .menubar li.menubar__item {
90
+ padding-left: 0;
91
+ }
92
+
93
+ .admin-navigation-scroll-area .menubar li a,
94
+ .admin-navigation-scroll-area .navigation__link {
95
+ box-sizing: border-box;
96
+ margin-left: 0;
97
+ margin-right: 0;
98
+ overflow: hidden;
99
+ width: 100%;
100
+ }
101
+
102
+ .admin-navigation-scroll-area .menubar li.menubar__item a {
103
+ padding-left: 36px;
104
+ }
105
+
106
+ .admin-navigation-scroll-area .button_to {
107
+ margin: 0;
108
+ }
109
+
110
+ .admin-navigation-scroll-area .btn_public,
111
+ .admin-navigation-scroll-area .btn_logout {
112
+ box-sizing: border-box;
113
+ margin-left: 0;
114
+ margin-right: 0;
115
+ min-width: 0;
116
+ overflow: hidden;
117
+ text-overflow: clip;
118
+ white-space: nowrap;
119
+ width: 100%;
120
+ }
121
+
122
+ .admin-navigation-tooltip {
123
+ background-color: #1f2933;
124
+ border-radius: 4px;
125
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
126
+ color: #fff;
127
+ font-size: 0.85rem;
128
+ line-height: 1.4;
129
+ max-width: 260px;
130
+ opacity: 0;
131
+ padding: 6px 8px;
132
+ pointer-events: none;
133
+ position: fixed;
134
+ transform: translateX(-2px);
135
+ transition: opacity 0.12s ease, transform 0.12s ease;
136
+ white-space: nowrap;
137
+ z-index: 1000;
138
+ }
139
+
140
+ .admin-navigation-tooltip--visible {
141
+ opacity: 1;
142
+ transform: translateX(0);
143
+ }
144
+
145
+ body.admin-navigation-is-resizing,
146
+ body.admin-navigation-is-resizing * {
147
+ cursor: col-resize !important;
148
+ user-select: none;
149
+ }
150
+ }
151
+
152
+ @media screen and (max-width: 767px) {
153
+ .admin-navigation-resize-handle {
154
+ display: none;
155
+ }
156
+ }
@@ -679,3 +679,5 @@ table[data-fixed-columns-count] [data-fixed-header-column-min-width] {
679
679
  grid-template-columns: 1fr;
680
680
  }
681
681
  }
682
+
683
+ @import "resizable_navigation";
@@ -14,6 +14,7 @@ module YummyGuide
14
14
  yummy_guide_administrate/filter_controls.js
15
15
  yummy_guide_administrate/filter_form.js
16
16
  yummy_guide_administrate/sticky_left_columns.js
17
+ yummy_guide_administrate/resizable_navigation.js
17
18
  yummy_guide_administrate/sticky_table_headers.js
18
19
  ]
19
20
  end
@@ -152,6 +152,39 @@ module YummyGuide
152
152
  end
153
153
  end
154
154
 
155
+ class BooleanRadioGroup < Base
156
+ protected
157
+
158
+ def input_cell(view_context, _form, scope, current_values, locals)
159
+ selected = current_value(current_values).to_s
160
+ controls = boolean_options(view_context, locals).map do |label, value|
161
+ value_string = value.to_s
162
+ id = "#{scope}_#{name}_#{value_string.presence || "unspecified"}".parameterize(separator: "_")
163
+
164
+ view_context.content_tag(:label, style: "display: inline-flex; align-items: center; gap: 6px;") do
165
+ view_context.safe_join([
166
+ view_context.radio_button_tag("#{scope}[#{name}]", value, selected == value_string, id: id),
167
+ view_context.content_tag(:span, label)
168
+ ])
169
+ end
170
+ end
171
+
172
+ view_context.content_tag(:td) do
173
+ view_context.content_tag(:div, view_context.safe_join(controls), style: "display: flex; align-items: center; gap: 12px; flex-wrap: wrap;")
174
+ end
175
+ end
176
+
177
+ private
178
+
179
+ def boolean_options(view_context, locals)
180
+ [
181
+ [evaluate_option(options.fetch(:unspecified_label, "Unspecified"), view_context, locals), ""],
182
+ [evaluate_option(options.fetch(:true_label, "true"), view_context, locals), "true"],
183
+ [evaluate_option(options.fetch(:false_label, "false"), view_context, locals), "false"]
184
+ ]
185
+ end
186
+ end
187
+
155
188
  class CheckboxGroup < Base
156
189
  protected
157
190
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module YummyGuide
4
4
  module Administrate
5
- VERSION = "0.6.1"
5
+ VERSION = "0.7.0"
6
6
  end
7
7
  end
@@ -49,6 +49,38 @@ RSpec.describe YummyGuide::Administrate::FilterControlsHelper do
49
49
  expect(document.at_css('select[name="search_options[status]"] option[selected]')["value"]).to eq("closed")
50
50
  end
51
51
 
52
+ # boolean radio group が未指定・true・falseを横並びで描画し、ラベルと選択状態を反映することを確認する
53
+ it "renders boolean radio group controls with custom labels" do
54
+ dashboard = Class.new
55
+ dashboard.const_set(
56
+ :FILTER_ATTRIBUTES,
57
+ {
58
+ visible: YummyGuide::Administrate::Filters::BooleanRadioGroup.with_options(
59
+ label: "Visible",
60
+ unspecified_label: "-",
61
+ true_label: "Visible",
62
+ false_label: "Hidden"
63
+ )
64
+ }.freeze
65
+ )
66
+
67
+ html = helper_host.admin_filter_controls(
68
+ dashboard: dashboard,
69
+ path: "/admin/resources",
70
+ search_options: { visible: "false" }
71
+ )
72
+ document = fragment(html)
73
+
74
+ wrapper = document.at_css('input[name="search_options[visible]"]').ancestors("div").first
75
+
76
+ expect(document.at_css('input[type="radio"][name="search_options[visible]"][value=""]')).to be_present
77
+ expect(document.at_css('input[type="radio"][name="search_options[visible]"][value="true"]')).to be_present
78
+ expect(document.at_css('input[type="radio"][name="search_options[visible]"][value="false"]')).to be_present
79
+ expect(document.at_css('input[type="radio"][name="search_options[visible]"][value="false"]')["checked"]).to eq("checked")
80
+ expect(wrapper["style"]).to include("display: flex")
81
+ expect(document.text).to include("-", "Visible", "Hidden")
82
+ end
83
+
52
84
  # dashboard に定義した FILTER_PATH をフィルター送信先として利用できることを確認する
53
85
  it "uses dashboard filter path when no explicit path is passed" do
54
86
  dashboard = Class.new
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.6.1
4
+ version: 0.7.0
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-05-27 00:00:00.000000000 Z
11
+ date: 2026-05-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: administrate
@@ -95,10 +95,12 @@ files:
95
95
  - app/assets/javascripts/yummy_guide_administrate/filter_controls.js
96
96
  - app/assets/javascripts/yummy_guide_administrate/filter_form.js
97
97
  - app/assets/javascripts/yummy_guide_administrate/fixed_submit_actions.js
98
+ - app/assets/javascripts/yummy_guide_administrate/resizable_navigation.js
98
99
  - app/assets/javascripts/yummy_guide_administrate/sticky_left_columns.js
99
100
  - app/assets/javascripts/yummy_guide_administrate/sticky_table_headers.js
100
101
  - app/assets/stylesheets/yummy_guide_administrate/_datetime_input.scss
101
102
  - app/assets/stylesheets/yummy_guide_administrate/_fixed_submit_actions.scss
103
+ - app/assets/stylesheets/yummy_guide_administrate/_resizable_navigation.scss
102
104
  - app/assets/stylesheets/yummy_guide_administrate/components.scss
103
105
  - app/controllers/concerns/yummy_guide/administrate/datetime_filter_parameters.rb
104
106
  - app/controllers/concerns/yummy_guide/administrate/default_sorting.rb