@radioactive-labs/plutonium 0.49.0 → 0.50.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.
- package/package.json +1 -1
- package/src/css/components.css +304 -131
- package/src/css/tokens.css +101 -85
- package/src/dist/css/plutonium.css +2 -2
- package/src/dist/js/plutonium.js +404 -25
- package/src/dist/js/plutonium.js.map +4 -4
- package/src/dist/js/plutonium.min.js +45 -45
- package/src/dist/js/plutonium.min.js.map +4 -4
- package/src/js/controllers/autosubmit_controller.js +24 -0
- package/src/js/controllers/bulk_actions_controller.js +15 -16
- package/src/js/controllers/capture_url_controller.js +14 -0
- package/src/js/controllers/filter_panel_controller.js +77 -19
- package/src/js/controllers/flatpickr_controller.js +23 -0
- package/src/js/controllers/frame_navigator_controller.js +34 -6
- package/src/js/controllers/icon_rail_controller.js +22 -0
- package/src/js/controllers/icon_rail_flyout_controller.js +128 -0
- package/src/js/controllers/register_controllers.js +16 -0
- package/src/js/controllers/resource_tab_list_controller.js +56 -3
- package/src/js/controllers/row_click_controller.js +21 -0
- package/src/js/controllers/sidebar_controller.js +28 -1
- package/src/js/controllers/table_column_menu_controller.js +43 -0
- package/src/js/controllers/table_header_controller.js +16 -0
- package/src/js/controllers/view_switcher_controller.js +29 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Connects to data-controller="autosubmit"
|
|
4
|
+
// Submits the closest <form> after the user stops typing for `delay` ms
|
|
5
|
+
// (default 300). Use on inputs where the user expects "as you type"
|
|
6
|
+
// behavior (e.g. the toolbar search input).
|
|
7
|
+
export default class extends Controller {
|
|
8
|
+
static values = { delay: { type: Number, default: 300 } }
|
|
9
|
+
|
|
10
|
+
connect() {
|
|
11
|
+
this._timer = null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
disconnect() {
|
|
15
|
+
if (this._timer) clearTimeout(this._timer)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
submit() {
|
|
19
|
+
if (this._timer) clearTimeout(this._timer)
|
|
20
|
+
this._timer = setTimeout(() => {
|
|
21
|
+
this.element.closest("form")?.requestSubmit()
|
|
22
|
+
}, this.delayValue)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -3,22 +3,7 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
3
3
|
// Connects to data-controller="bulk-actions"
|
|
4
4
|
// Manages bulk action selection in resource tables
|
|
5
5
|
export default class extends Controller {
|
|
6
|
-
static targets = ["checkbox", "checkboxAll", "toolbar", "selectedCount", "actionButton", "
|
|
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
|
-
}
|
|
6
|
+
static targets = ["checkbox", "checkboxAll", "toolbar", "selectedCount", "actionButton", "filterPills"]
|
|
22
7
|
|
|
23
8
|
toggle() {
|
|
24
9
|
this.updateUI()
|
|
@@ -45,6 +30,11 @@ export default class extends Controller {
|
|
|
45
30
|
this.toolbarTarget.classList.toggle("hidden", checked.length === 0)
|
|
46
31
|
}
|
|
47
32
|
|
|
33
|
+
// FilterPills strip is mutually exclusive with the toolbar
|
|
34
|
+
if (this.hasFilterPillsTarget) {
|
|
35
|
+
this.filterPillsTarget.classList.toggle("hidden", checked.length > 0)
|
|
36
|
+
}
|
|
37
|
+
|
|
48
38
|
// Update selected count display
|
|
49
39
|
if (this.hasSelectedCountTarget) {
|
|
50
40
|
this.selectedCountTarget.textContent = checked.length
|
|
@@ -99,6 +89,15 @@ export default class extends Controller {
|
|
|
99
89
|
return allowedActions ? allowedActions.split(",").filter(a => a) : []
|
|
100
90
|
}
|
|
101
91
|
|
|
92
|
+
clearSelection() {
|
|
93
|
+
this.checkboxTargets.forEach(cb => cb.checked = false)
|
|
94
|
+
if (this.hasCheckboxAllTarget) {
|
|
95
|
+
this.checkboxAllTarget.checked = false
|
|
96
|
+
this.checkboxAllTarget.indeterminate = false
|
|
97
|
+
}
|
|
98
|
+
this.updateUI()
|
|
99
|
+
}
|
|
100
|
+
|
|
102
101
|
get checked() {
|
|
103
102
|
return this.checkboxTargets.filter(cb => cb.checked)
|
|
104
103
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Connects to data-controller="capture-url"
|
|
4
|
+
// Sets the controller's own element's `value` to window.location.href
|
|
5
|
+
// on connect — capturing URL fragments (#tab-id) that the server never
|
|
6
|
+
// sees over HTTP. Apply directly to any input/button whose value should
|
|
7
|
+
// reflect the full client-side URL.
|
|
8
|
+
export default class extends Controller {
|
|
9
|
+
connect() {
|
|
10
|
+
if ("value" in this.element) {
|
|
11
|
+
this.element.value = window.location.href
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -1,35 +1,93 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
|
|
3
3
|
// Connects to data-controller="filter-panel"
|
|
4
|
+
//
|
|
5
|
+
// Hosts the toolbar's filter slideover. The trigger lives in the toolbar
|
|
6
|
+
// strip; the panel + backdrop are rendered as siblings inside the same
|
|
7
|
+
// controller scope. `open` is mirrored on both targets via the `data-open`
|
|
8
|
+
// attribute, which CSS uses to drive the slide / fade transitions.
|
|
4
9
|
export default class extends Controller {
|
|
10
|
+
static targets = ["panel", "backdrop"]
|
|
11
|
+
|
|
12
|
+
connect() {
|
|
13
|
+
this._onKeydown = this._onKeydown.bind(this)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
disconnect() {
|
|
17
|
+
if (this.isOpen) {
|
|
18
|
+
document.removeEventListener("keydown", this._onKeydown)
|
|
19
|
+
this._unlockBodyScroll()
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
toggle() {
|
|
24
|
+
this.isOpen ? this.close() : this.open()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
open() {
|
|
28
|
+
if (this.hasPanelTarget) {
|
|
29
|
+
this.panelTarget.setAttribute("data-open", "")
|
|
30
|
+
this.panelTarget.setAttribute("aria-hidden", "false")
|
|
31
|
+
}
|
|
32
|
+
if (this.hasBackdropTarget) this.backdropTarget.setAttribute("data-open", "")
|
|
33
|
+
this._lockBodyScroll()
|
|
34
|
+
document.addEventListener("keydown", this._onKeydown)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
close() {
|
|
38
|
+
if (this.hasPanelTarget) {
|
|
39
|
+
this.panelTarget.removeAttribute("data-open")
|
|
40
|
+
this.panelTarget.setAttribute("aria-hidden", "true")
|
|
41
|
+
}
|
|
42
|
+
if (this.hasBackdropTarget) this.backdropTarget.removeAttribute("data-open")
|
|
43
|
+
this._unlockBodyScroll()
|
|
44
|
+
document.removeEventListener("keydown", this._onKeydown)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Mirrors remote-modal's approach: stash the body's current overflow
|
|
48
|
+
// and restore it on close. Avoids stomping a value another component
|
|
49
|
+
// (e.g. an open dialog) may have set.
|
|
50
|
+
_lockBodyScroll() {
|
|
51
|
+
if (this._previousBodyOverflow != null) return
|
|
52
|
+
this._previousBodyOverflow = document.body.style.overflow
|
|
53
|
+
document.body.style.overflow = "hidden"
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
_unlockBodyScroll() {
|
|
57
|
+
if (this._previousBodyOverflow == null) return
|
|
58
|
+
document.body.style.overflow = this._previousBodyOverflow
|
|
59
|
+
this._previousBodyOverflow = null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Reset every input under this controller's scope, then submit so the
|
|
63
|
+
// table reflects the cleared filters immediately.
|
|
5
64
|
clear() {
|
|
6
|
-
this.element.querySelectorAll(
|
|
7
|
-
if (input.type ===
|
|
65
|
+
this.element.querySelectorAll("input, select, textarea").forEach(input => {
|
|
66
|
+
if (input.type === "checkbox" || input.type === "radio") {
|
|
8
67
|
input.checked = false
|
|
9
|
-
} else if (input.tagName ===
|
|
68
|
+
} else if (input.tagName === "SELECT") {
|
|
10
69
|
input.selectedIndex = 0
|
|
11
|
-
} else if (input.type ===
|
|
12
|
-
|
|
13
|
-
if (input.dataset.controller === 'flatpickr') {
|
|
14
|
-
input.value = ''
|
|
15
|
-
}
|
|
70
|
+
} else if (input.type === "hidden") {
|
|
71
|
+
if (input.dataset.controller === "flatpickr") input.value = ""
|
|
16
72
|
} else {
|
|
17
|
-
input.value =
|
|
73
|
+
input.value = ""
|
|
18
74
|
}
|
|
19
75
|
})
|
|
20
76
|
|
|
21
|
-
// Clear flatpickr instances via Stimulus controller
|
|
22
77
|
this.element.querySelectorAll('[data-controller="flatpickr"]').forEach(input => {
|
|
23
|
-
const controller = this.application.getControllerForElementAndIdentifier(input,
|
|
24
|
-
if (controller?.picker)
|
|
25
|
-
controller.picker.clear()
|
|
26
|
-
}
|
|
78
|
+
const controller = this.application.getControllerForElementAndIdentifier(input, "flatpickr")
|
|
79
|
+
if (controller?.picker) controller.picker.clear()
|
|
27
80
|
})
|
|
28
81
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
82
|
+
const form = this.element.querySelector("form")
|
|
83
|
+
if (form) form.requestSubmit()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get isOpen() {
|
|
87
|
+
return this.hasPanelTarget && this.panelTarget.hasAttribute("data-open")
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
_onKeydown(event) {
|
|
91
|
+
if (event.key === "Escape") this.close()
|
|
34
92
|
}
|
|
35
93
|
}
|
|
@@ -54,7 +54,30 @@ export default class extends Controller {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
if (this.modal) {
|
|
57
|
+
// Inside a <dialog> opened via showModal(), the dialog establishes its
|
|
58
|
+
// own containing block in the top layer. flatpickr's default positioning
|
|
59
|
+
// computes document coordinates but the calendar (appended to the
|
|
60
|
+
// dialog) interprets them relative to the dialog's box, placing the
|
|
61
|
+
// calendar far from the input. Append to the modal and reposition
|
|
62
|
+
// manually relative to the modal's bounding rect.
|
|
57
63
|
options.appendTo = this.modal;
|
|
64
|
+
options.position = (instance) => {
|
|
65
|
+
const input = instance.altInput || instance.input;
|
|
66
|
+
const inputRect = input.getBoundingClientRect();
|
|
67
|
+
const modalRect = this.modal.getBoundingClientRect();
|
|
68
|
+
const cal = instance.calendarContainer;
|
|
69
|
+
const calHeight = cal.offsetHeight;
|
|
70
|
+
const spaceBelow = window.innerHeight - inputRect.bottom;
|
|
71
|
+
const showAbove = spaceBelow < calHeight && inputRect.top > calHeight;
|
|
72
|
+
const top = showAbove
|
|
73
|
+
? inputRect.top - modalRect.top - calHeight - 2
|
|
74
|
+
: inputRect.bottom - modalRect.top + 2;
|
|
75
|
+
cal.style.top = `${top}px`;
|
|
76
|
+
cal.style.left = `${inputRect.left - modalRect.left}px`;
|
|
77
|
+
cal.style.right = "auto";
|
|
78
|
+
cal.classList.toggle("arrowTop", !showAbove);
|
|
79
|
+
cal.classList.toggle("arrowBottom", showAbove);
|
|
80
|
+
};
|
|
58
81
|
}
|
|
59
82
|
|
|
60
83
|
return options;
|
|
@@ -49,6 +49,16 @@ export default class extends Controller {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
frameLoading(event) {
|
|
52
|
+
// turbo:click / turbo:submit-start bubble from links and forms inside
|
|
53
|
+
// the frame, even when those links target a different frame
|
|
54
|
+
// (e.g. data-turbo-frame="remote_modal"). Without this filter, the
|
|
55
|
+
// pulse animation is triggered for navigations that never resolve
|
|
56
|
+
// against this frame, leaving it stuck in a loading state.
|
|
57
|
+
if (event) {
|
|
58
|
+
const trigger = event.target.closest("a, form")
|
|
59
|
+
const requested = trigger?.dataset?.turboFrame
|
|
60
|
+
if (requested && requested !== this.frameTarget.id) return
|
|
61
|
+
}
|
|
52
62
|
this.#loadingStarted()
|
|
53
63
|
}
|
|
54
64
|
|
|
@@ -79,20 +89,38 @@ export default class extends Controller {
|
|
|
79
89
|
homeButtonClicked(event) {
|
|
80
90
|
this.frameLoading(null)
|
|
81
91
|
|
|
92
|
+
// Clear history immediately so Back/Home vanish during the load.
|
|
93
|
+
this.srcHistory = [this.originalFrameSrc]
|
|
94
|
+
this.#updateNavigationButtonsDisplay()
|
|
95
|
+
|
|
96
|
+
// Mark the next frame load as "home" so notifySrcChanged doesn't
|
|
97
|
+
// push the loaded URL onto a fresh stack (the loaded URL may differ
|
|
98
|
+
// from originalFrameSrc due to redirects / trailing slashes).
|
|
99
|
+
this._homeRequested = true
|
|
100
|
+
|
|
101
|
+
// Force a reload even if frame.src already matches the original
|
|
102
|
+
// (a same-value assignment wouldn't fire turbo:frame-load).
|
|
82
103
|
this.frameTarget.src = this.originalFrameSrc
|
|
104
|
+
this.frameTarget.reload()
|
|
83
105
|
}
|
|
84
106
|
|
|
85
107
|
get currentSrc() { return this.srcHistory[this.srcHistory.length - 1] }
|
|
86
108
|
|
|
87
109
|
#notifySrcChanged(src) {
|
|
88
|
-
if (
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
110
|
+
if (this._homeRequested) {
|
|
111
|
+
// Home click: capture the actually-loaded URL as the new singleton
|
|
112
|
+
// history root (handles redirect/trailing-slash differences from
|
|
113
|
+
// originalFrameSrc).
|
|
114
|
+
this._homeRequested = false
|
|
93
115
|
this.srcHistory = [src]
|
|
94
|
-
|
|
116
|
+
this.originalFrameSrc = src
|
|
117
|
+
} else if (src == this.currentSrc) {
|
|
118
|
+
// refresh — do nothing
|
|
119
|
+
} else if (src == this.originalFrameSrc) {
|
|
120
|
+
this.srcHistory = [src]
|
|
121
|
+
} else {
|
|
95
122
|
this.srcHistory.push(src)
|
|
123
|
+
}
|
|
96
124
|
|
|
97
125
|
this.#updateNavigationButtonsDisplay()
|
|
98
126
|
if (this.hasMaximizeLinkTarget) this.maximizeLinkTarget.href = src
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Connects to data-controller="icon-rail"
|
|
4
|
+
// Manages the collapsed ↔ pinned-expanded state of the IconRail sidebar.
|
|
5
|
+
// Pinned state is persisted in localStorage so it survives page reloads.
|
|
6
|
+
export default class extends Controller {
|
|
7
|
+
static values = {
|
|
8
|
+
storageKey: { type: String, default: "pu_rail_pinned" }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
connect() {
|
|
12
|
+
const pinned = localStorage.getItem(this.storageKeyValue) === "true"
|
|
13
|
+
if (pinned) {
|
|
14
|
+
document.body.classList.add("pu-rail-pinned")
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
togglePin() {
|
|
19
|
+
const pinned = document.body.classList.toggle("pu-rail-pinned")
|
|
20
|
+
localStorage.setItem(this.storageKeyValue, pinned)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Connects to data-controller="icon-rail-flyout"
|
|
4
|
+
// Manages a flyout panel anchored to a trigger element.
|
|
5
|
+
// - Hover or focus the wrapper → open
|
|
6
|
+
// - Mouse leave (with delay) or blur → close
|
|
7
|
+
// - Esc → close immediately
|
|
8
|
+
// - Click trigger → toggle (touch-friendly)
|
|
9
|
+
//
|
|
10
|
+
// On open, the panel is portaled to <body> so it escapes any ancestor
|
|
11
|
+
// transform / overflow:hidden (the rail aside has both). On close,
|
|
12
|
+
// the panel returns to its original parent.
|
|
13
|
+
//
|
|
14
|
+
// IMPORTANT: once portaled, the panel is OUTSIDE the controller's
|
|
15
|
+
// element scope, so this.panelTarget stops resolving. We capture the
|
|
16
|
+
// node into _panel before moving it.
|
|
17
|
+
export default class extends Controller {
|
|
18
|
+
static targets = ["trigger", "panel"]
|
|
19
|
+
static values = {
|
|
20
|
+
closeDelay: { type: Number, default: 150 }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
connect() {
|
|
24
|
+
this._closeTimer = null
|
|
25
|
+
this._open = false
|
|
26
|
+
this._panel = null
|
|
27
|
+
this._panelHome = null
|
|
28
|
+
this._onPanelEnter = () => {
|
|
29
|
+
if (this._closeTimer) {
|
|
30
|
+
clearTimeout(this._closeTimer)
|
|
31
|
+
this._closeTimer = null
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
this._onPanelLeave = () => this.scheduleClose()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
disconnect() {
|
|
38
|
+
this._returnPanel()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
open() {
|
|
42
|
+
if (this._closeTimer) {
|
|
43
|
+
clearTimeout(this._closeTimer)
|
|
44
|
+
this._closeTimer = null
|
|
45
|
+
}
|
|
46
|
+
if (this._open) return
|
|
47
|
+
if (!this._panel && !this.hasPanelTarget) return
|
|
48
|
+
this._open = true
|
|
49
|
+
this.element.dataset.flyoutOpen = "true"
|
|
50
|
+
this._portalPanel()
|
|
51
|
+
this._position()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
scheduleClose() {
|
|
55
|
+
if (this._closeTimer) clearTimeout(this._closeTimer)
|
|
56
|
+
this._closeTimer = setTimeout(() => this.close(), this.closeDelayValue)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
close() {
|
|
60
|
+
if (!this._open) return
|
|
61
|
+
this._open = false
|
|
62
|
+
delete this.element.dataset.flyoutOpen
|
|
63
|
+
this._returnPanel()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
toggle(event) {
|
|
67
|
+
event.preventDefault()
|
|
68
|
+
this._open ? this.close() : this.open()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
closeOnEsc(event) {
|
|
72
|
+
if (event.key === "Escape") this.close()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
_portalPanel() {
|
|
76
|
+
if (this._panel) return
|
|
77
|
+
// Capture the panel BEFORE moving it — once it leaves the
|
|
78
|
+
// controller element, this.panelTarget no longer resolves.
|
|
79
|
+
const panel = this.panelTarget
|
|
80
|
+
if (!panel) return
|
|
81
|
+
this._panel = panel
|
|
82
|
+
this._panelHome = panel.parentElement
|
|
83
|
+
panel.addEventListener("mouseenter", this._onPanelEnter)
|
|
84
|
+
panel.addEventListener("mouseleave", this._onPanelLeave)
|
|
85
|
+
document.body.appendChild(panel)
|
|
86
|
+
panel.style.display = "block"
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
_returnPanel() {
|
|
90
|
+
if (!this._panel) return
|
|
91
|
+
const panel = this._panel
|
|
92
|
+
panel.removeEventListener("mouseenter", this._onPanelEnter)
|
|
93
|
+
panel.removeEventListener("mouseleave", this._onPanelLeave)
|
|
94
|
+
panel.style.position = ""
|
|
95
|
+
panel.style.left = ""
|
|
96
|
+
panel.style.top = ""
|
|
97
|
+
panel.style.display = ""
|
|
98
|
+
// If the original parent has been morphed away, the panel would
|
|
99
|
+
// orphan in <body>. Drop it instead of re-attaching to a detached
|
|
100
|
+
// home node.
|
|
101
|
+
if (this._panelHome && document.contains(this._panelHome)) {
|
|
102
|
+
this._panelHome.appendChild(panel)
|
|
103
|
+
} else {
|
|
104
|
+
panel.remove()
|
|
105
|
+
}
|
|
106
|
+
this._panel = null
|
|
107
|
+
this._panelHome = null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
_position() {
|
|
111
|
+
if (!this._panel || !this.hasTriggerTarget) return
|
|
112
|
+
const panel = this._panel
|
|
113
|
+
const triggerRect = this.triggerTarget.getBoundingClientRect()
|
|
114
|
+
panel.style.position = "fixed"
|
|
115
|
+
panel.style.left = `${triggerRect.right + 4}px`
|
|
116
|
+
panel.style.top = `${triggerRect.top}px`
|
|
117
|
+
|
|
118
|
+
// Shift up if the panel would overflow the viewport bottom.
|
|
119
|
+
requestAnimationFrame(() => {
|
|
120
|
+
const panelRect = panel.getBoundingClientRect()
|
|
121
|
+
const viewportH = window.innerHeight
|
|
122
|
+
if (panelRect.bottom > viewportH - 8) {
|
|
123
|
+
const overflow = panelRect.bottom - (viewportH - 8)
|
|
124
|
+
panel.style.top = `${parseFloat(panel.style.top) - overflow}px`
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -24,6 +24,14 @@ import BulkActionsController from "./bulk_actions_controller.js"
|
|
|
24
24
|
import FilterPanelController from "./filter_panel_controller.js"
|
|
25
25
|
import TextareaAutogrowController from "./textarea_autogrow_controller.js"
|
|
26
26
|
import ClipboardController from "./clipboard_controller.js"
|
|
27
|
+
import IconRailController from "./icon_rail_controller.js"
|
|
28
|
+
import IconRailFlyoutController from "./icon_rail_flyout_controller.js"
|
|
29
|
+
import TableHeaderController from "./table_header_controller.js"
|
|
30
|
+
import TableColumnMenuController from "./table_column_menu_controller.js"
|
|
31
|
+
import CaptureUrlController from "./capture_url_controller.js"
|
|
32
|
+
import RowClickController from "./row_click_controller.js"
|
|
33
|
+
import ViewSwitcherController from "./view_switcher_controller.js"
|
|
34
|
+
import AutosubmitController from "./autosubmit_controller.js"
|
|
27
35
|
|
|
28
36
|
export default function (application) {
|
|
29
37
|
// Register controllers here
|
|
@@ -52,4 +60,12 @@ export default function (application) {
|
|
|
52
60
|
application.register("filter-panel", FilterPanelController)
|
|
53
61
|
application.register("textarea-autogrow", TextareaAutogrowController)
|
|
54
62
|
application.register("clipboard", ClipboardController)
|
|
63
|
+
application.register("icon-rail", IconRailController)
|
|
64
|
+
application.register("icon-rail-flyout", IconRailFlyoutController)
|
|
65
|
+
application.register("table-header", TableHeaderController)
|
|
66
|
+
application.register("table-column-menu", TableColumnMenuController)
|
|
67
|
+
application.register("capture-url", CaptureUrlController)
|
|
68
|
+
application.register("row-click", RowClickController)
|
|
69
|
+
application.register("view-switcher", ViewSwitcherController)
|
|
70
|
+
application.register("autosubmit", AutosubmitController)
|
|
55
71
|
}
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
|
|
3
3
|
// Connects to data-controller="resource-tab-list"
|
|
4
|
+
//
|
|
5
|
+
// URL hash sync:
|
|
6
|
+
// - On connect, if location.hash matches a tab identifier, that tab is
|
|
7
|
+
// selected (overrides defaultTabValue). Falls back to defaultTabValue
|
|
8
|
+
// or the first tab.
|
|
9
|
+
// - On user click, the URL hash is updated via history.replaceState
|
|
10
|
+
// (no scroll, no back-button entry).
|
|
11
|
+
// - On hashchange (back/forward, manual hash edit), the matching tab
|
|
12
|
+
// is re-selected.
|
|
4
13
|
export default class extends Controller {
|
|
5
14
|
static targets = ["btn", "tab"]
|
|
6
15
|
static values = {
|
|
@@ -13,14 +22,37 @@ export default class extends Controller {
|
|
|
13
22
|
this.activeClasses = this.hasActiveClassesValue ? this.activeClassesValue.split(" ") : []
|
|
14
23
|
this.inActiveClasses = this.hasInActiveClassesValue ? this.inActiveClassesValue.split(" ") : []
|
|
15
24
|
|
|
16
|
-
this.#
|
|
25
|
+
const fromHash = this.#buttonIdFromHash()
|
|
26
|
+
const initialId = fromHash || this.defaultTabValue || this.btnTargets[0]?.id
|
|
27
|
+
this.#selectInternal(initialId, { skipFocus: true, skipHashUpdate: true })
|
|
28
|
+
|
|
29
|
+
this._syncFromHash = this._syncFromHash.bind(this)
|
|
30
|
+
// hashchange covers manual hash edits and back/forward.
|
|
31
|
+
window.addEventListener("hashchange", this._syncFromHash)
|
|
32
|
+
// turbo:load covers Turbo navigations (including morph) where the URL
|
|
33
|
+
// changes via pushState — which doesn't fire hashchange. Without this,
|
|
34
|
+
// a second submit landing via morph leaves the previously-active tab
|
|
35
|
+
// selected even though the URL hash points elsewhere.
|
|
36
|
+
document.addEventListener("turbo:load", this._syncFromHash)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
disconnect() {
|
|
40
|
+
if (this._syncFromHash) {
|
|
41
|
+
window.removeEventListener("hashchange", this._syncFromHash)
|
|
42
|
+
document.removeEventListener("turbo:load", this._syncFromHash)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_syncFromHash() {
|
|
47
|
+
const id = this.#buttonIdFromHash()
|
|
48
|
+
if (id) this.#selectInternal(id, { skipFocus: true, skipHashUpdate: true })
|
|
17
49
|
}
|
|
18
50
|
|
|
19
51
|
select(event) {
|
|
20
52
|
this.#selectInternal(event.currentTarget.id)
|
|
21
53
|
}
|
|
22
54
|
|
|
23
|
-
#selectInternal(id) {
|
|
55
|
+
#selectInternal(id, options = {}) {
|
|
24
56
|
const selectedBtn = this.btnTargets.find(element => element.id === id)
|
|
25
57
|
if (!selectedBtn) {
|
|
26
58
|
console.error(`Tab Button with id "${id}" not found`)
|
|
@@ -56,9 +88,30 @@ export default class extends Controller {
|
|
|
56
88
|
selectedTab.hidden = false
|
|
57
89
|
selectedTab.setAttribute('aria-hidden', 'false')
|
|
58
90
|
|
|
91
|
+
// Sync URL hash so the tab is shareable / restorable on reload.
|
|
92
|
+
if (!options.skipHashUpdate) this.#updateHash(id)
|
|
93
|
+
|
|
59
94
|
// Focus management
|
|
60
|
-
if (selectedBtn !== document.activeElement) {
|
|
95
|
+
if (!options.skipFocus && selectedBtn !== document.activeElement) {
|
|
61
96
|
selectedBtn.focus()
|
|
62
97
|
}
|
|
63
98
|
}
|
|
99
|
+
|
|
100
|
+
// Button ids follow `${identifier}-tab`. The URL hash carries just
|
|
101
|
+
// the identifier (e.g., #details, #orders).
|
|
102
|
+
#buttonIdFromHash() {
|
|
103
|
+
const hash = window.location.hash.replace(/^#/, "")
|
|
104
|
+
if (!hash) return null
|
|
105
|
+
const candidateId = `${hash}-tab`
|
|
106
|
+
const exists = this.btnTargets.some(btn => btn.id === candidateId)
|
|
107
|
+
return exists ? candidateId : null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#updateHash(buttonId) {
|
|
111
|
+
const identifier = buttonId.replace(/-tab$/, "")
|
|
112
|
+
const newHash = `#${identifier}`
|
|
113
|
+
if (window.location.hash !== newHash) {
|
|
114
|
+
history.replaceState(null, "", newHash)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
64
117
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Connects to data-controller="row-click"
|
|
4
|
+
//
|
|
5
|
+
// Makes a table row or grid card behave like the resource's Show
|
|
6
|
+
// affordance: clicking anywhere except a real interactive element
|
|
7
|
+
// triggers the row's existing Show button. Single source of truth for
|
|
8
|
+
// the URL — turbo-frame targets, modal opening, and any other
|
|
9
|
+
// configuration on the Show action are inherited automatically.
|
|
10
|
+
//
|
|
11
|
+
// Mark the show element with `data-row-click-target="show"`.
|
|
12
|
+
// Opt out of triggering for a specific element with
|
|
13
|
+
// `data-row-click-ignore`.
|
|
14
|
+
export default class extends Controller {
|
|
15
|
+
click(event) {
|
|
16
|
+
if (event.target.closest("a, button, input, label, select, textarea, [data-row-click-ignore]")) {
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
this.element.querySelector('[data-row-click-target="show"]')?.click()
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -1,3 +1,30 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// Persists across controller reconnects so the value saved on
|
|
4
|
+
// turbo:before-render is still available on turbo:render, even though
|
|
5
|
+
// the <aside> hosting this controller is replaced during navigation.
|
|
6
|
+
let savedScrollTop = 0;
|
|
7
|
+
|
|
8
|
+
export default class extends Controller {
|
|
9
|
+
static targets = ["scroll"];
|
|
10
|
+
|
|
11
|
+
connect() {
|
|
12
|
+
this.beforeRender = this.beforeRender.bind(this);
|
|
13
|
+
this.afterRender = this.afterRender.bind(this);
|
|
14
|
+
document.addEventListener("turbo:before-render", this.beforeRender);
|
|
15
|
+
document.addEventListener("turbo:render", this.afterRender);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
disconnect() {
|
|
19
|
+
document.removeEventListener("turbo:before-render", this.beforeRender);
|
|
20
|
+
document.removeEventListener("turbo:render", this.afterRender);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
beforeRender() {
|
|
24
|
+
if (this.hasScrollTarget) savedScrollTop = this.scrollTarget.scrollTop;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
afterRender() {
|
|
28
|
+
if (this.hasScrollTarget) this.scrollTarget.scrollTop = savedScrollTop;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Connects to data-controller="table-column-menu"
|
|
4
|
+
// Toggles the column ⋯ menu panel; closes on outside click and Escape.
|
|
5
|
+
export default class extends Controller {
|
|
6
|
+
static targets = ["panel"]
|
|
7
|
+
|
|
8
|
+
connect() {
|
|
9
|
+
this._onDocClick = this._onDocClick.bind(this)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
toggle(event) {
|
|
13
|
+
event.preventDefault()
|
|
14
|
+
event.stopPropagation()
|
|
15
|
+
if (this.hasPanelTarget) {
|
|
16
|
+
const isNowVisible = !this.panelTarget.classList.toggle("hidden")
|
|
17
|
+
if (isNowVisible) {
|
|
18
|
+
document.addEventListener("click", this._onDocClick)
|
|
19
|
+
this._onKey = (e) => { if (e.key === "Escape") this._close() }
|
|
20
|
+
document.addEventListener("keydown", this._onKey)
|
|
21
|
+
} else {
|
|
22
|
+
this._unbind()
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
_close() {
|
|
28
|
+
if (this.hasPanelTarget) this.panelTarget.classList.add("hidden")
|
|
29
|
+
this._unbind()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
_unbind() {
|
|
33
|
+
document.removeEventListener("click", this._onDocClick)
|
|
34
|
+
if (this._onKey) {
|
|
35
|
+
document.removeEventListener("keydown", this._onKey)
|
|
36
|
+
this._onKey = null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_onDocClick(event) {
|
|
41
|
+
if (!this.element.contains(event.target)) this._close()
|
|
42
|
+
}
|
|
43
|
+
}
|