active_admin_prism 0.1.0 → 0.1.2

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.
data/js-src/prism.js CHANGED
@@ -75,6 +75,103 @@
75
75
  });
76
76
  })();
77
77
 
78
+ // Sidebar menu search box (lib/active_admin/views/prism_sidebar.rb
79
+ // #render_search_box) — filters the "Pages" nav as the visitor types,
80
+ // matching an item's own label at ANY nesting depth, not just top-level
81
+ // entries. A submenu item that matches keeps every one of its ancestors
82
+ // visible and forced open; a parent whose own label matches instead keeps
83
+ // its *entire* subtree visible and expanded, even children that don't
84
+ // match on their own — either way via ".prism-search-open", distinct from
85
+ // the persisted/manual ".open" class above so clearing the search can't
86
+ // clobber a group the visitor had manually pinned open.
87
+ (function () {
88
+ "use strict";
89
+
90
+ function normalize(text) {
91
+ return (text || "").trim().toLowerCase();
92
+ }
93
+
94
+ function ownLabel(li) {
95
+ var el = li.querySelector(
96
+ ":scope > .prism-nav-link .prism-nav-label, :scope > .prism-nav-group-toggle .prism-nav-label"
97
+ );
98
+ return el ? normalize(el.textContent) : "";
99
+ }
100
+
101
+ function directChildItems(li) {
102
+ var submenu = li.querySelector(":scope > .prism-nav-submenu");
103
+ if (!submenu) return [];
104
+ return Array.prototype.filter.call(submenu.children, function (child) {
105
+ return child.classList.contains("prism-nav-item");
106
+ });
107
+ }
108
+
109
+ // Recurses depth-first, carrying `ancestorOwnMatch` down (true once any
110
+ // ancestor's own label has matched) so a matched group reveals its whole
111
+ // subtree, not just itself — and returns whether this node's own label
112
+ // or any descendant's matched (ignoring ancestorOwnMatch), so that signal
113
+ // keeps bubbling up to open every group on the path to a deep match
114
+ // regardless of how many submenu levels separate it from the top.
115
+ function matchItem(li, query, ancestorOwnMatch) {
116
+ var ownMatch = !!query && ownLabel(li).indexOf(query) !== -1;
117
+ var childMatch = false;
118
+
119
+ directChildItems(li).forEach(function (child) {
120
+ if (matchItem(child, query, ancestorOwnMatch || ownMatch)) childMatch = true;
121
+ });
122
+
123
+ var visible = !query || ancestorOwnMatch || ownMatch || childMatch;
124
+ li.classList.toggle("prism-nav-hidden", !visible);
125
+ li.classList.toggle("prism-search-open", !!query && (ownMatch || childMatch || ancestorOwnMatch));
126
+
127
+ return ownMatch || childMatch;
128
+ }
129
+
130
+ function applySearch(sidebar, query) {
131
+ var nav = sidebar.querySelector(".prism-sidebar-scroll > .prism-nav");
132
+ if (!nav) return;
133
+
134
+ var anyVisible = false;
135
+ Array.prototype.forEach.call(nav.children, function (li) {
136
+ if (!li.classList.contains("prism-nav-item")) return;
137
+ if (matchItem(li, query, false)) anyVisible = true;
138
+ });
139
+
140
+ var empty = sidebar.querySelector(".prism-nav-empty");
141
+ if (empty) empty.classList.toggle("prism-nav-empty-visible", !!query && !anyVisible);
142
+
143
+ var clear = sidebar.querySelector("[data-prism-nav-search-clear]");
144
+ if (clear) clear.classList.toggle("prism-nav-search-clear-visible", !!query);
145
+ }
146
+
147
+ document.addEventListener("DOMContentLoaded", function () {
148
+ var sidebar = document.getElementById("header");
149
+ if (!sidebar || !sidebar.classList.contains("prism-sidebar")) return;
150
+
151
+ var input = sidebar.querySelector("[data-prism-nav-search]");
152
+ if (!input) return;
153
+
154
+ input.addEventListener("input", function () {
155
+ applySearch(sidebar, normalize(input.value));
156
+ });
157
+
158
+ input.addEventListener("keydown", function (event) {
159
+ if (event.key !== "Escape" || !input.value) return;
160
+ input.value = "";
161
+ applySearch(sidebar, "");
162
+ });
163
+
164
+ var clearBtn = sidebar.querySelector("[data-prism-nav-search-clear]");
165
+ if (clearBtn) {
166
+ clearBtn.addEventListener("click", function () {
167
+ input.value = "";
168
+ applySearch(sidebar, "");
169
+ input.focus();
170
+ });
171
+ }
172
+ });
173
+ })();
174
+
78
175
  // Routes every plain `data-confirm` link (row-level View/Edit/Delete
