rails_admin_next 1.0.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/Gemfile +46 -0
- data/LICENSE.md +21 -0
- data/README.md +317 -0
- data/Rakefile +25 -0
- data/app/assets/javascripts/rails_admin/custom/ui.js +1 -0
- data/app/assets/stylesheets/rails_admin/framework.css +2375 -0
- data/app/assets/stylesheets/rails_admin/skin.css +472 -0
- data/app/assets/stylesheets/rails_admin/tokens.css +281 -0
- data/app/assets/stylesheets/rails_admin.css +47 -0
- data/app/controllers/rails_admin_next/application_controller.rb +98 -0
- data/app/controllers/rails_admin_next/main_controller.rb +185 -0
- data/app/helpers/rails_admin_next/application_helper.rb +254 -0
- data/app/helpers/rails_admin_next/form_builder.rb +173 -0
- data/app/helpers/rails_admin_next/main_helper.rb +63 -0
- data/app/views/layouts/rails_admin_next/_head.html.erb +29 -0
- data/app/views/layouts/rails_admin_next/_navigation.html.erb +18 -0
- data/app/views/layouts/rails_admin_next/_secondary_navigation.html.erb +27 -0
- data/app/views/layouts/rails_admin_next/_sidebar_navigation.html.erb +6 -0
- data/app/views/layouts/rails_admin_next/application.html.erb +42 -0
- data/app/views/layouts/rails_admin_next/content.html.erb +22 -0
- data/app/views/layouts/rails_admin_next/modal.js.erb +7 -0
- data/app/views/rails_admin_next/main/_dashboard_history.html.erb +46 -0
- data/app/views/rails_admin_next/main/_delete_notice.html.erb +36 -0
- data/app/views/rails_admin_next/main/_form_action_text.html.erb +2 -0
- data/app/views/rails_admin_next/main/_form_boolean.html.erb +15 -0
- data/app/views/rails_admin_next/main/_form_colorpicker.html.erb +6 -0
- data/app/views/rails_admin_next/main/_form_datetime.html.erb +14 -0
- data/app/views/rails_admin_next/main/_form_enumeration.html.erb +26 -0
- data/app/views/rails_admin_next/main/_form_field.html.erb +2 -0
- data/app/views/rails_admin_next/main/_form_file_upload.html.erb +18 -0
- data/app/views/rails_admin_next/main/_form_filtering_multiselect.html.erb +20 -0
- data/app/views/rails_admin_next/main/_form_filtering_select.html.erb +22 -0
- data/app/views/rails_admin_next/main/_form_multiple_file_upload.html.erb +21 -0
- data/app/views/rails_admin_next/main/_form_nested_many.html.erb +20 -0
- data/app/views/rails_admin_next/main/_form_nested_one.html.erb +19 -0
- data/app/views/rails_admin_next/main/_form_polymorphic_association.html.erb +24 -0
- data/app/views/rails_admin_next/main/_form_text.html.erb +2 -0
- data/app/views/rails_admin_next/main/_pagination.html.erb +54 -0
- data/app/views/rails_admin_next/main/_submit_buttons.html.erb +26 -0
- data/app/views/rails_admin_next/main/bulk_delete.html.erb +19 -0
- data/app/views/rails_admin_next/main/dashboard.html.erb +64 -0
- data/app/views/rails_admin_next/main/delete.html.erb +21 -0
- data/app/views/rails_admin_next/main/edit.html.erb +3 -0
- data/app/views/rails_admin_next/main/export.html.erb +149 -0
- data/app/views/rails_admin_next/main/history.html.erb +80 -0
- data/app/views/rails_admin_next/main/index.html.erb +186 -0
- data/app/views/rails_admin_next/main/new.html.erb +3 -0
- data/app/views/rails_admin_next/main/show.html.erb +30 -0
- data/config/brakeman.ignore +145 -0
- data/config/importmap.rails_admin.rb +29 -0
- data/config/initializers/active_record_extensions.rb +21 -0
- data/config/locales/rails_admin_next.en.yml +158 -0
- data/config/routes.rb +14 -0
- data/lib/generators/rails_admin_next/install_generator.rb +39 -0
- data/lib/generators/rails_admin_next/templates/initializer.erb +40 -0
- data/lib/generators/rails_admin_next/utils.rb +21 -0
- data/lib/rails_admin_next/abstract_model.rb +266 -0
- data/lib/rails_admin_next/adapters/active_record/association.rb +130 -0
- data/lib/rails_admin_next/adapters/active_record/object_extension.rb +13 -0
- data/lib/rails_admin_next/adapters/active_record/property.rb +58 -0
- data/lib/rails_admin_next/adapters/active_record.rb +371 -0
- data/lib/rails_admin_next/config/actions/base.rb +160 -0
- data/lib/rails_admin_next/config/actions/bulk_delete.rb +67 -0
- data/lib/rails_admin_next/config/actions/dashboard.rb +62 -0
- data/lib/rails_admin_next/config/actions/delete.rb +58 -0
- data/lib/rails_admin_next/config/actions/edit.rb +56 -0
- data/lib/rails_admin_next/config/actions/export.rb +47 -0
- data/lib/rails_admin_next/config/actions/history_index.rb +40 -0
- data/lib/rails_admin_next/config/actions/history_show.rb +40 -0
- data/lib/rails_admin_next/config/actions/index.rb +108 -0
- data/lib/rails_admin_next/config/actions/new.rb +69 -0
- data/lib/rails_admin_next/config/actions/show.rb +36 -0
- data/lib/rails_admin_next/config/actions/show_in_app.rb +37 -0
- data/lib/rails_admin_next/config/actions.rb +122 -0
- data/lib/rails_admin_next/config/configurable.rb +107 -0
- data/lib/rails_admin_next/config/const_load_suppressor.rb +78 -0
- data/lib/rails_admin_next/config/fields/association.rb +169 -0
- data/lib/rails_admin_next/config/fields/base.rb +391 -0
- data/lib/rails_admin_next/config/fields/collection_association.rb +90 -0
- data/lib/rails_admin_next/config/fields/factories/action_text.rb +14 -0
- data/lib/rails_admin_next/config/fields/factories/active_storage.rb +34 -0
- data/lib/rails_admin_next/config/fields/factories/association.rb +32 -0
- data/lib/rails_admin_next/config/fields/factories/encrypted_password.rb +28 -0
- data/lib/rails_admin_next/config/fields/factories/enum.rb +23 -0
- data/lib/rails_admin_next/config/fields/factories/password.rb +19 -0
- data/lib/rails_admin_next/config/fields/group.rb +77 -0
- data/lib/rails_admin_next/config/fields/singular_association.rb +62 -0
- data/lib/rails_admin_next/config/fields/types/action_text.rb +20 -0
- data/lib/rails_admin_next/config/fields/types/active_record_enum.rb +62 -0
- data/lib/rails_admin_next/config/fields/types/active_storage.rb +70 -0
- data/lib/rails_admin_next/config/fields/types/all.rb +32 -0
- data/lib/rails_admin_next/config/fields/types/belongs_to_association.rb +48 -0
- data/lib/rails_admin_next/config/fields/types/boolean.rb +70 -0
- data/lib/rails_admin_next/config/fields/types/citext.rb +15 -0
- data/lib/rails_admin_next/config/fields/types/color.rb +43 -0
- data/lib/rails_admin_next/config/fields/types/date.rb +38 -0
- data/lib/rails_admin_next/config/fields/types/datetime.rb +79 -0
- data/lib/rails_admin_next/config/fields/types/decimal.rb +23 -0
- data/lib/rails_admin_next/config/fields/types/enum.rb +52 -0
- data/lib/rails_admin_next/config/fields/types/file_upload.rb +87 -0
- data/lib/rails_admin_next/config/fields/types/float.rb +23 -0
- data/lib/rails_admin_next/config/fields/types/has_and_belongs_to_many_association.rb +16 -0
- data/lib/rails_admin_next/config/fields/types/has_many_association.rb +16 -0
- data/lib/rails_admin_next/config/fields/types/has_one_association.rb +35 -0
- data/lib/rails_admin_next/config/fields/types/hidden.rb +31 -0
- data/lib/rails_admin_next/config/fields/types/inet.rb +15 -0
- data/lib/rails_admin_next/config/fields/types/integer.rb +20 -0
- data/lib/rails_admin_next/config/fields/types/json.rb +37 -0
- data/lib/rails_admin_next/config/fields/types/multiple_active_storage.rb +82 -0
- data/lib/rails_admin_next/config/fields/types/multiple_file_upload.rb +134 -0
- data/lib/rails_admin_next/config/fields/types/numeric.rb +24 -0
- data/lib/rails_admin_next/config/fields/types/password.rb +45 -0
- data/lib/rails_admin_next/config/fields/types/polymorphic_association.rb +118 -0
- data/lib/rails_admin_next/config/fields/types/serialized.rb +28 -0
- data/lib/rails_admin_next/config/fields/types/string.rb +50 -0
- data/lib/rails_admin_next/config/fields/types/string_like.rb +25 -0
- data/lib/rails_admin_next/config/fields/types/text.rb +28 -0
- data/lib/rails_admin_next/config/fields/types/time.rb +39 -0
- data/lib/rails_admin_next/config/fields/types/timestamp.rb +16 -0
- data/lib/rails_admin_next/config/fields/types/uuid.rb +15 -0
- data/lib/rails_admin_next/config/fields/types.rb +29 -0
- data/lib/rails_admin_next/config/fields.rb +88 -0
- data/lib/rails_admin_next/config/groupable.rb +20 -0
- data/lib/rails_admin_next/config/has_description.rb +14 -0
- data/lib/rails_admin_next/config/has_fields.rb +144 -0
- data/lib/rails_admin_next/config/has_groups.rb +27 -0
- data/lib/rails_admin_next/config/hideable.rb +30 -0
- data/lib/rails_admin_next/config/inspectable.rb +39 -0
- data/lib/rails_admin_next/config/lazy_model.rb +74 -0
- data/lib/rails_admin_next/config/model.rb +126 -0
- data/lib/rails_admin_next/config/proxyable/proxy.rb +42 -0
- data/lib/rails_admin_next/config/proxyable.rb +26 -0
- data/lib/rails_admin_next/config/sections/base.rb +36 -0
- data/lib/rails_admin_next/config/sections/create.rb +13 -0
- data/lib/rails_admin_next/config/sections/edit.rb +13 -0
- data/lib/rails_admin_next/config/sections/export.rb +13 -0
- data/lib/rails_admin_next/config/sections/list.rb +63 -0
- data/lib/rails_admin_next/config/sections/modal.rb +12 -0
- data/lib/rails_admin_next/config/sections/nested.rb +12 -0
- data/lib/rails_admin_next/config/sections/show.rb +12 -0
- data/lib/rails_admin_next/config/sections/update.rb +12 -0
- data/lib/rails_admin_next/config/sections.rb +39 -0
- data/lib/rails_admin_next/config.rb +436 -0
- data/lib/rails_admin_next/engine.rb +91 -0
- data/lib/rails_admin_next/extension.rb +35 -0
- data/lib/rails_admin_next/extensions/cancancan/authorization_adapter.rb +90 -0
- data/lib/rails_admin_next/extensions/cancancan.rb +5 -0
- data/lib/rails_admin_next/extensions/controller_extension.rb +8 -0
- data/lib/rails_admin_next/extensions/paper_trail/auditing_adapter.rb +192 -0
- data/lib/rails_admin_next/extensions/paper_trail.rb +5 -0
- data/lib/rails_admin_next/extensions/pundit/authorization_adapter.rb +72 -0
- data/lib/rails_admin_next/extensions/pundit.rb +5 -0
- data/lib/rails_admin_next/extensions/url_for_extension.rb +15 -0
- data/lib/rails_admin_next/icons.rb +143 -0
- data/lib/rails_admin_next/support/composite_keys_serializer.rb +15 -0
- data/lib/rails_admin_next/support/csv_converter.rb +100 -0
- data/lib/rails_admin_next/support/hash_helper.rb +31 -0
- data/lib/rails_admin_next/support/paginated_collection.rb +79 -0
- data/lib/rails_admin_next/version.rb +17 -0
- data/lib/rails_admin_next.rb +72 -0
- data/lib/tasks/rails_admin.rake +40 -0
- data/src/rails_admin/application.js +15 -0
- data/src/rails_admin/base.js +12 -0
- data/src/rails_admin/controllers/alert_controller.js +28 -0
- data/src/rails_admin/controllers/collapse_controller.js +16 -0
- data/src/rails_admin/controllers/dropdown_controller.js +110 -0
- data/src/rails_admin/controllers/feedback_controller.js +73 -0
- data/src/rails_admin/controllers/filter_box_controller.js +346 -0
- data/src/rails_admin/controllers/filtering_multiselect_controller.js +386 -0
- data/src/rails_admin/controllers/filtering_select_controller.js +509 -0
- data/src/rails_admin/controllers/index.js +31 -0
- data/src/rails_admin/controllers/modal_controller.js +142 -0
- data/src/rails_admin/controllers/nested_form_controller.js +117 -0
- data/src/rails_admin/controllers/polymorphic_controller.js +26 -0
- data/src/rails_admin/controllers/remote_form_controller.js +173 -0
- data/src/rails_admin/controllers/sidescroll_controller.js +23 -0
- data/src/rails_admin/dom-ready.js +20 -0
- data/src/rails_admin/dom.js +19 -0
- data/src/rails_admin/i18n.js +20 -0
- data/src/rails_admin/icons.js +64 -0
- data/src/rails_admin/interactions.js +221 -0
- data/src/rails_admin/select_query.js +65 -0
- metadata +346 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus";
|
|
2
|
+
import I18n from "rails_admin/i18n";
|
|
3
|
+
import { icon } from "rails_admin/icons";
|
|
4
|
+
import { el } from "rails_admin/dom";
|
|
5
|
+
import {
|
|
6
|
+
createScopedQuery,
|
|
7
|
+
toQueryString,
|
|
8
|
+
escapeRegex,
|
|
9
|
+
fetchJson,
|
|
10
|
+
} from "rails_admin/select_query";
|
|
11
|
+
|
|
12
|
+
const SEARCH_DELAY = 200;
|
|
13
|
+
|
|
14
|
+
let sequence = 0;
|
|
15
|
+
|
|
16
|
+
// Accessible combobox over a hidden <select> (the real form field), following
|
|
17
|
+
// the WAI-ARIA APG combobox + listbox pattern: role=combobox input with
|
|
18
|
+
// aria-expanded / aria-activedescendant driving a role=listbox of role=option
|
|
19
|
+
// items, arrow/Enter/Escape/Home/End keys, and aria-live regions for the
|
|
20
|
+
// empty and error states.
|
|
21
|
+
//
|
|
22
|
+
// Wired by data-controller="filtering-select" on the <select>, with
|
|
23
|
+
// data-filtering-select-options-value = { xhr, remoteSource, scopeBy }.
|
|
24
|
+
//
|
|
25
|
+
// The rendered DOM keeps the class hooks the inline-add flow (remote-form.js)
|
|
26
|
+
// and the existing CSS depend on: `.filtering-select[data-input-for]`,
|
|
27
|
+
// `.ra-filtering-select-input`, `.dropdown-toggle`.
|
|
28
|
+
export default class extends Controller {
|
|
29
|
+
static values = { options: { type: Object, default: {} } };
|
|
30
|
+
|
|
31
|
+
connect() {
|
|
32
|
+
this.select = this.element;
|
|
33
|
+
this.activeIndex = -1;
|
|
34
|
+
this.items = [];
|
|
35
|
+
this.requestId = 0;
|
|
36
|
+
this.#build();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
disconnect() {
|
|
40
|
+
// Remove the generated UI and restore the <select> so a Turbo snapshot
|
|
41
|
+
// caches the original markup — this is what prevents a duplicate combobox
|
|
42
|
+
// from being restored on browser back/forward.
|
|
43
|
+
clearTimeout(this.searchTimer);
|
|
44
|
+
this.#teardown();
|
|
45
|
+
this.group?.remove();
|
|
46
|
+
this.select.style.display = "";
|
|
47
|
+
this.select.removeAttribute("data-ra-filtering-select-built");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Re-initialise against a fresh option set / remote source (used by the
|
|
51
|
+
// polymorphic type switch): tears down and fully rebuilds the field.
|
|
52
|
+
reload() {
|
|
53
|
+
this.#teardown();
|
|
54
|
+
this.group?.remove();
|
|
55
|
+
this.select.innerHTML = '<option value="" selected="selected"></option>';
|
|
56
|
+
this.#build();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Remove the document-level outside-click listener bound in #bind(). Without
|
|
60
|
+
// this, every reload() (each polymorphic type switch) would add a new listener
|
|
61
|
+
// on top of the previous one, leaking a permanent handler closing over
|
|
62
|
+
// removed DOM.
|
|
63
|
+
#teardown() {
|
|
64
|
+
if (this.outsideHandler)
|
|
65
|
+
document.removeEventListener("click", this.outsideHandler);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#build() {
|
|
69
|
+
const id = this.select.id;
|
|
70
|
+
|
|
71
|
+
// Reuse an orphaned group from a bfcache snapshot rather than duplicating it.
|
|
72
|
+
this.element.parentNode
|
|
73
|
+
.querySelectorAll(`.filtering-select[data-input-for="${CSS.escape(id)}"]`)
|
|
74
|
+
.forEach((node) => node.remove());
|
|
75
|
+
|
|
76
|
+
// Capture the select's own style BEFORE hiding it — and after clearing any
|
|
77
|
+
// display we set on a previous build (reload) — so we never copy display:none
|
|
78
|
+
// onto the visible combobox input.
|
|
79
|
+
this.select.style.display = "";
|
|
80
|
+
const selectStyle = this.select.getAttribute("style");
|
|
81
|
+
this.select.style.display = "none";
|
|
82
|
+
this.select.setAttribute("data-ra-filtering-select-built", "true");
|
|
83
|
+
|
|
84
|
+
const selected = this.select.options[this.select.selectedIndex];
|
|
85
|
+
const value = selected && selected.value ? selected.text : "";
|
|
86
|
+
|
|
87
|
+
this.listId = `ra-fs-list-${(sequence += 1)}`;
|
|
88
|
+
this.statusId = `ra-fs-status-${sequence}`;
|
|
89
|
+
|
|
90
|
+
this.group = el("div", {
|
|
91
|
+
class: "input-group filtering-select",
|
|
92
|
+
"data-input-for": id,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
this.input = el("input", {
|
|
96
|
+
type: "text",
|
|
97
|
+
class: "form-control ra-filtering-select-input",
|
|
98
|
+
role: "combobox",
|
|
99
|
+
"aria-autocomplete": "list",
|
|
100
|
+
"aria-expanded": "false",
|
|
101
|
+
"aria-controls": this.listId,
|
|
102
|
+
"aria-describedby": this.statusId,
|
|
103
|
+
autocomplete: "off",
|
|
104
|
+
});
|
|
105
|
+
this.input.value = value;
|
|
106
|
+
if (selectStyle) {
|
|
107
|
+
this.input.setAttribute("style", selectStyle);
|
|
108
|
+
}
|
|
109
|
+
if (this.select.getAttribute("placeholder")) {
|
|
110
|
+
this.input.setAttribute(
|
|
111
|
+
"placeholder",
|
|
112
|
+
this.select.getAttribute("placeholder")
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
// Move `required` off the hidden <select> (it can't be validated while hidden).
|
|
116
|
+
if (this.select.hasAttribute("required")) {
|
|
117
|
+
this.required = true;
|
|
118
|
+
this.input.setAttribute("required", "required");
|
|
119
|
+
this.select.removeAttribute("required");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
this.button = el("label", {
|
|
123
|
+
class: "btn btn-info dropdown-toggle",
|
|
124
|
+
role: "button",
|
|
125
|
+
title: "Show All Items",
|
|
126
|
+
"aria-label": "Show All Items",
|
|
127
|
+
});
|
|
128
|
+
this.buttonWrap = el("span", { class: "input-group-btn" }, this.button);
|
|
129
|
+
|
|
130
|
+
this.menu = el("ul", {
|
|
131
|
+
class: "ra-filtering-select-menu",
|
|
132
|
+
id: this.listId,
|
|
133
|
+
role: "listbox",
|
|
134
|
+
hidden: true,
|
|
135
|
+
});
|
|
136
|
+
Object.assign(this.menu.style, {
|
|
137
|
+
position: "absolute",
|
|
138
|
+
zIndex: "1000",
|
|
139
|
+
top: "100%",
|
|
140
|
+
left: "0",
|
|
141
|
+
right: "0",
|
|
142
|
+
margin: "0",
|
|
143
|
+
padding: "0",
|
|
144
|
+
listStyle: "none",
|
|
145
|
+
background: "#fff",
|
|
146
|
+
border: "1px solid rgba(0,0,0,.15)",
|
|
147
|
+
maxHeight: "16rem",
|
|
148
|
+
overflowY: "auto",
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
this.status = el("div", {
|
|
152
|
+
id: this.statusId,
|
|
153
|
+
class: "ra-filtering-select-status visually-hidden",
|
|
154
|
+
role: "status",
|
|
155
|
+
"aria-live": "polite",
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
this.error = el("div", {
|
|
159
|
+
class: "ra-filtering-select-error text-danger",
|
|
160
|
+
role: "alert",
|
|
161
|
+
"aria-live": "assertive",
|
|
162
|
+
hidden: true,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
this.group.append(
|
|
166
|
+
this.input,
|
|
167
|
+
this.buttonWrap,
|
|
168
|
+
this.menu,
|
|
169
|
+
this.status,
|
|
170
|
+
this.error
|
|
171
|
+
);
|
|
172
|
+
this.group.style.position = "relative";
|
|
173
|
+
this.select.after(this.group);
|
|
174
|
+
|
|
175
|
+
this.#bind();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
#bind() {
|
|
179
|
+
this.input.addEventListener("input", () => {
|
|
180
|
+
// Emptying the box clears the current selection.
|
|
181
|
+
if (this.input.value.length === 0) this.#clear();
|
|
182
|
+
this.#scheduleSearch(this.input.value);
|
|
183
|
+
});
|
|
184
|
+
this.input.addEventListener("keydown", (e) => this.#onKeydown(e));
|
|
185
|
+
this.input.addEventListener("blur", () => this.#onBlur());
|
|
186
|
+
this.button.addEventListener("click", (e) => {
|
|
187
|
+
e.preventDefault();
|
|
188
|
+
this.#onToggle();
|
|
189
|
+
});
|
|
190
|
+
this.outsideHandler = (e) => {
|
|
191
|
+
if (this.group && !this.group.contains(e.target)) this.#close();
|
|
192
|
+
};
|
|
193
|
+
document.addEventListener("click", this.outsideHandler);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
#scheduleSearch(term) {
|
|
197
|
+
clearTimeout(this.searchTimer);
|
|
198
|
+
this.searchTimer = setTimeout(() => this.#search(term), SEARCH_DELAY);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
#onToggle() {
|
|
202
|
+
if (!this.menu.hidden) {
|
|
203
|
+
this.#close();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
this.#search("");
|
|
207
|
+
this.input.focus();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
#search(term) {
|
|
211
|
+
this.#query(term, (results, isRemote) =>
|
|
212
|
+
this.#renderMenu(this.#resultSet(term, results, isRemote))
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Resolve the source: local (the <select>'s own options) or remote (xhr).
|
|
217
|
+
#query(term, done) {
|
|
218
|
+
if (this.optionsValue.xhr) {
|
|
219
|
+
this.#remoteQuery(term, done);
|
|
220
|
+
} else {
|
|
221
|
+
const source = Array.from(this.select.options)
|
|
222
|
+
.filter((o) => o.value !== "")
|
|
223
|
+
.map((o) => ({ label: o.text, value: o.value }));
|
|
224
|
+
const matcher = new RegExp(escapeRegex(term), "i");
|
|
225
|
+
done(
|
|
226
|
+
source.filter((el) => matcher.test(el.label)),
|
|
227
|
+
false
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
#remoteQuery(term, done) {
|
|
233
|
+
const id = ++this.requestId;
|
|
234
|
+
this.controller?.abort();
|
|
235
|
+
this.controller = new AbortController();
|
|
236
|
+
this.#setBusy(true);
|
|
237
|
+
this.error.hidden = true;
|
|
238
|
+
|
|
239
|
+
const query = createScopedQuery(this.optionsValue.scopeBy, term);
|
|
240
|
+
const url = `${this.optionsValue.remote_source}&${toQueryString(query)}`;
|
|
241
|
+
|
|
242
|
+
fetchJson(url, { signal: this.controller.signal })
|
|
243
|
+
.then((data) => {
|
|
244
|
+
if (id !== this.requestId) return;
|
|
245
|
+
this.#setBusy(false);
|
|
246
|
+
done(data, true);
|
|
247
|
+
})
|
|
248
|
+
.catch((error) => {
|
|
249
|
+
if (error.name === "AbortError" || id !== this.requestId) return;
|
|
250
|
+
this.#setBusy(false);
|
|
251
|
+
this.#renderError();
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Build the list of menu entries: a leading "Clear" entry on an empty,
|
|
256
|
+
// optional field; a disabled "no objects" entry when nothing matches;
|
|
257
|
+
// otherwise the (highlighted) matches.
|
|
258
|
+
#resultSet(term, data, isRemote) {
|
|
259
|
+
const matcher = new RegExp(escapeRegex(term), "i");
|
|
260
|
+
const matches = data
|
|
261
|
+
.map((el) => {
|
|
262
|
+
const id = el.id || el.value;
|
|
263
|
+
const label = el.label || el.id;
|
|
264
|
+
if (id && (isRemote || matcher.test(el.label))) {
|
|
265
|
+
return { id, value: label, term };
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
})
|
|
269
|
+
.filter(Boolean);
|
|
270
|
+
|
|
271
|
+
if (term.length === 0 && !this.required) {
|
|
272
|
+
return [{ clear: true }, ...matches];
|
|
273
|
+
}
|
|
274
|
+
if (matches.length === 0) {
|
|
275
|
+
return [{ empty: true }];
|
|
276
|
+
}
|
|
277
|
+
return matches;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
#renderMenu(entries) {
|
|
281
|
+
this.menu.replaceChildren();
|
|
282
|
+
this.items = [];
|
|
283
|
+
this.activeIndex = -1;
|
|
284
|
+
|
|
285
|
+
entries.forEach((entry) => {
|
|
286
|
+
const li = document.createElement("li");
|
|
287
|
+
li.id = `${this.listId}-opt-${this.items.length}`;
|
|
288
|
+
li.setAttribute("role", "option");
|
|
289
|
+
li.setAttribute("aria-selected", "false");
|
|
290
|
+
li.className = "ra-filtering-select-option";
|
|
291
|
+
Object.assign(li.style, { padding: ".25rem .75rem", cursor: "pointer" });
|
|
292
|
+
|
|
293
|
+
if (entry.empty) {
|
|
294
|
+
li.setAttribute("aria-disabled", "true");
|
|
295
|
+
li.classList.add("ra-filtering-select-empty");
|
|
296
|
+
li.textContent = I18n.t("no_objects");
|
|
297
|
+
li.style.cursor = "default";
|
|
298
|
+
this.#announce(I18n.t("no_objects"));
|
|
299
|
+
} else if (entry.clear) {
|
|
300
|
+
li.dataset.clear = "true";
|
|
301
|
+
li.append(icon("cancel"), ` ${I18n.t("clear")}`);
|
|
302
|
+
li.addEventListener("mousedown", (e) => e.preventDefault());
|
|
303
|
+
li.addEventListener("click", () => this.#choose(li));
|
|
304
|
+
this.items.push({ el: li, id: null, value: null });
|
|
305
|
+
} else {
|
|
306
|
+
li.dataset.value = entry.id;
|
|
307
|
+
li.dataset.label = entry.value;
|
|
308
|
+
li.append(...this.#highlight(entry.value, entry.term));
|
|
309
|
+
li.addEventListener("mousedown", (e) => e.preventDefault());
|
|
310
|
+
li.addEventListener("click", () => this.#choose(li));
|
|
311
|
+
this.items.push({ el: li, id: entry.id, value: entry.value });
|
|
312
|
+
}
|
|
313
|
+
this.menu.appendChild(li);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
this.#open();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
#renderError() {
|
|
320
|
+
this.menu.replaceChildren();
|
|
321
|
+
this.items = [];
|
|
322
|
+
this.error.replaceChildren();
|
|
323
|
+
const message = document.createElement("span");
|
|
324
|
+
message.textContent = I18n.t("no_objects");
|
|
325
|
+
const retry = document.createElement("button");
|
|
326
|
+
retry.type = "button";
|
|
327
|
+
retry.className = "btn btn-link btn-sm ra-filtering-select-retry";
|
|
328
|
+
retry.textContent = I18n.t("retry");
|
|
329
|
+
retry.addEventListener("click", (e) => {
|
|
330
|
+
e.preventDefault();
|
|
331
|
+
this.#search(this.input.value);
|
|
332
|
+
});
|
|
333
|
+
this.error.append(message, " ", retry);
|
|
334
|
+
this.error.hidden = false;
|
|
335
|
+
this.#close();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Split `label` on `term` and wrap the matched term in <strong>, building DOM
|
|
339
|
+
// text nodes (never innerHTML) so server-supplied labels can't inject markup.
|
|
340
|
+
#highlight(label, term) {
|
|
341
|
+
if (!term || !term.length) return [document.createTextNode(label)];
|
|
342
|
+
const matcher = new RegExp(escapeRegex(term), "i");
|
|
343
|
+
const nodes = [];
|
|
344
|
+
let rest = label;
|
|
345
|
+
let match;
|
|
346
|
+
while ((match = rest.match(matcher)) && match[0].length) {
|
|
347
|
+
const index = match.index;
|
|
348
|
+
if (index > 0) nodes.push(document.createTextNode(rest.slice(0, index)));
|
|
349
|
+
const strong = document.createElement("strong");
|
|
350
|
+
strong.textContent = rest.slice(index, index + match[0].length);
|
|
351
|
+
nodes.push(strong);
|
|
352
|
+
rest = rest.slice(index + match[0].length);
|
|
353
|
+
}
|
|
354
|
+
if (rest.length) nodes.push(document.createTextNode(rest));
|
|
355
|
+
return nodes;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
#choose(li) {
|
|
359
|
+
if (li.dataset.clear) {
|
|
360
|
+
this.#clear();
|
|
361
|
+
this.#close();
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const id = li.dataset.value;
|
|
365
|
+
const label = li.dataset.label;
|
|
366
|
+
|
|
367
|
+
let option = Array.from(this.select.options).find((o) => o.value === id);
|
|
368
|
+
this.select
|
|
369
|
+
.querySelectorAll("option[selected]")
|
|
370
|
+
.forEach((o) => (o.selected = false));
|
|
371
|
+
if (option) {
|
|
372
|
+
option.selected = true;
|
|
373
|
+
} else {
|
|
374
|
+
option = document.createElement("option");
|
|
375
|
+
option.value = id;
|
|
376
|
+
option.selected = true;
|
|
377
|
+
option.text = label;
|
|
378
|
+
this.select.appendChild(option);
|
|
379
|
+
}
|
|
380
|
+
this.input.value = label;
|
|
381
|
+
this.select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
382
|
+
this.#enableInlineEdit(true);
|
|
383
|
+
this.#close();
|
|
384
|
+
this.input.focus();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Deselect without destroying the option list, so the candidate set survives a
|
|
388
|
+
// browser back/forward restore.
|
|
389
|
+
#clear() {
|
|
390
|
+
this.input.value = "";
|
|
391
|
+
this.select
|
|
392
|
+
.querySelectorAll("option[selected]")
|
|
393
|
+
.forEach((o) => (o.selected = false));
|
|
394
|
+
let blank = Array.from(this.select.options).find((o) => o.value === "");
|
|
395
|
+
if (!blank) {
|
|
396
|
+
blank = document.createElement("option");
|
|
397
|
+
blank.value = "";
|
|
398
|
+
this.select.insertBefore(blank, this.select.firstChild);
|
|
399
|
+
}
|
|
400
|
+
blank.selected = true;
|
|
401
|
+
this.select.value = "";
|
|
402
|
+
this.select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
403
|
+
this.#enableInlineEdit(false);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
#onKeydown(event) {
|
|
407
|
+
switch (event.key) {
|
|
408
|
+
case "ArrowDown":
|
|
409
|
+
event.preventDefault();
|
|
410
|
+
if (this.menu.hidden) this.#search(this.input.value);
|
|
411
|
+
else this.#move(1);
|
|
412
|
+
break;
|
|
413
|
+
case "ArrowUp":
|
|
414
|
+
event.preventDefault();
|
|
415
|
+
this.#move(-1);
|
|
416
|
+
break;
|
|
417
|
+
case "Home":
|
|
418
|
+
if (!this.menu.hidden) {
|
|
419
|
+
event.preventDefault();
|
|
420
|
+
this.#activate(0);
|
|
421
|
+
}
|
|
422
|
+
break;
|
|
423
|
+
case "End":
|
|
424
|
+
if (!this.menu.hidden) {
|
|
425
|
+
event.preventDefault();
|
|
426
|
+
this.#activate(this.items.length - 1);
|
|
427
|
+
}
|
|
428
|
+
break;
|
|
429
|
+
case "Enter":
|
|
430
|
+
if (!this.menu.hidden && this.activeIndex >= 0) {
|
|
431
|
+
event.preventDefault();
|
|
432
|
+
this.#choose(this.items[this.activeIndex].el);
|
|
433
|
+
}
|
|
434
|
+
break;
|
|
435
|
+
case "Escape":
|
|
436
|
+
if (!this.menu.hidden) {
|
|
437
|
+
event.preventDefault();
|
|
438
|
+
this.#close();
|
|
439
|
+
}
|
|
440
|
+
break;
|
|
441
|
+
default:
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
#onBlur() {
|
|
447
|
+
// Revert a half-typed, uncommitted query back to the committed selection's
|
|
448
|
+
// label so the input never shows a value the <select> doesn't hold.
|
|
449
|
+
setTimeout(() => {
|
|
450
|
+
if (this.group && this.group.contains(document.activeElement)) return;
|
|
451
|
+
const selected = this.select.options[this.select.selectedIndex];
|
|
452
|
+
const label = selected && selected.value ? selected.text : "";
|
|
453
|
+
if (this.input.value !== label) this.input.value = label;
|
|
454
|
+
this.#close();
|
|
455
|
+
}, 150);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
#move(direction) {
|
|
459
|
+
if (this.items.length === 0) return;
|
|
460
|
+
let index = this.activeIndex + direction;
|
|
461
|
+
if (index < 0) index = this.items.length - 1;
|
|
462
|
+
if (index >= this.items.length) index = 0;
|
|
463
|
+
this.#activate(index);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
#activate(index) {
|
|
467
|
+
this.items.forEach(({ el }) => {
|
|
468
|
+
el.classList.remove("active");
|
|
469
|
+
el.setAttribute("aria-selected", "false");
|
|
470
|
+
});
|
|
471
|
+
this.activeIndex = index;
|
|
472
|
+
const { el } = this.items[index];
|
|
473
|
+
el.classList.add("active");
|
|
474
|
+
el.setAttribute("aria-selected", "true");
|
|
475
|
+
el.scrollIntoView({ block: "nearest" });
|
|
476
|
+
this.input.setAttribute("aria-activedescendant", el.id);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
#open() {
|
|
480
|
+
this.menu.hidden = false;
|
|
481
|
+
this.input.setAttribute("aria-expanded", "true");
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
#close() {
|
|
485
|
+
this.menu.hidden = true;
|
|
486
|
+
this.input.setAttribute("aria-expanded", "false");
|
|
487
|
+
this.input.removeAttribute("aria-activedescendant");
|
|
488
|
+
this.activeIndex = -1;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
#setBusy(busy) {
|
|
492
|
+
this.input.setAttribute("aria-busy", busy ? "true" : "false");
|
|
493
|
+
this.group.classList.toggle("ra-filtering-select-busy", busy);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
#announce(message) {
|
|
497
|
+
this.status.textContent = message;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
#enableInlineEdit(enabled) {
|
|
501
|
+
const controls = this.select.closest(".controls");
|
|
502
|
+
controls?.querySelector(".update")?.classList.toggle("disabled", !enabled);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
optionsValueChanged() {
|
|
506
|
+
// The polymorphic controller swaps this in; rebuild against the new source.
|
|
507
|
+
if (this.group) this.reload();
|
|
508
|
+
}
|
|
509
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { application } from "rails_admin/application";
|
|
2
|
+
|
|
3
|
+
import AlertController from "rails_admin/controllers/alert_controller";
|
|
4
|
+
import CollapseController from "rails_admin/controllers/collapse_controller";
|
|
5
|
+
import DropdownController from "rails_admin/controllers/dropdown_controller";
|
|
6
|
+
import FeedbackController from "rails_admin/controllers/feedback_controller";
|
|
7
|
+
import FilterBoxController from "rails_admin/controllers/filter_box_controller";
|
|
8
|
+
import FilteringMultiselectController from "rails_admin/controllers/filtering_multiselect_controller";
|
|
9
|
+
import FilteringSelectController from "rails_admin/controllers/filtering_select_controller";
|
|
10
|
+
import ModalController from "rails_admin/controllers/modal_controller";
|
|
11
|
+
import NestedFormController from "rails_admin/controllers/nested_form_controller";
|
|
12
|
+
import PolymorphicController from "rails_admin/controllers/polymorphic_controller";
|
|
13
|
+
import RemoteFormController from "rails_admin/controllers/remote_form_controller";
|
|
14
|
+
import SidescrollController from "rails_admin/controllers/sidescroll_controller";
|
|
15
|
+
|
|
16
|
+
// Explicit manifest (one import + register per controller). Preferred over
|
|
17
|
+
// stimulus-loading's eager/lazy autoloading because the engine ships a small,
|
|
18
|
+
// known set of controllers and must stay self-contained — it can't scan the
|
|
19
|
+
// host's controllers/ path under importmap+Propshaft.
|
|
20
|
+
application.register("alert", AlertController);
|
|
21
|
+
application.register("collapse", CollapseController);
|
|
22
|
+
application.register("dropdown", DropdownController);
|
|
23
|
+
application.register("feedback", FeedbackController);
|
|
24
|
+
application.register("filter-box", FilterBoxController);
|
|
25
|
+
application.register("filtering-multiselect", FilteringMultiselectController);
|
|
26
|
+
application.register("filtering-select", FilteringSelectController);
|
|
27
|
+
application.register("modal", ModalController);
|
|
28
|
+
application.register("nested-form", NestedFormController);
|
|
29
|
+
application.register("polymorphic", PolymorphicController);
|
|
30
|
+
application.register("remote-form", RemoteFormController);
|
|
31
|
+
application.register("sidescroll", SidescrollController);
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus";
|
|
2
|
+
|
|
3
|
+
const FOCUSABLE = [
|
|
4
|
+
"a[href]",
|
|
5
|
+
"button:not([disabled])",
|
|
6
|
+
'input:not([disabled]):not([type="hidden"])',
|
|
7
|
+
"select:not([disabled])",
|
|
8
|
+
"textarea:not([disabled])",
|
|
9
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
10
|
+
].join(", ");
|
|
11
|
+
|
|
12
|
+
// Inline-edit dialog on a native <dialog closedby="any">: the platform owns the
|
|
13
|
+
// top layer, focus trap, Escape, backdrop light-dismissal, return focus and
|
|
14
|
+
// ARIA modality. This controller only fills it with a server-rendered form via
|
|
15
|
+
// renderContent() and wires the primary action through the `onSave` callback
|
|
16
|
+
// set by RemoteFormController.
|
|
17
|
+
//
|
|
18
|
+
// Every dismissal — cancel button, Escape, backdrop click, Turbo teardown —
|
|
19
|
+
// funnels through the native `close` event, so the reset there (nulling
|
|
20
|
+
// `onSave` invalidates in-flight loads, see RemoteFormController#open) cannot
|
|
21
|
+
// be bypassed.
|
|
22
|
+
export default class extends Controller {
|
|
23
|
+
static targets = ["title", "body", "saveButton", "cancelButton"];
|
|
24
|
+
|
|
25
|
+
connect() {
|
|
26
|
+
this.onSave = null;
|
|
27
|
+
// The markup seeds the title with the I18n loading string; keep it to
|
|
28
|
+
// re-seed the loading state on every close.
|
|
29
|
+
this.loadingTitle = this.titleTarget.textContent;
|
|
30
|
+
this.onClose = () => this.#reset();
|
|
31
|
+
this.element.addEventListener("close", this.onClose);
|
|
32
|
+
// Turbo snapshots the DOM as-is, [open] included — but a restored [open]
|
|
33
|
+
// dialog would sit inline, outside the top layer. Close before the
|
|
34
|
+
// snapshot is taken, and on connect in case a stray one got cached anyway.
|
|
35
|
+
this.onBeforeCache = () => this.#dismiss();
|
|
36
|
+
document.addEventListener("turbo:before-cache", this.onBeforeCache);
|
|
37
|
+
this.#dismiss();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
disconnect() {
|
|
41
|
+
this.element.removeEventListener("close", this.onClose);
|
|
42
|
+
document.removeEventListener("turbo:before-cache", this.onBeforeCache);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
open() {
|
|
46
|
+
// showModal() throws if the dialog is already open, so reopening (the
|
|
47
|
+
// shared dialog grabbed for another field) goes through a close first.
|
|
48
|
+
// Caveat for future callers: the `close` event is a QUEUED task, so its
|
|
49
|
+
// reset would run after a caller's synchronous post-open() assignments
|
|
50
|
+
// (nulling a just-set onSave). Unreachable today — an open modal dialog
|
|
51
|
+
// makes the rest of the document inert, so nothing can re-enter open()
|
|
52
|
+
// while it is open — but do not rely on this path without fixing that.
|
|
53
|
+
this.#dismiss();
|
|
54
|
+
this.enableSave();
|
|
55
|
+
this.element.showModal();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Swap in a server-rendered form fragment (trusted engine HTML, like a Turbo
|
|
59
|
+
// frame load), copy its title and submit/cancel labels into the chrome, drop
|
|
60
|
+
// the in-form action bar (replaced by the footer) and any nested inline-add
|
|
61
|
+
// controls, then move focus to the first field — autofocus resolved back
|
|
62
|
+
// when showModal() ran, before this async content existed.
|
|
63
|
+
renderContent(html) {
|
|
64
|
+
// A response landing after the dialog was dismissed (e.g. Escape during a
|
|
65
|
+
// slow 422 submit) must not repopulate the reset body — the stale form
|
|
66
|
+
// would resurface, populated, on the next open().
|
|
67
|
+
if (!this.element.open) return;
|
|
68
|
+
this.bodyTarget.innerHTML = html;
|
|
69
|
+
this.bodyTarget.removeAttribute("aria-busy");
|
|
70
|
+
const form = this.bodyTarget.querySelector("form");
|
|
71
|
+
if (form) {
|
|
72
|
+
this.titleTarget.textContent = form.dataset.title || "";
|
|
73
|
+
const save = form.querySelector('[name="_save"]');
|
|
74
|
+
const cancel = form.querySelector('[name="_continue"]');
|
|
75
|
+
if (save) this.saveButtonTarget.innerHTML = save.innerHTML;
|
|
76
|
+
if (cancel) this.cancelButtonTarget.innerHTML = cancel.innerHTML;
|
|
77
|
+
form.querySelectorAll(".form-actions").forEach((node) => node.remove());
|
|
78
|
+
}
|
|
79
|
+
this.bodyTarget
|
|
80
|
+
.querySelectorAll(".modal-actions")
|
|
81
|
+
.forEach((node) => node.remove());
|
|
82
|
+
this.#focusFirst();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
save(event) {
|
|
86
|
+
event?.preventDefault();
|
|
87
|
+
if (this.saveButtonTarget.classList.contains("disabled")) return;
|
|
88
|
+
this.onSave?.();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
close(event) {
|
|
92
|
+
event?.preventDefault();
|
|
93
|
+
this.#dismiss();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The Save anchor has no native `disabled` — RemoteFormController toggles
|
|
97
|
+
// this around its fetch so a second click (or Enter) while a submit is in
|
|
98
|
+
// flight is a no-op instead of a second POST.
|
|
99
|
+
disableSave() {
|
|
100
|
+
this.saveButtonTarget.classList.add("disabled");
|
|
101
|
+
this.saveButtonTarget.setAttribute("aria-disabled", "true");
|
|
102
|
+
this.element.setAttribute("aria-busy", "true");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
enableSave() {
|
|
106
|
+
this.saveButtonTarget.classList.remove("disabled");
|
|
107
|
+
this.saveButtonTarget.removeAttribute("aria-disabled");
|
|
108
|
+
this.element.removeAttribute("aria-busy");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#dismiss() {
|
|
112
|
+
if (this.element.open) this.element.close();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Runs on the native `close` event, whatever triggered it. Nulling `onSave`
|
|
116
|
+
// is the stale-load guard's reset; the rest returns the chrome to the seeded
|
|
117
|
+
// loading state (busy empty body, loading title, interactive Save) so the
|
|
118
|
+
// next open() starts clean even after a mid-submit dismissal.
|
|
119
|
+
#reset() {
|
|
120
|
+
this.onSave = null;
|
|
121
|
+
this.enableSave();
|
|
122
|
+
this.bodyTarget.replaceChildren();
|
|
123
|
+
this.bodyTarget.setAttribute("aria-busy", "true");
|
|
124
|
+
this.titleTarget.textContent = this.loadingTitle;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
#focusable() {
|
|
128
|
+
return Array.from(this.bodyTarget.querySelectorAll(FOCUSABLE)).filter(
|
|
129
|
+
(el) => el.offsetParent !== null || el === document.activeElement
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// First errored field on a 422 re-render (its wrapper is marked
|
|
134
|
+
// .control-group.error server-side), first field otherwise. With nothing
|
|
135
|
+
// focusable in the body, the focus showModal() placed on the chrome stands.
|
|
136
|
+
#focusFirst() {
|
|
137
|
+
const fields = this.#focusable();
|
|
138
|
+
const target =
|
|
139
|
+
fields.find((el) => el.closest(".control-group.error")) || fields[0];
|
|
140
|
+
target?.focus();
|
|
141
|
+
}
|
|
142
|
+
}
|