@radioactive-labs/plutonium 0.34.1 → 0.35.1

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.
@@ -0,0 +1,109 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="bulk-actions"
4
+ // Manages bulk action selection in resource tables
5
+ export default class extends Controller {
6
+ static targets = ["checkbox", "checkboxAll", "toolbar", "selectedCount", "actionButton", "selectionCell"]
7
+ static values = {
8
+ hasActions: { type: Boolean, default: false }
9
+ }
10
+
11
+ connect() {
12
+ // Show selection column only if bulk actions exist
13
+ if (this.hasActionsValue) {
14
+ this.enableSelection()
15
+ }
16
+ }
17
+
18
+ enableSelection() {
19
+ // Show all selection cells (header + body cells)
20
+ this.selectionCellTargets.forEach(el => el.classList.remove("hidden"))
21
+ }
22
+
23
+ toggle() {
24
+ this.updateUI()
25
+ }
26
+
27
+ toggleAll(event) {
28
+ const checked = event.target.checked
29
+ this.checkboxTargets.forEach(cb => cb.checked = checked)
30
+ this.updateUI()
31
+ }
32
+
33
+ updateUI() {
34
+ const checked = this.checked
35
+ const total = this.checkboxTargets.length
36
+
37
+ // Update "select all" checkbox state (checked, unchecked, or indeterminate)
38
+ if (this.hasCheckboxAllTarget) {
39
+ this.checkboxAllTarget.checked = checked.length === total && total > 0
40
+ this.checkboxAllTarget.indeterminate = checked.length > 0 && checked.length < total
41
+ }
42
+
43
+ // Show toolbar only when items are selected
44
+ if (this.hasToolbarTarget) {
45
+ this.toolbarTarget.classList.toggle("hidden", checked.length === 0)
46
+ }
47
+
48
+ // Update selected count display
49
+ if (this.hasSelectedCountTarget) {
50
+ this.selectedCountTarget.textContent = checked.length
51
+ }
52
+
53
+ // Update action button URLs and visibility based on allowed actions
54
+ this.updateActionButtons()
55
+ }
56
+
57
+ updateActionButtons() {
58
+ const checked = this.checked
59
+ const ids = checked.map(cb => cb.value)
60
+ const idsParam = ids.map(id => `ids[]=${encodeURIComponent(id)}`).join("&")
61
+
62
+ // Compute intersection of allowed actions across all selected records
63
+ const allowedActions = this.computeAllowedActions(checked)
64
+
65
+ this.actionButtonTargets.forEach(button => {
66
+ const baseUrl = button.dataset.bulkActionUrl
67
+ const actionName = button.dataset.bulkActionName
68
+
69
+ // Update URL with selected IDs
70
+ if (baseUrl) {
71
+ button.href = idsParam ? `${baseUrl}?${idsParam}` : baseUrl
72
+ }
73
+
74
+ // Show/hide button based on whether action is allowed for all selected records
75
+ button.style.display = allowedActions.has(actionName) ? '' : 'none'
76
+ })
77
+ }
78
+
79
+ // Compute the intersection of allowed actions across all selected checkboxes
80
+ computeAllowedActions(checked) {
81
+ if (checked.length === 0) {
82
+ return new Set()
83
+ }
84
+
85
+ // Start with actions allowed for the first selected record
86
+ let intersection = new Set(this.getAllowedActionsForCheckbox(checked[0]))
87
+
88
+ // Intersect with actions allowed for each subsequent record
89
+ for (let i = 1; i < checked.length; i++) {
90
+ const actions = this.getAllowedActionsForCheckbox(checked[i])
91
+ intersection = new Set([...intersection].filter(a => actions.includes(a)))
92
+ }
93
+
94
+ return intersection
95
+ }
96
+
97
+ getAllowedActionsForCheckbox(checkbox) {
98
+ const allowedActions = checkbox.dataset.allowedActions
99
+ return allowedActions ? allowedActions.split(",").filter(a => a) : []
100
+ }
101
+
102
+ get checked() {
103
+ return this.checkboxTargets.filter(cb => cb.checked)
104
+ }
105
+
106
+ get unchecked() {
107
+ return this.checkboxTargets.filter(cb => !cb.checked)
108
+ }
109
+ }
@@ -0,0 +1,35 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="filter-panel"
4
+ export default class extends Controller {
5
+ clear() {
6
+ this.element.querySelectorAll('input, select, textarea').forEach(input => {
7
+ if (input.type === 'checkbox' || input.type === 'radio') {
8
+ input.checked = false
9
+ } else if (input.tagName === 'SELECT') {
10
+ input.selectedIndex = 0
11
+ } else if (input.type === 'hidden') {
12
+ // Clear hidden inputs that are filter values (e.g., flatpickr)
13
+ if (input.dataset.controller === 'flatpickr') {
14
+ input.value = ''
15
+ }
16
+ } else {
17
+ input.value = ''
18
+ }
19
+ })
20
+
21
+ // Clear flatpickr instances via Stimulus controller
22
+ this.element.querySelectorAll('[data-controller="flatpickr"]').forEach(input => {
23
+ const controller = this.application.getControllerForElementAndIdentifier(input, 'flatpickr')
24
+ if (controller?.picker) {
25
+ controller.picker.clear()
26
+ }
27
+ })
28
+
29
+ // Submit the parent form
30
+ const form = this.element.closest('form')
31
+ if (form) {
32
+ form.requestSubmit()
33
+ }
34
+ }
35
+ }
@@ -19,7 +19,9 @@ 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
+ import KeyValueStoreController from "./key_value_store_controller.js"
23
+ import BulkActionsController from "./bulk_actions_controller.js"
24
+ import FilterPanelController from "./filter_panel_controller.js"
23
25
 