79
176
  // actions, any other rails-ujs confirm) through ActiveAdmin's own styled
80
177
  // jQuery UI dialog — the same one it already uses for Batch Actions —
@@ -268,3 +365,31 @@
268
365
  });
269
366
  });
270
367
  })();
368
+
369
+ // Auto-enhances every plain <select> ActiveAdmin renders (filters, form
370
+ // inputs, association pickers) into a searchable Select2 widget when
371
+ // ActiveAdminPrism.configuration.select2 is true (the
372
+ // "prism-select2-enabled" body class, set in
373
+ // lib/active_admin/views/flash_messages.rb#body_classes). Select2 itself
374
+ // (vendor/select2/select2.full.min.js, MIT licensed) is concatenated just
375
+ // above this file in the compiled asset (see bin/build-js) — turning this
376
+ // flag on needs no host-side JS/npm setup of its own. Skips any <select>
377
+ // a host already initialized manually (the per-field
378
+ // input_html: { class: "..." } + own .select2() call approach predating
379
+ // this flag, still documented in INTEGRATION.md) so the two can coexist
380
+ // without double-initializing the same element.
381
+ (function () {
382
+ "use strict";
383
+
384
+ document.addEventListener("DOMContentLoaded", function () {
385
+ if (!document.body.classList.contains("prism-select2-enabled")) return;
386
+ if (typeof jQuery === "undefined" || !jQuery.fn.select2) return;
387
+
388
+ var $ = jQuery;
389
+ $("select").each(function () {
390
+ var $select = $(this);
391
+ if ($select.data("select2")) return;
392
+ $select.select2({ width: "100%" });
393
+ });
394
+ });
395
+ })();
@@ -47,10 +47,11 @@ module ActiveAdmin
47
47
 
48
48
  # Read by prism.js / scss-src's CSS to decide whether to route
49
49
  # data-confirm links through the styled dialog, whether the Filters
50
- # sidebar panel collapses to an icon, and whether ActiveAdmin's
51
- # original #footer should stay hidden (see
50
+ # sidebar panel collapses to an icon, whether ActiveAdmin's
51
+ # original #footer should stay hidden, and whether every plain
52
+ # <select> should be auto-enhanced into Select2 (see
52
53
  # ActiveAdminPrism::Configuration #styled_confirms /
53
- # #collapsible_filters / #sidebar_footer).
54
+ # #collapsible_filters / #sidebar_footer / #select2).
54
55
  #
55
56
  # Can't call `super` here — #body_classes is defined directly on
56
57
  # this same class (not a superclass), so reopening and redefining
@@ -69,6 +70,7 @@ module ActiveAdmin
69
70
  classes << "prism-styled-confirms-disabled" unless config.styled_confirms
70
71
  classes << "prism-filters-collapsible-disabled" unless config.collapsible_filters
71
72
  classes << "prism-sidebar-footer-disabled" unless config.sidebar_footer
73
+ classes << "prism-select2-enabled" if config.select2
72
74
  classes
73
75
  end
74
76
  end
@@ -42,9 +42,11 @@ module ActiveAdmin
42
42
  div class: "prism-sidebar-inner" do
43
43
  div(class: "prism-sidebar-brand") { site_title @namespace }
44
44
  render_language_switcher if ActiveAdminPrism.configuration.language_switcher
45
+ render_search_box if ActiveAdminPrism.configuration.menu_search
45
46
  div(class: "prism-sidebar-scroll") do
46
47
  div "Pages", class: "prism-nav-section-label"
47
48
  render_menu @menu, class: "prism-nav"
49
+ div("No matching menu items", class: "prism-nav-empty")
48
50
  end
