plutonium 0.25.1 → 0.26.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.
@@ -91,12 +91,18 @@ module Plutonium
91
91
  # @param [Symbol] name The name of the nested resource field
92
92
  # @raise [ArgumentError] if the nested input definition is missing required configuration
93
93
  def render_nested_resource_field(name)
94
+ nested_input_definition = resource_definition.defined_nested_inputs[name]
95
+ condition = nested_input_definition[:options]&.fetch(:condition, nil)
96
+ if condition && !instance_exec(&condition)
97
+ return
98
+ end
99
+
94
100
  context = NestedFieldContext.new(
95
101
  name: name,
96
102
  definition: build_nested_fields_definition(name),
97
103
  resource_class: resource_class,
98
104
  resource_definition: resource_definition,
99
- object_class: resource_definition.defined_nested_inputs[name][:options]&.fetch(:object_class, nil)
105
+ object_class: nested_input_definition[:options]&.fetch(:object_class, nil)
100
106
  )
101
107
 
102
108
  render_nested_field_container(context) do
@@ -171,7 +177,7 @@ module Plutonium
171
177
  end
172
178
 
173
179
  def render_template_for_nested_fields(context, options, nesting_method:)
174
- template_tag data_nested_resource_form_fields_target: "template" do
180
+ template data_nested_resource_form_fields_target: "template" do
175
181
  send(nesting_method, context.name, as: context.nested_fields_input_param, **options, template: true) do |nested|
176
182
  render_nested_fields_fieldset(nested, context)
177
183
  end
@@ -32,6 +32,29 @@ module Plutonium
32
32
  transition-opacity duration-300 ease-in-out",
33
33
  data: {controller: "remote-modal"}
34
34
  ) do
35
+ # Close button
36
+ button(
37
+ type: "button",
38
+ class: "absolute top-4 right-4 p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors duration-200",
39
+ data: {action: "remote-modal#close"},
40
+ "aria-label": "Close dialog"
41
+ ) do
42
+ svg(
43
+ class: "w-5 h-5",
44
+ fill: "none",
45
+ stroke: "currentColor",
46
+ viewBox: "0 0 24 24",
47
+ xmlns: "http://www.w3.org/2000/svg"
48
+ ) do |s|
49
+ s.path(
50
+ stroke_linecap: "round",
51
+ stroke_linejoin: "round",
52
+ stroke_width: "2",
53
+ d: "M6 18L18 6M6 6l12 12"
54
+ )
55
+ end
56
+ end
57
+
35
58
  render_page_header
36
59
  render partial("interactive_action_form")
37
60
  end
@@ -60,7 +60,7 @@ module Plutonium
60
60
  column_options = column_definition[:options] || {}
61
61
 
62
62
  # Check for conditional rendering
63
- condition = column_options[:condition] || display_options[:condition] || field_options[:condition]
63
+ condition = column_options[:condition]
64
64
  conditionally_hidden = condition && !instance_exec(&condition)
65
65
  next if conditionally_hidden