24
26
  export default function (application) {
25
27
  // Register controllers here
@@ -44,4 +46,6 @@ export default function (application) {
44
46
  application.register("attachment-preview-container", AttachmentPreviewContainerController)
45
47
  application.register("remote-modal", RemoteModalController)
46
48
  application.register("key-value-store", KeyValueStoreController)
49
+ application.register("bulk-actions", BulkActionsController)
50
+ application.register("filter-panel", FilterPanelController)
47
51
  }
@@ -4,6 +4,9 @@ import { createPopper } from '@popperjs/core'
4
4
  // Connects to data-controller="resource-drop-down"
5
5
  export default class extends Controller {
6
6
  static targets = ["trigger", "menu"]
7
+ static values = {
8
+ placement: { type: String, default: 'bottom' }
9
+ }
7
10
 
8
11
  connect() {
9
12
  this.visible = false
@@ -11,7 +14,7 @@ export default class extends Controller {
11
14
 
12
15
  // Default options matching Flowbite's defaults
13
16
  this.options = {
14
- placement: 'bottom',
17
+ placement: this.placementValue,
15
18
  triggerType: 'click',
16
19
  offsetSkidding: 0,
17
20
  offsetDistance: 10,
@@ -25,7 +28,9 @@ export default class extends Controller {
25
28
  init() {
26
29
  if (this.triggerTarget && this.menuTarget && !this.initialized) {
27
30
  // Initialize popper instance
31
+ // Use 'fixed' strategy to escape overflow containers (e.g., table rows)
28
32
  this.popperInstance = createPopper(this.triggerTarget, this.menuTarget, {
33
+ strategy: 'fixed',
29
34
  placement: this.options.placement,
30
35
  modifiers: [
31
36
  {
@@ -34,6 +39,21 @@ export default class extends Controller {
34
39
  offset: [this.options.offsetSkidding, this.options.offsetDistance],
35
40
  },
36
41
  },
42
+ {
43
+ name: 'flip',
44
+ options: {
45
+ fallbackPlacements: ['left-end', 'right-start', 'right-end', 'bottom-start', 'bottom-end', 'top-start', 'top-end'],
46
+ boundary: 'clippingParents',
47
+ },
48
+ },
49
+ {
50
+ name: 'preventOverflow',
51
+ options: {
52
+ boundary: 'clippingParents',
53
+ altAxis: true,
54
+ padding: 8,
55
+ },
56
+ },
37
57
  ],
38
58
  })
39
59
 
@@ -116,11 +136,15 @@ export default class extends Controller {
116
136
  })
117
137
  }
118
138
 
139
+ // Ignore clicks on flatpickr calendars and other floating UI elements
140
+ const isFloatingUI = clickedEl.closest('.flatpickr-calendar, .ss-main, .ss-content')
141
+
119
142
  if (
120
143
  clickedEl !== this.menuTarget &&
121
144
  !this.menuTarget.contains(clickedEl) &&
122
145
  !this.triggerTarget.contains(clickedEl) &&
123
146
  !isIgnored &&
147
+ !isFloatingUI &&
124
148
  this.visible
125
149
  ) {
126
150
  this.hide()
@@ -9,8 +9,12 @@ export default class extends Controller {
9
9
 
10
10
  // Just recreate SlimSelect after morphing - the DOM will have correct selections
11
11
  this.element.addEventListener("turbo:morph-element", (event) => {
12
- if (event.target === this.element) {
13
- requestAnimationFrame(() => this.#handleMorph());
12
+ if (event.target === this.element && !this.morphing) {
13
+ this.morphing = true;
14
+ requestAnimationFrame(() => {
15
+ this.#handleMorph();
16
+ this.morphing = false;
17
+ });
14
18
  }
15
19
  });
16
20
  }
@@ -1,7 +1,7 @@
1
1
  // Add a redirect stream action
2
2
  Turbo.StreamActions.redirect = function () {
3
3
  // See: https://github.com/hotwired/turbo/issues/554
4
- Turbo.clearCache();
4
+ Turbo.cache.clear();
5
5
 
6
6
  const url = this.getAttribute("url")
7
7
  Turbo.visit(url)