49
51
  if @utility_menu.items.any?
50
52
  div(class: "prism-sidebar-utility") do
@@ -111,6 +113,30 @@ module ActiveAdmin
111
113
  end
112
114
  end
113
115
 
116
+ # A text input that filters the "Pages" nav below as the visitor
117
+ # types (see js-src/prism.js) — matching happens purely client-side,
118
+ # against every item's own label regardless of nesting level, so a
119
+ # long menu with several submenus doesn't force a scroll to find one
120
+ # item. The actual show/hide + submenu auto-expand logic lives in JS
121
+ # because the whole menu is already rendered up front here; there's
122
+ # nothing server-side left to decide once the markup exists.
123
+ def render_search_box
124
+ div(class: "prism-sidebar-search") do
125
+ text_node ActiveAdminPrism::Icons.svg(:search, css_class: "prism-nav-icon prism-search-icon").html_safe
126
+ input(
127
+ type: "search",
128
+ class: "prism-nav-search-input",
129
+ placeholder: "Search menu",
130
+ autocomplete: "off",
131
+ "aria-label": "Search menu",
132
+ data: { "prism-nav-search": true }
133
+ )
134
+ span(class: "prism-nav-search-clear", data: { "prism-nav-search-clear": true }, "aria-label": "Clear search") do
135
+ text_node ActiveAdminPrism::Icons.svg(:x, css_class: "prism-nav-icon").html_safe
136
+ end
137
+ end
138
+ end
139
+
114
140
  # Each language entry's own :url (String/Proc/Symbol — same
115
141
  # nil/String/Proc/Symbol convention as config.login_logo etc.,
116
142
  # evaluated the same way item.url is above) wins when given, e.g. to
@@ -136,6 +136,31 @@ module ActiveAdminPrism
136
136
  # as it would be if you'd wired up the utility_navigation menu by hand.
137
137
  attr_accessor :languages
138
138
 
139
+ # Whether a search box renders at the top of the sidebar's "Pages" nav,
140
+ # letting a visitor filter menu items by label without scrolling —
141
+ # matching against every item regardless of nesting (a top-level item,
142
+ # or one buried in a submenu). A submenu item that matches keeps its
143
+ # parent visible (and expanded) even though the parent's own label
144
+ # doesn't match; a parent that matches shows all of its children. See
145
+ # lib/active_admin/views/prism_sidebar.rb#render_search_box for the
146
+ # markup and js-src/prism.js for the filtering itself (pure client-side
147
+ # DOM filtering — no server round trip, no change to how menu do |m|
148
+ # ... end is authored in app/admin/*.rb). false renders no search box at
149
+ # all.
150
+ attr_accessor :menu_search
151
+
152
+ # Whether every plain <select> ActiveAdmin renders (filters, form
153
+ # inputs, association pickers — anything, no per-field opt-in needed)
154
+ # is automatically enhanced into a searchable Select2 widget. false
155
+ # (the default) leaves every select exactly as ActiveAdmin renders it;
156
+ # you can still opt individual fields into Select2 yourself either way
157
+ # (see INTEGRATION.md's Select2 section) — this flag only controls the
158
+ # blanket, no-setup-needed behavior. Select2 itself (JS + its own base
159
+ # CSS) ships vendored inside this gem's assets either way — turning
160
+ # this on requires no extra gem/npm dependency or JS of your own;
161
+ # see js-src/prism.js and vendor/select2/.
162
+ attr_accessor :select2
163
+
139
164
  def initialize
140
165
  @sidebar = true
141
166
  @colorize_action_icons = true
@@ -156,6 +181,8 @@ module ActiveAdminPrism
156
181
  { label: "Español", locale: :es },
157
182
  { label: "Français", locale: :fr }
158
183
  ]
184
+ @menu_search = true
185
+ @select2 = false
159
186
  end
160
187
  end
161
188
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveAdminPrism
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.2"
5
5
  end
data/scss-src/_forms.scss CHANGED
@@ -162,12 +162,28 @@ form {
162
162
  }
163
163
  }
164
164
 