66
66
 
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.25.1"
2
+ VERSION = "0.26.0"
3
3
  NEXT_MAJOR_VERSION = VERSION.split(".").tap { |v|
4
4
  v[1] = v[1].to_i + 1
5
5
  v[2] = 0
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radioactive-labs/plutonium",
3
- "version": "0.4.4",
3
+ "version": "0.4.8",
4
4
  "description": "Core assets for the Plutonium gem",
5
5
  "type": "module",
6
6
  "main": "src/js/core.js",
@@ -309,3 +309,40 @@
309
309
  .ss-main.ss-valid .ss-values .ss-placeholder {
310
310
  @apply text-green-700 dark:text-green-500;
311
311
  }
312
+
313
+ /* Modal-specific styles for SlimSelect dropdown */
314
+ .ss-dropdown-container {
315
+ position: absolute;
316
+ z-index: 9999;
317
+ inset: 40% 0px auto;
318
+ }
319
+
320
+ .ss-dropdown-container .ss-content {
321
+ position: static !important;
322
+ transform: none !important;
323
+ width: 100% !important;
324
+ border-radius: 0 !important;
325
+ margin: 0 !important;
326
+ pointer-events: none !important; /* Disabled by default */
327
+ }
328
+
329
+ /* When active (dropdown is expanded), enable pointer events */
330
+ .ss-dropdown-container.ss-active .ss-content {
331
+ pointer-events: auto !important;
332
+ }
333
+
334
+ .ss-dropdown-container .ss-list {
335
+ max-height: 250px !important;
336
+ overflow-y: auto !important;
337
+ }
338
+
339
+ /* Ensure the dropdown doesn't block other elements when closed */
340
+ .ss-dropdown-container:not(:has(.ss-content)),
341
+ .ss-dropdown-container:not(.ss-active) {
342
+ pointer-events: none !important;
343
+ }
344
+
345
+ /* Prevent interaction with closed dropdown */
346
+ .ss-dropdown-container:not(.ss-active) * {
347
+ pointer-events: none !important;
348
+ }
@@ -0,0 +1,109 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="key-value-store"
4
+ export default class extends Controller {
5
+ static targets = ["container", "pair", "template", "addButton", "keyInput", "valueInput"]
6
+ static values = { limit: Number }
7
+
8
+ connect() {
9
+ this.updateIndices()
10
+ this.updateAddButtonState()
11
+ }
12
+
13
+ addPair(event) {
14
+ event.preventDefault()
15
+
16
+ if (this.pairTargets.length >= this.limitValue) {
17
+ return
18
+ }
19
+
20
+ const template = this.templateTarget
21
+ const newPair = template.content.cloneNode(true)
22
+ const index = this.pairTargets.length
23
+
24
+ // Update the template placeholders with actual indices
25
+ this.updatePairIndices(newPair, index)
26
+
27
+ this.containerTarget.appendChild(newPair)
28
+ this.updateAddButtonState()
29
+
30
+ // Focus on the key input of the new pair
31
+ const newKeyInput = this.containerTarget.lastElementChild.querySelector('[data-key-value-store-target="keyInput"]')
32
+ if (newKeyInput) {
33
+ newKeyInput.focus()
34
+ }
35
+ }
36
+
37
+ removePair(event) {
38
+ event.preventDefault()
39
+
40
+ const pair = event.target.closest('[data-key-value-store-target="pair"]')
41
+ if (pair) {
42
+ pair.remove()
43
+ this.updateIndices()
44
+ this.updateAddButtonState()
45
+ }
46
+ }
47
+
48
+ updateIndices() {
49
+ this.pairTargets.forEach((pair, index) => {
50
+ const keyInput = pair.querySelector('[data-key-value-store-target="keyInput"]')
51
+ const valueInput = pair.querySelector('[data-key-value-store-target="valueInput"]')
52
+
53
+ if (keyInput) {
54
+ keyInput.name = keyInput.name.replace(/\[\d+\]/, `[${index}]`)
55
+ }
56
+ if (valueInput) {
57
+ valueInput.name = valueInput.name.replace(/\[\d+\]/, `[${index}]`)
58
+ }
59
+ })
60
+ }
61
+
62
+ updatePairIndices(element, index) {
63
+ const inputs = element.querySelectorAll('input')
64
+ inputs.forEach(input => {
65
+ if (input.name) {
66
+ input.name = input.name.replace('__INDEX__', index)
67
+ }
68
+ })
69
+ }
70
+
71
+ updateAddButtonState() {
72
+ const addButton = this.addButtonTarget
73
+ if (this.pairTargets.length >= this.limitValue) {
74
+ addButton.disabled = true
75
+ addButton.classList.add('opacity-50', 'cursor-not-allowed')
76
+ } else {
77
+ addButton.disabled = false
78
+ addButton.classList.remove('opacity-50', 'cursor-not-allowed')
79
+ }
80
+ }
81
+
82
+ // Serialize the current key-value pairs to JSON
83
+ toJSON() {
84
+ const pairs = {}
85
+ this.pairTargets.forEach(pair => {
86
+ const keyInput = pair.querySelector('[data-key-value-store-target="keyInput"]')
87
+ const valueInput = pair.querySelector('[data-key-value-store-target="valueInput"]')
88
+
89
+ if (keyInput && valueInput && keyInput.value.trim()) {
90
+ pairs[keyInput.value.trim()] = valueInput.value
91
+ }
92
+ })
93
+ return JSON.stringify(pairs)
94
+ }
95
+
96
+ // Get the current key-value pairs as an object
97
+ toObject() {
98
+ const pairs = {}
99
+ this.pairTargets.forEach(pair => {
100
+ const keyInput = pair.querySelector('[data-key-value-store-target="keyInput"]')
101
+ const valueInput = pair.querySelector('[data-key-value-store-target="valueInput"]')
102
+
103
+ if (keyInput && valueInput && keyInput.value.trim()) {
104
+ pairs[keyInput.value.trim()] = valueInput.value
105
+ }
106
+ })
107
+ return pairs
108
+ }
109
+ }
@@ -19,6 +19,7 @@ import AttachmentPreviewContainerController from "./attachment_preview_container
19
19
  import SidebarController from "./sidebar_controller.js"
20
20
  import PasswordVisibilityController from "./password_visibility_controller.js"
21
21
  import RemoteModalController from "./remote_modal_controller.js"
22
+ import KeyValueStoreController from "./key_value_st\ore_controller.js"
22
23
 
23
24
  export default function (application) {
24
25
  // Register controllers here
@@ -42,4 +43,5 @@ export default function (application) {
42
43
  application.register("attachment-preview", AttachmentPreviewController)
43
44
  application.register("attachment-preview-container", AttachmentPreviewContainerController)
44
45
  application.register("remote-modal", RemoteModalController)
46
+ application.register("key-value-store", KeyValueStoreController)
45
47
  }
@@ -2,23 +2,30 @@ import { Controller } from "@hotwired/stimulus";
2
2
 
3
3
  // Connects to data-controller="remote-modal"
4
4
  export default class extends Controller {
5
- connect() {
6
- // Store original scroll position
7
- this.originalScrollPosition = window.scrollY;
5
+ connect() {
6
+ // Store original scroll position
7
+ this.originalScrollPosition = window.scrollY;
8
8
 
9
- // Show the modal
10
- this.element.showModal();
11
- // Add close event listener
12
- this.element.addEventListener('close', this.handleClose.bind(this));
13
- }
9
+ // Show the modal
10
+ this.element.showModal();
11
+ // Add close event listener
12
+ this.element.addEventListener("close", this.handleClose.bind(this));
13
+ }
14
14
 
15
- disconnect() {
16
- // Clean up event listener when controller is disconnected
17
- this.element.removeEventListener('close', this.handleClose);
18
- }
15
+ close() {
16
+ // Close the modal
17
+ this.element.close();
18
+ // Restore the original scroll position
19
+ window.scrollTo(0, this.originalScrollPosition);
20
+ }
19
21
 
20
- handleClose() {
21
- // Restore the original scroll position after dialog closes
22
- window.scrollTo(0, this.originalScrollPosition);
23
- }
24
- }
22
+ disconnect() {
23
+ // Clean up event listener when controller is disconnected
24
+ this.element.removeEventListener("close", this.handleClose);
25
+ }
26
+
27
+ handleClose() {
28
+ // Restore the original scroll position after dialog closes
29
+ window.scrollTo(0, this.originalScrollPosition);
30
+ }
31
+ }
@@ -1,25 +1,218 @@
1
- import { Controller } from "@hotwired/stimulus"
1
+ import { Controller } from "@hotwired/stimulus";
2
2
 
3
3
  // Connects to data-controller="slim-select"
4
4
  export default class extends Controller {
5
5
  connect() {
6
+ const settings = {};
7
+ const modal = document.querySelector('[data-controller="remote-modal"]');
8
+
9
+ if (modal) {
10
+ // Create a dedicated container div right after the select element
11
+ this.dropdownContainer = document.createElement("div");
12
+ this.dropdownContainer.className = "ss-dropdown-container";
13
+
14
+ // Make the select wrapper position relative to contain the absolute dropdown
15
+ const selectWrapper = this.element.parentNode;
16
+ const originalPosition = getComputedStyle(selectWrapper).position;
17
+ if (originalPosition === "static") {
18
+ selectWrapper.style.position = "relative";
19
+ this.modifiedSelectWrapper = selectWrapper;
20
+ }
21
+
22
+ // Insert the container right after the select element
23
+ this.element.parentNode.insertBefore(
24
+ this.dropdownContainer,
25
+ this.element.nextSibling
26
+ );
27
+
28
+ settings.contentLocation = this.dropdownContainer;
29
+ settings.contentPosition = "absolute";
30
+ settings.openPosition = "auto";
31
+ }
32
+
6
33
  this.slimSelect = new SlimSelect({
7
- select: this.element
8
- })
9
- this.element.setAttribute("data-action", "turbo:morph-element->slim-select#reconnect")
34
+ select: this.element,
35
+ settings: settings,
36
+ });
37
+
38
+ // Add event listeners for better positioning
39
+ this.handleDropdownPosition();
40
+
41
+ // Bind event handlers for proper cleanup
42
+ this.boundHandleDropdownOpen = this.handleDropdownOpen.bind(this);
43
+ this.boundHandleDropdownClose = this.handleDropdownClose.bind(this);
44
+
45
+ // Add event listeners to properly handle dropdown visibility
46
+ this.element.addEventListener("ss:open", this.boundHandleDropdownOpen);
47
+ this.element.addEventListener("ss:close", this.boundHandleDropdownClose);
48
+
49
+ // Add mutation observer to track aria-expanded attribute
50
+ this.setupAriaObserver();
51
+
52
+ this.element.setAttribute(
53
+ "data-action",
54
+ "turbo:morph-element->slim-select#reconnect"
55
+ );
56
+ }
57
+
58
+ handleDropdownPosition() {
59
+ if (this.dropdownContainer) {
60
+ // Reposition dropdown when window resizes or scrolls
61
+ const repositionDropdown = () => {
62
+ const selectRect = this.element.getBoundingClientRect();
63
+
64
+ // Calculate if there's enough space below
65
+ const spaceBelow = window.innerHeight - selectRect.bottom;
66
+ const spaceAbove = selectRect.top;
67
+
68
+ if (spaceBelow < 200 && spaceAbove > spaceBelow) {
69
+ // Position above if not enough space below
70
+ this.dropdownContainer.style.top = "auto";
71
+ this.dropdownContainer.style.bottom = "100%";
72
+ this.dropdownContainer.style.borderRadius = "0.375rem 0.375rem 0 0";
73
+ } else {
74
+ // Position below (default)
75
+ this.dropdownContainer.style.bottom = "auto";
76
+ this.dropdownContainer.style.borderRadius = "0 0 0.375rem 0.375rem";
77
+ }
78
+ };
79
+
80
+ // Initial positioning
81
+ setTimeout(repositionDropdown, 0);
82
+
83
+ // Reposition on events
84
+ window.addEventListener("resize", repositionDropdown);
85
+ window.addEventListener("scroll", repositionDropdown);
86
+
87
+ // Store references for cleanup
88
+ this.repositionDropdown = repositionDropdown;
89
+ }
90
+ }
91
+
92
+ handleDropdownOpen() {
93
+ if (this.dropdownContainer) {
94
+ // When dropdown opens, ensure our container is properly sized
95
+ this.dropdownContainer.style.height = "auto";
96
+ this.dropdownContainer.style.overflow = "visible";
97
+
98
+ // Add open class for better CSS targeting
99
+ this.dropdownContainer.classList.add("ss-active");
100
+
101
+ // Ensure this dropdown appears above others
102
+ const allContainers = document.querySelectorAll(".ss-dropdown-container");
103
+ allContainers.forEach((container) => {
104
+ if (container !== this.dropdownContainer) {
105
+ container.style.zIndex = "9999";
106
+ }
107
+ });
108
+ this.dropdownContainer.style.zIndex = "10000";
109
+ }
110
+ }
111
+
112
+ handleDropdownClose() {
113
+ if (this.dropdownContainer) {
114
+ // Remove active class
115
+ this.dropdownContainer.classList.remove("ss-active");
116
+ }
117
+ }
118
+
119
+ setupAriaObserver() {
120
+ // Track aria-expanded attribute on the select element or its wrapper
121
+ if (this.element) {
122
+ this.ariaObserver = new MutationObserver((mutations) => {
123
+ mutations.forEach((mutation) => {
124
+ if (mutation.attributeName === "aria-expanded") {
125
+ const expanded =
126
+ mutation.target.getAttribute("aria-expanded") === "true";
127
+ if (expanded) {
128
+ this.handleDropdownOpen();
129
+ } else {
130
+ this.handleDropdownClose();
131
+ }
132
+ }
133
+ });
134
+ });
135
+
136
+ // Look for the actual element that gets the aria-expanded attribute
137
+ const possibleTargets = [
138
+ this.element,
139
+ this.element.parentNode.querySelector(".ss-main"),
140
+ this.element.parentNode.querySelector("[aria-expanded]"),
141
+ ];
142
+
143
+ const target = possibleTargets.find(
144
+ (el) => el && el.hasAttribute && el.hasAttribute("aria-expanded")
145
+ );
146
+
147
+ if (target) {
148
+ this.ariaObserver.observe(target, {
149
+ attributes: true,
150
+ attributeFilter: ["aria-expanded"],
151
+ });
152
+
153
+ // Check initial state
154
+ const expanded = target.getAttribute("aria-expanded") === "true";
155
+ if (expanded) {
156
+ this.handleDropdownOpen();
157
+ } else {
158
+ this.handleDropdownClose();
159
+ }
160
+ }
161
+ }
10
162
  }
