@unabridged/midwest 0.24.4 → 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.
- package/app/assets/javascript/midwest/index.ts +6 -0
- package/app/assets/javascript/midwest.js +642 -24
- package/app/assets/javascript/midwest.js.map +1 -1
- package/app/assets/stylesheets/midwest.css +1 -1
- package/app/assets/stylesheets/midwest.tailwind.css +13 -1
- package/dist/css/midwest.css +1 -1
- package/dist/javascript/collection/app/assets/javascript/midwest/index.js +6 -0
- package/dist/javascript/collection/app/assets/javascript/midwest/index.js.map +1 -1
- package/dist/javascript/collection/app/components/midwest/assets_component/assets_component_controller.js +47 -0
- package/dist/javascript/collection/app/components/midwest/assets_component/assets_component_controller.js.map +1 -0
- package/dist/javascript/collection/app/components/midwest/chart_component/chart_component_controller.js +122 -2
- package/dist/javascript/collection/app/components/midwest/chart_component/chart_component_controller.js.map +1 -1
- package/dist/javascript/collection/app/components/midwest/context_menu_component/context_menu_component_controller.js +71 -0
- package/dist/javascript/collection/app/components/midwest/context_menu_component/context_menu_component_controller.js.map +1 -0
- package/dist/javascript/collection/app/components/midwest/dialog_component/dialog_component_controller.js +33 -1
- package/dist/javascript/collection/app/components/midwest/dialog_component/dialog_component_controller.js.map +1 -1
- package/dist/javascript/collection/app/components/midwest/form/form_controller.js +37 -3
- package/dist/javascript/collection/app/components/midwest/form/form_controller.js.map +1 -1
- package/dist/javascript/collection/app/components/midwest/split_view_component/split_view_component_controller.js +99 -0
- package/dist/javascript/collection/app/components/midwest/split_view_component/split_view_component_controller.js.map +1 -0
- package/dist/javascript/collection/app/components/midwest/table_component/table_component_controller.js +239 -15
- package/dist/javascript/collection/app/components/midwest/table_component/table_component_controller.js.map +1 -1
- package/dist/javascript/midwest.d.ts +16 -1
- package/dist/javascript/midwest.js +642 -24
- package/dist/javascript/midwest.js.map +1 -1
- package/package.json +1 -1
|
@@ -2688,13 +2688,18 @@ class Prose extends Controller {
|
|
|
2688
2688
|
class Dialog extends Controller {
|
|
2689
2689
|
static targets = ["remoteContent", "clearableForm"];
|
|
2690
2690
|
static values = {
|
|
2691
|
-
resetFrame: { type: Boolean, default: false }
|
|
2691
|
+
resetFrame: { type: Boolean, default: false },
|
|
2692
|
+
history: { type: Boolean, default: false }
|
|
2692
2693
|
};
|
|
2693
2694
|
_previousFocus = null;
|
|
2695
|
+
// True while a close is being driven by a history navigation (Back button),
|
|
2696
|
+
// so onClose knows not to push another history entry in response.
|
|
2697
|
+
_closingViaHistory = false;
|
|
2694
2698
|
connect() {
|
|
2695
2699
|
document.addEventListener("turbo:frame-load", this.bindToggleEls);
|
|
2696
2700
|
document.addEventListener("turbo:before-visit", this.dismiss);
|
|
2697
2701
|
document.addEventListener("click", this.handleClickOutside);
|
|
2702
|
+
window.addEventListener("popstate", this.handlePopState);
|
|
2698
2703
|
this.dialog.addEventListener("close", this.onClose);
|
|
2699
2704
|
this.bindToggleEls();
|
|
2700
2705
|
}
|
|
@@ -2702,8 +2707,18 @@ class Dialog extends Controller {
|
|
|
2702
2707
|
document.removeEventListener("turbo:frame-load", this.bindToggleEls);
|
|
2703
2708
|
document.removeEventListener("turbo:before-visit", this.dismiss);
|
|
2704
2709
|
document.removeEventListener("click", this.handleClickOutside);
|
|
2710
|
+
window.removeEventListener("popstate", this.handlePopState);
|
|
2705
2711
|
this.dialog.removeEventListener("close", this.onClose);
|
|
2706
2712
|
}
|
|
2713
|
+
// History-aware modals: a Back navigation (popstate) closes the open dialog.
|
|
2714
|
+
// The flag tells onClose this close came from history, so it must not push
|
|
2715
|
+
// another entry back onto the stack.
|
|
2716
|
+
handlePopState = () => {
|
|
2717
|
+
if (!this.historyValue || !this.dialog.open)
|
|
2718
|
+
return;
|
|
2719
|
+
this._closingViaHistory = true;
|
|
2720
|
+
this.dialog.close();
|
|
2721
|
+
};
|
|
2707
2722
|
get dialog() {
|
|
2708
2723
|
return this.element;
|
|
2709
2724
|
}
|
|
@@ -2755,6 +2770,8 @@ class Dialog extends Controller {
|
|
|
2755
2770
|
if (!detail.success)
|
|
2756
2771
|
return;
|
|
2757
2772
|
const redirectUrl = detail.fetchResponse?.location?.href;
|
|
2773
|
+
if (redirectUrl)
|
|
2774
|
+
this._closingViaHistory = true;
|
|
2758
2775
|
this.dismiss();
|
|
2759
2776
|
if (redirectUrl)
|
|
2760
2777
|
window.Turbo?.visit(redirectUrl);
|
|
@@ -2796,10 +2813,25 @@ class Dialog extends Controller {
|
|
|
2796
2813
|
this._previousFocus?.focus();
|
|
2797
2814
|
this._previousFocus = null;
|
|
2798
2815
|
this.htmlEl.classList.toggle("overflow-hidden", false);
|
|
2816
|
+
this.restoreHistory();
|
|
2799
2817
|
await this.delay(500);
|
|
2800
2818
|
this.resetForm();
|
|
2801
2819
|
this.resetFrame();
|
|
2802
2820
|
};
|
|
2821
|
+
// When a history-aware modal is closed manually (Escape, backdrop, close
|
|
2822
|
+
// button), pop the modal's advanced URL off the stack so the address bar
|
|
2823
|
+
// returns to the page behind it. A close driven by the Back button already
|
|
2824
|
+
// moved history, so it is skipped.
|
|
2825
|
+
restoreHistory() {
|
|
2826
|
+
if (!this.historyValue)
|
|
2827
|
+
return;
|
|
2828
|
+
if (this._closingViaHistory) {
|
|
2829
|
+
this._closingViaHistory = false;
|
|
2830
|
+
return;
|
|
2831
|
+
}
|
|
2832
|
+
if (window.history.length > 1)
|
|
2833
|
+
window.history.back();
|
|
2834
|
+
}
|
|
2803
2835
|
resetFrame() {
|
|
2804
2836
|
if (!this.resetFrameValue || this.frameEl == null)
|
|
2805
2837
|
return;
|
|
@@ -3579,13 +3611,30 @@ class FormLiveSummary extends Controller {
|
|
|
3579
3611
|
}
|
|
3580
3612
|
|
|
3581
3613
|
class Form extends Controller {
|
|
3614
|
+
static values = {
|
|
3615
|
+
// Auto-submit the form (debounced) whenever a field changes.
|
|
3616
|
+
autosave: { type: Boolean, default: false },
|
|
3617
|
+
autosaveDelay: { type: Number, default: 600 },
|
|
3618
|
+
// Emit success/error events and keep buttons interactive after an in-page
|
|
3619
|
+
// (non-navigating) Turbo submission.
|
|
3620
|
+
ajax: { type: Boolean, default: false }
|
|
3621
|
+
};
|
|
3622
|
+
autosaveTimer;
|
|
3582
3623
|
connect() {
|
|
3583
3624
|
this.element.addEventListener("submit", this.handleSubmit);
|
|
3584
3625
|
this.element.addEventListener("turbo:submit-end", this.handleTurboEnd);
|
|
3626
|
+
if (this.autosaveValue) {
|
|
3627
|
+
this.element.addEventListener("input", this.scheduleAutosave);
|
|
3628
|
+
this.element.addEventListener("change", this.scheduleAutosave);
|
|
3629
|
+
}
|
|
3585
3630
|
}
|
|
3586
3631
|
disconnect() {
|
|
3587
3632
|
this.element.removeEventListener("submit", this.handleSubmit);
|
|
3588
3633
|
this.element.removeEventListener("turbo:submit-end", this.handleTurboEnd);
|
|
3634
|
+
this.element.removeEventListener("input", this.scheduleAutosave);
|
|
3635
|
+
this.element.removeEventListener("change", this.scheduleAutosave);
|
|
3636
|
+
if (this.autosaveTimer != null)
|
|
3637
|
+
clearTimeout(this.autosaveTimer);
|
|
3589
3638
|
}
|
|
3590
3639
|
handleSubmit = () => {
|
|
3591
3640
|
this.submitButtons.forEach((btn) => {
|
|
@@ -3593,15 +3642,32 @@ class Form extends Controller {
|
|
|
3593
3642
|
btn.disabled = true;
|
|
3594
3643
|
});
|
|
3595
3644
|
};
|
|
3596
|
-
// Restore buttons if the submission fails (e.g. a Turbo Frame validation
|
|
3597
|
-
// error that re-renders the form without a full page transition).
|
|
3598
3645
|
handleTurboEnd = (event) => {
|
|
3599
|
-
|
|
3646
|
+
const detail = event.detail;
|
|
3647
|
+
const success = detail?.success ?? false;
|
|
3648
|
+
if (!success || this.ajaxValue || this.autosaveValue) {
|
|
3600
3649
|
this.submitButtons.forEach((btn) => {
|
|
3601
3650
|
btn.classList.remove("loading");
|
|
3602
3651
|
btn.disabled = false;
|
|
3603
3652
|
});
|
|
3604
3653
|
}
|
|
3654
|
+
if (this.ajaxValue) {
|
|
3655
|
+
this.dispatch(success ? "success" : "error", { detail });
|
|
3656
|
+
}
|
|
3657
|
+
};
|
|
3658
|
+
// Debounce field changes into a single background submission. `requestSubmit`
|
|
3659
|
+
// runs constraint validation and fires the `submit` event, so buttons pick up
|
|
3660
|
+
// their loading state and Turbo handles the request just like a manual submit.
|
|
3661
|
+
scheduleAutosave = () => {
|
|
3662
|
+
if (this.autosaveTimer != null)
|
|
3663
|
+
clearTimeout(this.autosaveTimer);
|
|
3664
|
+
this.autosaveTimer = setTimeout(() => {
|
|
3665
|
+
if (typeof this.element.requestSubmit === "function") {
|
|
3666
|
+
this.element.requestSubmit();
|
|
3667
|
+
} else {
|
|
3668
|
+
this.element.submit();
|
|
3669
|
+
}
|
|
3670
|
+
}, this.autosaveDelayValue);
|
|
3605
3671
|
};
|
|
3606
3672
|
get submitButtons() {
|
|
3607
3673
|
return Array.from(
|
|
@@ -3674,8 +3740,29 @@ class ConfirmationController extends Controller {
|
|
|
3674
3740
|
}
|
|
3675
3741
|
|
|
3676
3742
|
class Chart extends Controller {
|
|
3677
|
-
static targets = ["tooltip", "svg"];
|
|
3678
|
-
static values = {
|
|
3743
|
+
static targets = ["tooltip", "svg", "data"];
|
|
3744
|
+
static values = {
|
|
3745
|
+
tooltipEnabled: { type: Boolean, default: true },
|
|
3746
|
+
filename: { type: String, default: "chart" }
|
|
3747
|
+
};
|
|
3748
|
+
// Presentation properties copied inline when serializing the SVG, so exported
|
|
3749
|
+
// files render faithfully without the external component stylesheet.
|
|
3750
|
+
static STYLE_PROPS = [
|
|
3751
|
+
"fill",
|
|
3752
|
+
"stroke",
|
|
3753
|
+
"stroke-width",
|
|
3754
|
+
"stroke-dasharray",
|
|
3755
|
+
"stroke-linecap",
|
|
3756
|
+
"stroke-linejoin",
|
|
3757
|
+
"opacity",
|
|
3758
|
+
"fill-opacity",
|
|
3759
|
+
"stroke-opacity",
|
|
3760
|
+
"font-family",
|
|
3761
|
+
"font-size",
|
|
3762
|
+
"font-weight",
|
|
3763
|
+
"text-anchor",
|
|
3764
|
+
"color"
|
|
3765
|
+
];
|
|
3679
3766
|
connect() {
|
|
3680
3767
|
if (this.tooltipEnabledValue && this.hasTooltipTarget) {
|
|
3681
3768
|
this.attachListeners();
|
|
@@ -3755,6 +3842,105 @@ class Chart extends Controller {
|
|
|
3755
3842
|
this.tooltipTarget.style.left = `${left}px`;
|
|
3756
3843
|
this.tooltipTarget.style.top = `${top}px`;
|
|
3757
3844
|
}
|
|
3845
|
+
// --- Export actions -------------------------------------------------------
|
|
3846
|
+
// Download the chart as a standalone SVG file (styles inlined).
|
|
3847
|
+
exportSvg() {
|
|
3848
|
+
if (!this.hasSvgTarget)
|
|
3849
|
+
return;
|
|
3850
|
+
const blob = new Blob([this.serializeSvg()], { type: "image/svg+xml;charset=utf-8" });
|
|
3851
|
+
this.downloadBlob(blob, `${this.filenameValue}.svg`);
|
|
3852
|
+
}
|
|
3853
|
+
// Rasterize the chart to a PNG via an offscreen canvas (2× for crisp output).
|
|
3854
|
+
exportPng() {
|
|
3855
|
+
if (!this.hasSvgTarget)
|
|
3856
|
+
return;
|
|
3857
|
+
const box = this.svgTarget.viewBox.baseVal;
|
|
3858
|
+
const rect = this.svgTarget.getBoundingClientRect();
|
|
3859
|
+
const width = Math.max(1, Math.round(rect.width || box.width));
|
|
3860
|
+
const height = Math.max(1, Math.round(rect.height || box.height));
|
|
3861
|
+
const scale = 2;
|
|
3862
|
+
const blob = new Blob([this.serializeSvg()], { type: "image/svg+xml;charset=utf-8" });
|
|
3863
|
+
const url = URL.createObjectURL(blob);
|
|
3864
|
+
const img = new Image();
|
|
3865
|
+
img.onload = () => {
|
|
3866
|
+
const canvas = document.createElement("canvas");
|
|
3867
|
+
canvas.width = width * scale;
|
|
3868
|
+
canvas.height = height * scale;
|
|
3869
|
+
const ctx = canvas.getContext("2d");
|
|
3870
|
+
if (ctx == null) {
|
|
3871
|
+
URL.revokeObjectURL(url);
|
|
3872
|
+
return;
|
|
3873
|
+
}
|
|
3874
|
+
ctx.fillStyle = "#ffffff";
|
|
3875
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
3876
|
+
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
3877
|
+
URL.revokeObjectURL(url);
|
|
3878
|
+
canvas.toBlob((png) => {
|
|
3879
|
+
if (png != null)
|
|
3880
|
+
this.downloadBlob(png, `${this.filenameValue}.png`);
|
|
3881
|
+
}, "image/png");
|
|
3882
|
+
};
|
|
3883
|
+
img.onerror = () => {
|
|
3884
|
+
URL.revokeObjectURL(url);
|
|
3885
|
+
};
|
|
3886
|
+
img.src = url;
|
|
3887
|
+
}
|
|
3888
|
+
// Download the underlying data (chart.data_table) as a CSV file.
|
|
3889
|
+
exportCsv() {
|
|
3890
|
+
if (!this.hasDataTarget)
|
|
3891
|
+
return;
|
|
3892
|
+
const rows = JSON.parse(this.dataTarget.dataset.rows ?? "[]");
|
|
3893
|
+
const csv = rows.map((row) => row.map((cell) => this.csvCell(cell)).join(",")).join("\r\n");
|
|
3894
|
+
const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8" });
|
|
3895
|
+
this.downloadBlob(blob, `${this.filenameValue}.csv`);
|
|
3896
|
+
}
|
|
3897
|
+
csvCell(value) {
|
|
3898
|
+
if (value == null)
|
|
3899
|
+
return "";
|
|
3900
|
+
const str = String(value);
|
|
3901
|
+
return /["\n\r,]/.test(str) ? `"${str.replace(/"/g, '""')}"` : str;
|
|
3902
|
+
}
|
|
3903
|
+
// Clone the live SVG, inline computed presentation styles onto every node, and
|
|
3904
|
+
// serialize to a standalone XML string usable as a file or a canvas source.
|
|
3905
|
+
serializeSvg() {
|
|
3906
|
+
const clone = this.svgTarget.cloneNode(true);
|
|
3907
|
+
this.inlineStyles(this.svgTarget, clone);
|
|
3908
|
+
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
|
3909
|
+
clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
|
3910
|
+
const box = this.svgTarget.viewBox.baseVal;
|
|
3911
|
+
if (box?.width) {
|
|
3912
|
+
clone.setAttribute("width", String(box.width));
|
|
3913
|
+
clone.setAttribute("height", String(box.height));
|
|
3914
|
+
}
|
|
3915
|
+
const xml = new XMLSerializer().serializeToString(clone);
|
|
3916
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
3917
|
+
${xml}`;
|
|
3918
|
+
}
|
|
3919
|
+
// Copy the whitelisted computed styles from each source node to the matching
|
|
3920
|
+
// clone node. The trees are structurally identical, so index alignment holds.
|
|
3921
|
+
inlineStyles(source, target) {
|
|
3922
|
+
const sourceEls = [source, ...Array.from(source.querySelectorAll("*"))];
|
|
3923
|
+
const targetEls = [target, ...Array.from(target.querySelectorAll("*"))];
|
|
3924
|
+
sourceEls.forEach((el, i) => {
|
|
3925
|
+
const computed = window.getComputedStyle(el);
|
|
3926
|
+
const decls = Chart.STYLE_PROPS.map((prop) => {
|
|
3927
|
+
const value = computed.getPropertyValue(prop);
|
|
3928
|
+
return value ? `${prop}:${value}` : null;
|
|
3929
|
+
}).filter((decl) => decl !== null);
|
|
3930
|
+
if (decls.length > 0)
|
|
3931
|
+
targetEls[i].setAttribute("style", decls.join(";"));
|
|
3932
|
+
});
|
|
3933
|
+
}
|
|
3934
|
+
downloadBlob(blob, filename) {
|
|
3935
|
+
const url = URL.createObjectURL(blob);
|
|
3936
|
+
const link = document.createElement("a");
|
|
3937
|
+
link.href = url;
|
|
3938
|
+
link.download = filename;
|
|
3939
|
+
document.body.appendChild(link);
|
|
3940
|
+
link.click();
|
|
3941
|
+
link.remove();
|
|
3942
|
+
setTimeout(() => URL.revokeObjectURL(url), 1e3);
|
|
3943
|
+
}
|
|
3758
3944
|
}
|
|
3759
3945
|
|
|
3760
3946
|
class Range extends Controller {
|
|
@@ -4743,7 +4929,7 @@ var MMCQ = function() {
|
|
|
4743
4929
|
};
|
|
4744
4930
|
}();
|
|
4745
4931
|
|
|
4746
|
-
class Image extends Controller {
|
|
4932
|
+
let Image$1 = class Image extends Controller {
|
|
4747
4933
|
static targets = ["dialog"];
|
|
4748
4934
|
static values = { poster: String };
|
|
4749
4935
|
connect() {
|
|
@@ -4783,7 +4969,7 @@ class Image extends Controller {
|
|
|
4783
4969
|
this.dialogTarget.close();
|
|
4784
4970
|
}
|
|
4785
4971
|
}
|
|
4786
|
-
}
|
|
4972
|
+
};
|
|
4787
4973
|
|
|
4788
4974
|
class ColorPicker extends Controller {
|
|
4789
4975
|
static targets = ["input", "swatch", "customSwatch", "colorInput"];
|
|
@@ -4979,16 +5165,69 @@ class HorizontalScroll extends Controller {
|
|
|
4979
5165
|
}
|
|
4980
5166
|
|
|
4981
5167
|
class Table extends Controller {
|
|
4982
|
-
static targets = ["selectAll", "rowCheckbox"];
|
|
5168
|
+
static targets = ["selectAll", "rowCheckbox", "editable"];
|
|
5169
|
+
static values = {
|
|
5170
|
+
resizable: { type: Boolean, default: false },
|
|
5171
|
+
resizeStorageKey: { type: String, default: "" }
|
|
5172
|
+
};
|
|
5173
|
+
static MIN_COLUMN_WIDTH = 48;
|
|
5174
|
+
// Active resize gesture state.
|
|
5175
|
+
_resizeTh = null;
|
|
5176
|
+
_resizeStartX = 0;
|
|
5177
|
+
_resizeStartWidth = 0;
|
|
5178
|
+
connect() {
|
|
5179
|
+
if (this.resizableValue)
|
|
5180
|
+
this.initResize();
|
|
5181
|
+
}
|
|
5182
|
+
// ─── selection ─────────────────────────────────────────────────────────────
|
|
4983
5183
|
toggleAll() {
|
|
4984
5184
|
const checked = this.selectAllTarget.checked;
|
|
4985
5185
|
this.rowCheckboxTargets.forEach((cb) => {
|
|
4986
5186
|
cb.checked = checked;
|
|
4987
5187
|
});
|
|
4988
5188
|
}
|
|
5189
|
+
updateSelectAll() {
|
|
5190
|
+
if (!this.hasSelectAllTarget)
|
|
5191
|
+
return;
|
|
5192
|
+
const total = this.rowCheckboxTargets.length;
|
|
5193
|
+
const checkedCount = this.rowCheckboxTargets.filter((cb) => cb.checked).length;
|
|
5194
|
+
if (checkedCount === 0) {
|
|
5195
|
+
this.selectAllTarget.checked = false;
|
|
5196
|
+
this.selectAllTarget.indeterminate = false;
|
|
5197
|
+
} else if (checkedCount === total) {
|
|
5198
|
+
this.selectAllTarget.checked = true;
|
|
5199
|
+
this.selectAllTarget.indeterminate = false;
|
|
5200
|
+
} else {
|
|
5201
|
+
this.selectAllTarget.checked = false;
|
|
5202
|
+
this.selectAllTarget.indeterminate = true;
|
|
5203
|
+
}
|
|
5204
|
+
}
|
|
5205
|
+
// Shift-clicking a sort header extends the multi-column sort chain instead of
|
|
5206
|
+
// replacing it: follow the header's data-multi-sort-url. A plain click falls
|
|
5207
|
+
// through to the anchor's single-sort href. Navigation stays frame-scoped by
|
|
5208
|
+
// driving the enclosing <turbo-frame>'s src when present.
|
|
5209
|
+
sortColumn(event) {
|
|
5210
|
+
if (!event.shiftKey)
|
|
5211
|
+
return;
|
|
5212
|
+
const link = event.currentTarget;
|
|
5213
|
+
const url = link.dataset.multiSortUrl;
|
|
5214
|
+
if (!url)
|
|
5215
|
+
return;
|
|
5216
|
+
event.preventDefault();
|
|
5217
|
+
const frame = link.closest("turbo-frame");
|
|
5218
|
+
if (frame != null) {
|
|
5219
|
+
frame.src = url;
|
|
5220
|
+
} else {
|
|
5221
|
+
const Turbo = window.Turbo;
|
|
5222
|
+
if (Turbo?.visit)
|
|
5223
|
+
Turbo.visit(url);
|
|
5224
|
+
else
|
|
5225
|
+
window.location.href = url;
|
|
5226
|
+
}
|
|
5227
|
+
}
|
|
4989
5228
|
navigateRow(event) {
|
|
4990
5229
|
const target = event.target;
|
|
4991
|
-
if (target.closest('a, button, input, select, textarea, [role="button"]'))
|
|
5230
|
+
if (target.closest('a, button, input, select, textarea, [role="button"], .editable'))
|
|
4992
5231
|
return;
|
|
4993
5232
|
const row = event.currentTarget;
|
|
4994
5233
|
const href = row.dataset.href;
|
|
@@ -5005,22 +5244,193 @@ class Table extends Controller {
|
|
|
5005
5244
|
}
|
|
5006
5245
|
}
|
|
5007
5246
|
}
|
|
5008
|
-
|
|
5009
|
-
|
|
5247
|
+
// ─── column resizing ────────────────────────────────────────────────────────
|
|
5248
|
+
// Freeze the current (auto-laid-out) column widths as explicit values, switch
|
|
5249
|
+
// to a fixed layout so drags don't reflow other columns, then apply any widths
|
|
5250
|
+
// saved from a previous session.
|
|
5251
|
+
initResize() {
|
|
5252
|
+
const headers = this.headerCells();
|
|
5253
|
+
if (headers.length === 0)
|
|
5010
5254
|
return;
|
|
5011
|
-
|
|
5012
|
-
|
|
5013
|
-
|
|
5014
|
-
|
|
5015
|
-
|
|
5016
|
-
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
|
|
5255
|
+
headers.forEach((th) => {
|
|
5256
|
+
th.style.width = `${th.offsetWidth}px`;
|
|
5257
|
+
});
|
|
5258
|
+
this.table?.classList.add("is-fixed");
|
|
5259
|
+
this.restoreWidths();
|
|
5260
|
+
}
|
|
5261
|
+
startResize(event) {
|
|
5262
|
+
const handle = event.target;
|
|
5263
|
+
const th = handle.closest("th");
|
|
5264
|
+
if (th == null)
|
|
5265
|
+
return;
|
|
5266
|
+
event.preventDefault();
|
|
5267
|
+
this._resizeTh = th;
|
|
5268
|
+
this._resizeStartX = event.clientX;
|
|
5269
|
+
this._resizeStartWidth = th.offsetWidth;
|
|
5270
|
+
this.element.classList.add("is-resizing");
|
|
5271
|
+
window.addEventListener("pointermove", this.onResizeMove);
|
|
5272
|
+
window.addEventListener("pointerup", this.onResizeEnd);
|
|
5273
|
+
}
|
|
5274
|
+
// Double-clicking a handle clears that column's explicit width so it auto-sizes
|
|
5275
|
+
// to its content again.
|
|
5276
|
+
autoSizeColumn(event) {
|
|
5277
|
+
const th = event.target.closest("th");
|
|
5278
|
+
if (th == null)
|
|
5279
|
+
return;
|
|
5280
|
+
th.style.removeProperty("width");
|
|
5281
|
+
this.persistWidths();
|
|
5282
|
+
}
|
|
5283
|
+
onResizeMove = (event) => {
|
|
5284
|
+
if (this._resizeTh == null)
|
|
5285
|
+
return;
|
|
5286
|
+
const delta = event.clientX - this._resizeStartX;
|
|
5287
|
+
const width = Math.max(Table.MIN_COLUMN_WIDTH, this._resizeStartWidth + delta);
|
|
5288
|
+
this._resizeTh.style.width = `${width}px`;
|
|
5289
|
+
};
|
|
5290
|
+
onResizeEnd = () => {
|
|
5291
|
+
this._resizeTh = null;
|
|
5292
|
+
this.element.classList.remove("is-resizing");
|
|
5293
|
+
window.removeEventListener("pointermove", this.onResizeMove);
|
|
5294
|
+
window.removeEventListener("pointerup", this.onResizeEnd);
|
|
5295
|
+
this.persistWidths();
|
|
5296
|
+
};
|
|
5297
|
+
persistWidths() {
|
|
5298
|
+
if (!this.resizeStorageKeyValue)
|
|
5299
|
+
return;
|
|
5300
|
+
const widths = {};
|
|
5301
|
+
this.headerCells().forEach((th) => {
|
|
5302
|
+
const index = th.dataset.colIndex;
|
|
5303
|
+
if (index != null && th.style.width)
|
|
5304
|
+
widths[index] = th.style.width;
|
|
5305
|
+
});
|
|
5306
|
+
try {
|
|
5307
|
+
window.localStorage.setItem(this.storageKey, JSON.stringify(widths));
|
|
5308
|
+
} catch {
|
|
5022
5309
|
}
|
|
5023
5310
|
}
|
|
5311
|
+
restoreWidths() {
|
|
5312
|
+
if (!this.resizeStorageKeyValue)
|
|
5313
|
+
return;
|
|
5314
|
+
let widths = {};
|
|
5315
|
+
try {
|
|
5316
|
+
widths = JSON.parse(window.localStorage.getItem(this.storageKey) ?? "{}");
|
|
5317
|
+
} catch {
|
|
5318
|
+
return;
|
|
5319
|
+
}
|
|
5320
|
+
this.headerCells().forEach((th) => {
|
|
5321
|
+
const saved = th.dataset.colIndex != null ? widths[th.dataset.colIndex] : void 0;
|
|
5322
|
+
if (saved)
|
|
5323
|
+
th.style.width = saved;
|
|
5324
|
+
});
|
|
5325
|
+
}
|
|
5326
|
+
get storageKey() {
|
|
5327
|
+
return `midwest-table:${this.resizeStorageKeyValue}`;
|
|
5328
|
+
}
|
|
5329
|
+
headerCells() {
|
|
5330
|
+
return Array.from(this.element.querySelectorAll("thead th[data-col-index]"));
|
|
5331
|
+
}
|
|
5332
|
+
// ─── inline editing ─────────────────────────────────────────────────────────
|
|
5333
|
+
// Start editing when Enter or F2 is pressed on a focused editable cell. Ignore
|
|
5334
|
+
// keydowns bubbling up from the edit input itself (its own handler owns those).
|
|
5335
|
+
editCellKeydown(event) {
|
|
5336
|
+
if (event.target !== event.currentTarget)
|
|
5337
|
+
return;
|
|
5338
|
+
if (event.key !== "Enter" && event.key !== "F2")
|
|
5339
|
+
return;
|
|
5340
|
+
const cell = event.currentTarget;
|
|
5341
|
+
if (cell.dataset.editing === "true")
|
|
5342
|
+
return;
|
|
5343
|
+
event.preventDefault();
|
|
5344
|
+
this.startEditing(cell);
|
|
5345
|
+
}
|
|
5346
|
+
editCell(event) {
|
|
5347
|
+
this.startEditing(event.currentTarget);
|
|
5348
|
+
}
|
|
5349
|
+
startEditing(cell) {
|
|
5350
|
+
if (cell.dataset.editing === "true")
|
|
5351
|
+
return;
|
|
5352
|
+
cell.dataset.editing = "true";
|
|
5353
|
+
const input = document.createElement("input");
|
|
5354
|
+
input.type = "text";
|
|
5355
|
+
input.className = "midwest-table-edit-input";
|
|
5356
|
+
input.value = cell.dataset.value ?? cell.textContent?.trim() ?? "";
|
|
5357
|
+
cell.textContent = "";
|
|
5358
|
+
cell.appendChild(input);
|
|
5359
|
+
input.focus();
|
|
5360
|
+
input.select();
|
|
5361
|
+
input.addEventListener("keydown", (e) => {
|
|
5362
|
+
if (e.key === "Enter") {
|
|
5363
|
+
e.preventDefault();
|
|
5364
|
+
this.commitEdit(cell, input);
|
|
5365
|
+
} else if (e.key === "Escape") {
|
|
5366
|
+
e.preventDefault();
|
|
5367
|
+
this.cancelEdit(cell);
|
|
5368
|
+
}
|
|
5369
|
+
});
|
|
5370
|
+
input.addEventListener("blur", () => {
|
|
5371
|
+
this.commitEdit(cell, input);
|
|
5372
|
+
});
|
|
5373
|
+
}
|
|
5374
|
+
commitEdit(cell, input) {
|
|
5375
|
+
if (cell.dataset.editing !== "true")
|
|
5376
|
+
return;
|
|
5377
|
+
const newValue = input.value;
|
|
5378
|
+
const previousValue = cell.dataset.value ?? "";
|
|
5379
|
+
this.finishEditing(cell, newValue);
|
|
5380
|
+
if (newValue === previousValue)
|
|
5381
|
+
return;
|
|
5382
|
+
this.dispatch("edit", {
|
|
5383
|
+
bubbles: true,
|
|
5384
|
+
detail: {
|
|
5385
|
+
field: cell.dataset.field,
|
|
5386
|
+
value: newValue,
|
|
5387
|
+
previousValue,
|
|
5388
|
+
rowId: cell.closest("tr")?.dataset.rowId,
|
|
5389
|
+
cell
|
|
5390
|
+
}
|
|
5391
|
+
});
|
|
5392
|
+
this.persistEdit(cell, newValue);
|
|
5393
|
+
}
|
|
5394
|
+
cancelEdit(cell) {
|
|
5395
|
+
if (cell.dataset.editing !== "true")
|
|
5396
|
+
return;
|
|
5397
|
+
this.finishEditing(cell, cell.dataset.value ?? "");
|
|
5398
|
+
}
|
|
5399
|
+
// Replace the input with the resolved text, record the value, and return
|
|
5400
|
+
// focus to the cell for keyboard users.
|
|
5401
|
+
finishEditing(cell, value) {
|
|
5402
|
+
cell.dataset.editing = "false";
|
|
5403
|
+
cell.dataset.value = value;
|
|
5404
|
+
cell.textContent = value;
|
|
5405
|
+
cell.focus();
|
|
5406
|
+
}
|
|
5407
|
+
// PATCH the new value when the cell declares an edit URL. Persistence is
|
|
5408
|
+
// otherwise the host app's responsibility via the midwest-table:edit event.
|
|
5409
|
+
persistEdit(cell, value) {
|
|
5410
|
+
const url = cell.dataset.editUrl;
|
|
5411
|
+
if (!url)
|
|
5412
|
+
return;
|
|
5413
|
+
const token = document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") ?? "";
|
|
5414
|
+
void fetch(url, {
|
|
5415
|
+
method: "PATCH",
|
|
5416
|
+
headers: {
|
|
5417
|
+
"Content-Type": "application/json",
|
|
5418
|
+
"X-CSRF-Token": token,
|
|
5419
|
+
Accept: "text/vnd.turbo-stream.html, application/json"
|
|
5420
|
+
},
|
|
5421
|
+
body: JSON.stringify({ field: cell.dataset.field, value })
|
|
5422
|
+
}).then(async (response) => {
|
|
5423
|
+
const contentType = response.headers.get("Content-Type") ?? "";
|
|
5424
|
+
if (contentType.includes("turbo-stream")) {
|
|
5425
|
+
const Turbo = window.Turbo;
|
|
5426
|
+
Turbo?.renderStreamMessage?.(await response.text());
|
|
5427
|
+
}
|
|
5428
|
+
}).catch(() => {
|
|
5429
|
+
});
|
|
5430
|
+
}
|
|
5431
|
+
get table() {
|
|
5432
|
+
return this.element.querySelector("table");
|
|
5433
|
+
}
|
|
5024
5434
|
}
|
|
5025
5435
|
|
|
5026
5436
|
function observeViewport(element, options = {}) {
|
|
@@ -8854,6 +9264,211 @@ class Popover extends Controller {
|
|
|
8854
9264
|
}
|
|
8855
9265
|
}
|
|
8856
9266
|
|
|
9267
|
+
class ContextMenu extends Controller {
|
|
9268
|
+
static targets = ["menu"];
|
|
9269
|
+
boundOutside = (event) => this.onOutside(event);
|
|
9270
|
+
boundKeydown = (event) => this.onKeydown(event);
|
|
9271
|
+
boundDismiss = () => this.close();
|
|
9272
|
+
open(event) {
|
|
9273
|
+
event.preventDefault();
|
|
9274
|
+
this.show(event.clientX, event.clientY);
|
|
9275
|
+
}
|
|
9276
|
+
show(x, y) {
|
|
9277
|
+
const menu = this.menuTarget;
|
|
9278
|
+
if (menu.matches(":popover-open"))
|
|
9279
|
+
menu.hidePopover();
|
|
9280
|
+
menu.style.left = "0px";
|
|
9281
|
+
menu.style.top = "0px";
|
|
9282
|
+
menu.showPopover();
|
|
9283
|
+
const rect = menu.getBoundingClientRect();
|
|
9284
|
+
const vw = document.documentElement.clientWidth;
|
|
9285
|
+
const vh = document.documentElement.clientHeight;
|
|
9286
|
+
const left = Math.min(x, Math.max(0, vw - rect.width - 4));
|
|
9287
|
+
const top = Math.min(y, Math.max(0, vh - rect.height - 4));
|
|
9288
|
+
menu.style.left = `${left}px`;
|
|
9289
|
+
menu.style.top = `${top}px`;
|
|
9290
|
+
this.addListeners();
|
|
9291
|
+
}
|
|
9292
|
+
close() {
|
|
9293
|
+
if (this.menuTarget.matches(":popover-open"))
|
|
9294
|
+
this.menuTarget.hidePopover();
|
|
9295
|
+
this.removeListeners();
|
|
9296
|
+
}
|
|
9297
|
+
// Delegated from the menu: activating a leaf item closes the menu, but a click
|
|
9298
|
+
// that opens a sub-surface must keep it open. Both a submenu parent
|
|
9299
|
+
// (`as: :nested`) and a confirmation trigger open a popover via
|
|
9300
|
+
// `popovertarget`, so any such trigger is left alone — the confirmation dialog
|
|
9301
|
+
// (or submenu) then lives on inside the still-open menu.
|
|
9302
|
+
select(event) {
|
|
9303
|
+
const target = event.target;
|
|
9304
|
+
if (target.closest("[popovertarget]"))
|
|
9305
|
+
return;
|
|
9306
|
+
if (target.closest("a.midwest-dropdown-item, button.midwest-dropdown-item"))
|
|
9307
|
+
this.close();
|
|
9308
|
+
}
|
|
9309
|
+
onOutside(event) {
|
|
9310
|
+
if (!this.menuTarget.contains(event.target))
|
|
9311
|
+
this.close();
|
|
9312
|
+
}
|
|
9313
|
+
onKeydown(event) {
|
|
9314
|
+
if (event.key === "Escape")
|
|
9315
|
+
this.close();
|
|
9316
|
+
}
|
|
9317
|
+
addListeners() {
|
|
9318
|
+
document.addEventListener("pointerdown", this.boundOutside, true);
|
|
9319
|
+
document.addEventListener("keydown", this.boundKeydown, true);
|
|
9320
|
+
window.addEventListener("scroll", this.boundDismiss, true);
|
|
9321
|
+
window.addEventListener("resize", this.boundDismiss);
|
|
9322
|
+
}
|
|
9323
|
+
removeListeners() {
|
|
9324
|
+
document.removeEventListener("pointerdown", this.boundOutside, true);
|
|
9325
|
+
document.removeEventListener("keydown", this.boundKeydown, true);
|
|
9326
|
+
window.removeEventListener("scroll", this.boundDismiss, true);
|
|
9327
|
+
window.removeEventListener("resize", this.boundDismiss);
|
|
9328
|
+
}
|
|
9329
|
+
disconnect() {
|
|
9330
|
+
this.removeListeners();
|
|
9331
|
+
}
|
|
9332
|
+
}
|
|
9333
|
+
|
|
9334
|
+
class SplitView extends Controller {
|
|
9335
|
+
static targets = ["primary", "divider"];
|
|
9336
|
+
static values = {
|
|
9337
|
+
orientation: { type: String, default: "horizontal" },
|
|
9338
|
+
size: { type: Number, default: 50 },
|
|
9339
|
+
min: { type: Number, default: 10 },
|
|
9340
|
+
max: { type: Number, default: 90 },
|
|
9341
|
+
storageKey: { type: String, default: "" }
|
|
9342
|
+
};
|
|
9343
|
+
current = 50;
|
|
9344
|
+
boundMove = (event) => this.onPointerMove(event);
|
|
9345
|
+
boundUp = () => this.stopDrag();
|
|
9346
|
+
connect() {
|
|
9347
|
+
this.current = this.restore() ?? this.sizeValue;
|
|
9348
|
+
this.apply(this.current);
|
|
9349
|
+
}
|
|
9350
|
+
startDrag(event) {
|
|
9351
|
+
if (event.button !== 0)
|
|
9352
|
+
return;
|
|
9353
|
+
event.preventDefault();
|
|
9354
|
+
this.element.classList.add("is-dragging");
|
|
9355
|
+
window.addEventListener("pointermove", this.boundMove);
|
|
9356
|
+
window.addEventListener("pointerup", this.boundUp);
|
|
9357
|
+
}
|
|
9358
|
+
onPointerMove(event) {
|
|
9359
|
+
const rect = this.element.getBoundingClientRect();
|
|
9360
|
+
const pct = this.orientationValue === "vertical" ? (event.clientY - rect.top) / rect.height * 100 : (event.clientX - rect.left) / rect.width * 100;
|
|
9361
|
+
this.apply(pct);
|
|
9362
|
+
}
|
|
9363
|
+
stopDrag() {
|
|
9364
|
+
this.element.classList.remove("is-dragging");
|
|
9365
|
+
window.removeEventListener("pointermove", this.boundMove);
|
|
9366
|
+
window.removeEventListener("pointerup", this.boundUp);
|
|
9367
|
+
this.persist();
|
|
9368
|
+
}
|
|
9369
|
+
onKeydown(event) {
|
|
9370
|
+
const vertical = this.orientationValue === "vertical";
|
|
9371
|
+
if (event.key === "Home") {
|
|
9372
|
+
event.preventDefault();
|
|
9373
|
+
this.setAndPersist(this.minValue);
|
|
9374
|
+
return;
|
|
9375
|
+
}
|
|
9376
|
+
if (event.key === "End") {
|
|
9377
|
+
event.preventDefault();
|
|
9378
|
+
this.setAndPersist(this.maxValue);
|
|
9379
|
+
return;
|
|
9380
|
+
}
|
|
9381
|
+
const increaseKey = vertical ? "ArrowDown" : "ArrowRight";
|
|
9382
|
+
const decreaseKey = vertical ? "ArrowUp" : "ArrowLeft";
|
|
9383
|
+
if (event.key !== increaseKey && event.key !== decreaseKey)
|
|
9384
|
+
return;
|
|
9385
|
+
event.preventDefault();
|
|
9386
|
+
const step = event.shiftKey ? 10 : 2;
|
|
9387
|
+
this.setAndPersist(this.current + (event.key === increaseKey ? step : -step));
|
|
9388
|
+
}
|
|
9389
|
+
// Double-click restores the initial size.
|
|
9390
|
+
reset() {
|
|
9391
|
+
this.setAndPersist(this.sizeValue);
|
|
9392
|
+
}
|
|
9393
|
+
setAndPersist(pct) {
|
|
9394
|
+
this.apply(pct);
|
|
9395
|
+
this.persist();
|
|
9396
|
+
}
|
|
9397
|
+
apply(pct) {
|
|
9398
|
+
this.current = Math.min(this.maxValue, Math.max(this.minValue, pct));
|
|
9399
|
+
this.element.style.setProperty("--split-size", `${this.current}%`);
|
|
9400
|
+
this.dividerTarget.setAttribute("aria-valuenow", String(Math.round(this.current)));
|
|
9401
|
+
}
|
|
9402
|
+
persist() {
|
|
9403
|
+
if (!this.storageKeyValue)
|
|
9404
|
+
return;
|
|
9405
|
+
try {
|
|
9406
|
+
window.localStorage.setItem(this.storageKeyValue, String(this.current));
|
|
9407
|
+
} catch {
|
|
9408
|
+
}
|
|
9409
|
+
}
|
|
9410
|
+
restore() {
|
|
9411
|
+
if (!this.storageKeyValue)
|
|
9412
|
+
return null;
|
|
9413
|
+
try {
|
|
9414
|
+
const raw = window.localStorage.getItem(this.storageKeyValue);
|
|
9415
|
+
if (raw === null)
|
|
9416
|
+
return null;
|
|
9417
|
+
const value = Number.parseFloat(raw);
|
|
9418
|
+
return Number.isFinite(value) ? value : null;
|
|
9419
|
+
} catch {
|
|
9420
|
+
return null;
|
|
9421
|
+
}
|
|
9422
|
+
}
|
|
9423
|
+
disconnect() {
|
|
9424
|
+
window.removeEventListener("pointermove", this.boundMove);
|
|
9425
|
+
window.removeEventListener("pointerup", this.boundUp);
|
|
9426
|
+
}
|
|
9427
|
+
}
|
|
9428
|
+
|
|
9429
|
+
class Assets extends Controller {
|
|
9430
|
+
static values = {
|
|
9431
|
+
css: String,
|
|
9432
|
+
js: String
|
|
9433
|
+
};
|
|
9434
|
+
connect() {
|
|
9435
|
+
if (this.cssValue)
|
|
9436
|
+
this.ensureStylesheet(this.cssValue);
|
|
9437
|
+
if (this.jsValue)
|
|
9438
|
+
this.ensureScript(this.jsValue);
|
|
9439
|
+
}
|
|
9440
|
+
ensureStylesheet(href) {
|
|
9441
|
+
const url = this.absolute(href);
|
|
9442
|
+
const present = Array.from(
|
|
9443
|
+
document.querySelectorAll('link[rel="stylesheet"]')
|
|
9444
|
+
).some((link2) => link2.href === url);
|
|
9445
|
+
if (present)
|
|
9446
|
+
return;
|
|
9447
|
+
const link = document.createElement("link");
|
|
9448
|
+
link.rel = "stylesheet";
|
|
9449
|
+
link.href = href;
|
|
9450
|
+
document.head.appendChild(link);
|
|
9451
|
+
}
|
|
9452
|
+
ensureScript(src) {
|
|
9453
|
+
const url = this.absolute(src);
|
|
9454
|
+
const present = Array.from(
|
|
9455
|
+
document.querySelectorAll("script[src]")
|
|
9456
|
+
).some((script2) => script2.src === url);
|
|
9457
|
+
if (present)
|
|
9458
|
+
return;
|
|
9459
|
+
const script = document.createElement("script");
|
|
9460
|
+
script.type = "module";
|
|
9461
|
+
script.src = src;
|
|
9462
|
+
document.head.appendChild(script);
|
|
9463
|
+
}
|
|
9464
|
+
// Resolve a possibly-relative URL to its absolute form for comparison.
|
|
9465
|
+
absolute(url) {
|
|
9466
|
+
const anchor = document.createElement("a");
|
|
9467
|
+
anchor.href = url;
|
|
9468
|
+
return anchor.href;
|
|
9469
|
+
}
|
|
9470
|
+
}
|
|
9471
|
+
|
|
8857
9472
|
function registerMidwestControllers(application) {
|
|
8858
9473
|
application.register("midwest-card", Card);
|
|
8859
9474
|
application.register("midwest-banner", Banner);
|
|
@@ -8874,7 +9489,7 @@ function registerMidwestControllers(application) {
|
|
|
8874
9489
|
application.register("midwest-range", Range);
|
|
8875
9490
|
application.register("midwest-rating", Rating);
|
|
8876
9491
|
application.register("midwest-autocomplete", Autocomplete);
|
|
8877
|
-
application.register("midwest-image", Image);
|
|
9492
|
+
application.register("midwest-image", Image$1);
|
|
8878
9493
|
application.register("midwest-color-picker", ColorPicker);
|
|
8879
9494
|
application.register("midwest-notification", Notification);
|
|
8880
9495
|
application.register("midwest-horizontal-scroll", HorizontalScroll);
|
|
@@ -8900,6 +9515,9 @@ function registerMidwestControllers(application) {
|
|
|
8900
9515
|
application.register("midwest-map", Map$1);
|
|
8901
9516
|
application.register("midwest-address", Address);
|
|
8902
9517
|
application.register("midwest-popover", Popover);
|
|
9518
|
+
application.register("midwest-context-menu", ContextMenu);
|
|
9519
|
+
application.register("midwest-split-view", SplitView);
|
|
9520
|
+
application.register("midwest-assets", Assets);
|
|
8903
9521
|
}
|
|
8904
9522
|
|
|
8905
9523
|
export { Banner, Card, Chart, CountdownTimer, registerMidwestControllers };
|