165
+ // ActiveAdmin's own CSS lays these out via `float: left` on each `li`
166
+ // (mixins/_buttons.scss's basic-button + _forms.scss's `fieldset.buttons
167
+ // li, fieldset.actions li { float: left; }`) — fragile in practice: it
168
+ // only takes one host app's own CSS reset (or a form with an unusually
169
+ // long submit label, or a differently-structured actions bar) for that
170
+ // float to be lost, at which point every `li` (a block-level list item
171
+ // by default) stacks vertically instead of sitting in a row. Flexbox
172
+ // sidesteps this rather than trying to out-specificity AA's float rule:
173
+ // per spec, `float` has no effect on a flex item at all, so this simply
174
+ // wins outright regardless of load order/specificity, and doesn't
175
+ // depend on every button happening to fit on one line the way floats do.
165
176
  .buttons,
166
177
  .actions {
167
- input[type="submit"],
168
- input[type="button"],
169
- button {
170
- margin-right: 10px;
178
+ > ol {
179
+ display: flex;
180
+ flex-wrap: wrap;
181
+ align-items: center;
182
+ gap: 10px;
183
+ }
184
+
185
+ li {
186
+ padding: 0;
171
187
  }
172
188
  }
173
189
  }
@@ -156,6 +156,15 @@ body:has(.prism-collapsible-panel):not(.prism-filters-open) {
156
156
  }
157
157
 