11
163
 
12
164
  disconnect() {
165
+ // Clean up event listeners
166
+ if (this.element) {
167
+ if (this.boundHandleDropdownOpen) {
168
+ this.element.removeEventListener(
169
+ "ss:open",
170
+ this.boundHandleDropdownOpen
171
+ );
172
+ }
173
+ if (this.boundHandleDropdownClose) {
174
+ this.element.removeEventListener(
175
+ "ss:close",
176
+ this.boundHandleDropdownClose
177
+ );
178
+ }
179
+ }
180
+
181
+ // Disconnect observer
182
+ if (this.ariaObserver) {
183
+ this.ariaObserver.disconnect();
184
+ this.ariaObserver = null;
185
+ }
186
+
13
187
  if (this.slimSelect) {
14
- this.slimSelect.destroy()
15
- this.slimSelect = null
188
+ this.slimSelect.destroy();
189
+ this.slimSelect = null;
190
+ }
191
+
192
+ // Clean up event listeners
193
+ if (this.repositionDropdown) {
194
+ window.removeEventListener("resize", this.repositionDropdown);
195
+ window.removeEventListener("scroll", this.repositionDropdown);
196
+ this.repositionDropdown = null;
197
+ }
198
+
199
+ // Clean up the dropdown container if it exists
200
+ if (this.dropdownContainer && this.dropdownContainer.parentNode) {
201
+ this.dropdownContainer.parentNode.removeChild(this.dropdownContainer);
202
+ this.dropdownContainer = null;
203
+ }
204
+
205
+ // Restore original positioning if we modified it
206
+ if (this.modifiedSelectWrapper) {
207
+ this.modifiedSelectWrapper.style.position = "";
208
+ this.modifiedSelectWrapper = null;
16
209
  }
17
210
  }
18
211
 
19
212
  reconnect() {
20
- this.disconnect()
213
+ this.disconnect();
21
214
  // dispatch this on the next frame.
22
215
  // there's some funny issue where my elements get removed from the DOM
23
- setTimeout(() => this.connect(), 10)
216
+ setTimeout(() => this.connect(), 10);
24
217
  }
25
218
  }
data/src/js/core.js CHANGED
@@ -1,3 +1,4 @@
1
1
  import registerControllers from "./controllers/register_controllers.js"
2
-
3
2
  export { registerControllers }
3
+
4
+ import "./turbo"
data/src/js/plutonium.js CHANGED
@@ -1,7 +1,7 @@
1
+ import "@hotwired/turbo"
2
+
1
3
  import { Application } from "@hotwired/stimulus"
2
4
  const application = Application.start()
3
5
 
4
6
  import { registerControllers } from "./core"
5
7
  registerControllers(application)
6
-
7
- import "./turbo"