158
158
  &.open {
159
+ // Both this element and .panel_contents inherit overflow:hidden from
160
+ // the collapsed state above (needed there to clip the height-0
161
+ // content and, on .panel itself, to keep the base .panel rule's
162
+ // rounded-corner clipping) — reset to visible once open, otherwise a
163
+ // filter using a Select2 dropdown (e.g. a belongs_to filter's
164
+ // .select_and_search field) gets its open dropdown list clipped/
165
+ // garbled against this panel's own bounds, since Select2 renders its
166
+ // dropdown as a sibling near the input rather than inside it.
167
+ overflow: visible;
159
168
  background: $prism-bg-card;
160
169
  border-color: $prism-border-color;
161
170
  box-shadow: $prism-shadow-card;
@@ -186,6 +195,7 @@ body:has(.prism-collapsible-panel):not(.prism-filters-open) {
186
195
  }
187
196
 
188
197
  .panel_contents {
198
+ overflow: visible;
189
199
  max-height: 1200px;
190
200
  opacity: 1;
191
201
  padding-top: 20px;
@@ -200,6 +210,7 @@ body:has(.prism-collapsible-panel):not(.prism-filters-open) {
200
210
  // filters_sidebar.rb skips adding the icon/label markup entirely, so this
201
211
  // rule is mostly a no-op safety net rather than the primary mechanism).
202
212
  body.prism-filters-collapsible-disabled .prism-collapsible-panel {
213
+ overflow: visible;
203
214
  background: $prism-bg-card;
204
215
  border-color: $prism-border-color;
205
216
  box-shadow: $prism-shadow-card;
@@ -0,0 +1,264 @@
1
+ // Reskins Select2 (https://select2.org) — the jQuery widget commonly wired
2
+ // up for searchable belongs_to filters/inputs (host apps opt individual
3
+ // selects in with `input_html: { class: "your-select2-class" }` plus their
4
+ // own `.select2()` call; this gem does not ship the JS itself, only the
5
+ // CSS, since Select2 is an opt-in add-on rather than something AA bundles).
6
+ //
7
+ // Select2 replaces the (now hidden) <select> with its own
8
+ // `.select2-container` markup, so the plain `select { ... }` rules in
9
+ // _forms.scss (and the `.sidebar_section select` / `.filter_form_field`
10
+ // width rules) never reach it — every width/sizing/theming rule the
11
+ // original select relied on needs a `.select2-container` equivalent here.
12
+ // Without this file, a Select2-enhanced select renders at its unstyled
13
+ // default size (far narrower than the surrounding fields) and its open
14
+ // dropdown can visually clash with the theme entirely.
15
+ .select2-container {
16
+ display: block;
17
+ font-family: $prism-font-family;
18
+
19
+ .select2-selection--single {
20
+ height: auto;
21
+ border: 1px solid $prism-border-color-strong;
22
+ border-radius: $prism-radius-sm;
23
+ background: $prism-bg-card;
24
+ padding: 9px 32px 9px 12px;
25
+ transition: border-color $prism-transition-fast, box-shadow $prism-transition-fast;
26
+
27
+ .select2-selection__rendered {
28
+ padding: 0;
29
+ color: $prism-text-heading;
30
+ line-height: 1.4;
31
+ }
32
+
33
+ .select2-selection__placeholder {
34
+ color: $prism-text-muted;
35
+ }
36
+
37
+ .select2-selection__arrow {
38
+ height: 100%;
39
+ top: 0;
40
+ right: 8px;
41
+
42
+ b {
43
+ border-color: $prism-text-muted transparent transparent transparent;
44
+ }
45
+ }
46
+ }
47
+
48
+ &--open .select2-selection--single {
49
+ border-color: $prism-color-primary;
50
+ box-shadow: 0 0 0 3px rgba($prism-color-primary, 0.15);
51
+
52
+ .select2-selection__arrow b {
53
+ border-color: transparent transparent $prism-text-muted transparent;
54
+ }
55
+ }
56
+
57
+ &--disabled .select2-selection--single {
58
+ background: $prism-bg-page;
59
+ color: $prism-text-muted;
60
+ cursor: not-allowed;
61
+ }
62
+
63
+ .select2-selection__clear {
64
+ position: static;
65
+ margin: 0 4px 0 0;
66
+ height: auto;
67
+ color: $prism-text-muted;
68
+ font-weight: 700;
69
+
70
+ &:hover {
71
+ color: $prism-color-danger;
72
+ }
73
+ }
74
+
75
+ // Multi-select ("tags") mode — e.g. `f.input :subjects, as: :select2,
76
+ // multiple: true` in place of `as: :check_boxes`. Select2's own default
77
+ // theme lays this out with absolute-positioned remove buttons and
78
+ // per-choice margins (see its own select2-selection--multiple rules) —
79
+ // overridden wholesale here with a flex-wrapped row of pill chips,
80
+ // consistent with the toggle/badge look elsewhere in this theme, rather
81
+ // than patched piecemeal on top of that positioning.
82
+ .select2-selection--multiple {
83
+ min-height: auto;
84
+ border: 1px solid $prism-border-color-strong;
85
+ border-radius: $prism-radius-sm;
86
+ background: $prism-bg-card;
87
+ padding: 6px 8px;
88
+ cursor: text;
89
+ transition: border-color $prism-transition-fast, box-shadow $prism-transition-fast;
90
+
91
+ .select2-selection__rendered {
92
+ display: flex;
93
+ flex-wrap: wrap;
94
+ align-items: center;
95
+ gap: 6px;
96
+ padding: 0;
97
+ }
98
+
99
+ .select2-selection__placeholder {
100
+ color: $prism-text-muted;
101
+ }
102
+
103
+ .select2-selection__choice {
104
+ display: flex;
105
+ align-items: center;
106
+ gap: 4px;
107
+ position: static;
108
+ max-width: 100%;
109
+ margin: 0;
110
+ padding: 3px 6px 3px 10px;
111
+ border: none;
112
+ border-radius: $prism-radius-pill;
113
+ background: $prism-color-primary-light;
114
+ overflow: hidden;
115
+ }
116
+
117
+ .select2-selection__choice__display {
118
+ padding: 0;
119
+ color: $prism-color-primary-dark;
120
+ font-weight: 600;
121
+ font-size: 0.85em;
122
+ overflow: hidden;
123
+ text-overflow: ellipsis;
124
+ }
125
+
126
+ .select2-selection__choice__remove {
127
+ position: static;
128
+ border: none;
129
+ border-radius: 50%;
130
+ background: transparent;
131
+ color: $prism-color-primary-dark;
132
+ font-weight: 700;
133
+ line-height: 1;
134
+ padding: 2px;
135
+
136
+ &:hover,
137
+ &:focus {
138
+ background: rgba($prism-color-primary-dark, 0.15);
139
+ color: $prism-color-primary-dark;
140
+ outline: none;
141
+ }
142
+ }
143
+
144
+ .select2-selection__clear {
145
+ position: static;
146
+ margin: 0 0 0 auto;
147
+ height: auto;
148
+ color: $prism-text-muted;
149
+ font-weight: 700;
150
+
151
+ &:hover {
152
+ color: $prism-color-danger;
153
+ }
154
+ }
155
+
156
+ .select2-search--inline .select2-search__field {
157
+ margin: 0;
158
+ padding: 3px 2px;
159
+ min-height: 0;
160
+ height: auto;
161
+ color: $prism-text-heading;
162
+ font-family: $prism-font-family;
163
+ }
164
+ }
165
+
166
+ &--focus .select2-selection--multiple,
167
+ &--open .select2-selection--multiple {
168
+ border-color: $prism-color-primary;
169
+ box-shadow: 0 0 0 3px rgba($prism-color-primary, 0.15);
170
+ }
171
+
172
+ &--disabled .select2-selection--multiple {
173
+ background: $prism-bg-page;
174
+
175
+ .select2-selection__choice {
176
+ opacity: 0.75;
177
+ }
178
+
179
+ .select2-selection__choice__remove {
180
+ display: none;
181
+ }
182
+ }
183
+ }
184
+
185
+ // Sidebar Filters panel and the standalone filter form both stack the
186
+ // label above the field (no float) and size the underlying <select> to
187
+ // fill it — same treatment for its Select2 replacement (see the
188
+ // equivalent `select` rules in _forms.scss).
189
+ .sidebar_section .select2-container,
190
+ form.filter_form .select2-container {
191
+ width: 100% !important;
192
+ box-sizing: border-box;
193
+ max-width: 100%;
194
+ }
195
+
196
+ form.filter_form .filter_form_field.select_and_search .select2-container {
197
+ width: auto !important;
198
+ min-width: 0;
199
+ flex: 1 1 0;
200
+ }
201
+
202
+ // Main "fieldset.inputs" form: ActiveAdmin's own _forms.scss floats the
203
+ // label left at 20% width and gives text inputs an explicit
204
+ // width: calc(80% - padding) so they sit in the remaining space beside it
205
+ // — a value this gem has no access to at compile time (it's compiled
206
+ // standalone, without AA's own SCSS variables/mixins in its load path; see
207
+ // bin/build-css). Rather than hardcode an equivalent, giving the container
208
+ // its own block formatting context (overflow: hidden, no explicit width)
209
+ // gets the same result for free: per the CSS spec, a BFC-establishing box
210
+ // must not overlap a preceding float's margin box, so the browser sizes it
211
+ // to exactly the remaining space automatically. Without this, the default
212
+ // 100% width above would render full-width *underneath* the floated
213
+ // label, visually painting over it — this is the fix for that, not just a
214
+ // visual nicety.
215
+ fieldset.inputs .select2-container {
216
+ overflow: hidden;
217
+ }
218
+
219
+ // The dropdown panel itself is appended to <body> (or wherever
220
+ // `dropdownParent` points), not inside the field/panel — its own popover
221
+ // styling, independent of whatever container it happens to render in.
222
+ .select2-dropdown {
223
+ border: 1px solid $prism-border-color;
224
+ border-radius: $prism-radius-sm;
225
+ box-shadow: $prism-shadow-popover;
226
+ overflow: hidden;
227
+ }
228
+
229
+ .select2-search--dropdown {
230
+ padding: 8px;
231
+
232
+ .select2-search__field {
233
+ border: 1px solid $prism-border-color-strong;
234
+ border-radius: $prism-radius-sm;
235
+ padding: 7px 10px;
236
+ color: $prism-text-heading;
237
+
238
+ &:focus {
239
+ border-color: $prism-color-primary;
240
+ box-shadow: 0 0 0 3px rgba($prism-color-primary, 0.15);
241
+ }
242
+ }
243
+ }
244
+
245
+ .select2-results__option {
246
+ padding: 8px 12px;
247
+ color: $prism-text-body;
248
+
249
+ &--highlighted {
250
+ background: $prism-color-primary-light !important;
251
+ color: $prism-color-primary-dark !important;
252
+ }
253
+
254
+ &[aria-selected="true"] {
255
+ background: $prism-bg-page;
256
+ color: $prism-text-heading;
257
+ font-weight: 600;
258
+ }
259
+ }
260
+
261
+ .select2-results__message {
262
+ color: $prism-text-muted;
263
+ padding: 8px 12px;
264
+ }