@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.
Files changed (26) hide show
  1. package/app/assets/javascript/midwest/index.ts +6 -0
  2. package/app/assets/javascript/midwest.js +642 -24
  3. package/app/assets/javascript/midwest.js.map +1 -1
  4. package/app/assets/stylesheets/midwest.css +1 -1
  5. package/app/assets/stylesheets/midwest.tailwind.css +13 -1
  6. package/dist/css/midwest.css +1 -1
  7. package/dist/javascript/collection/app/assets/javascript/midwest/index.js +6 -0
  8. package/dist/javascript/collection/app/assets/javascript/midwest/index.js.map +1 -1
  9. package/dist/javascript/collection/app/components/midwest/assets_component/assets_component_controller.js +47 -0
  10. package/dist/javascript/collection/app/components/midwest/assets_component/assets_component_controller.js.map +1 -0
  11. package/dist/javascript/collection/app/components/midwest/chart_component/chart_component_controller.js +122 -2
  12. package/dist/javascript/collection/app/components/midwest/chart_component/chart_component_controller.js.map +1 -1
  13. package/dist/javascript/collection/app/components/midwest/context_menu_component/context_menu_component_controller.js +71 -0
  14. package/dist/javascript/collection/app/components/midwest/context_menu_component/context_menu_component_controller.js.map +1 -0
  15. package/dist/javascript/collection/app/components/midwest/dialog_component/dialog_component_controller.js +33 -1
  16. package/dist/javascript/collection/app/components/midwest/dialog_component/dialog_component_controller.js.map +1 -1
  17. package/dist/javascript/collection/app/components/midwest/form/form_controller.js +37 -3
  18. package/dist/javascript/collection/app/components/midwest/form/form_controller.js.map +1 -1
  19. package/dist/javascript/collection/app/components/midwest/split_view_component/split_view_component_controller.js +99 -0
  20. package/dist/javascript/collection/app/components/midwest/split_view_component/split_view_component_controller.js.map +1 -0
  21. package/dist/javascript/collection/app/components/midwest/table_component/table_component_controller.js +239 -15
  22. package/dist/javascript/collection/app/components/midwest/table_component/table_component_controller.js.map +1 -1
  23. package/dist/javascript/midwest.d.ts +16 -1
  24. package/dist/javascript/midwest.js +642 -24
  25. package/dist/javascript/midwest.js.map +1 -1
  26. 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
- if (!event.detail?.success) {
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 = { tooltipEnabled: { type: Boolean, default: true } };
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
- updateSelectAll() {
5009
- if (!this.hasSelectAllTarget)
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
- const total = this.rowCheckboxTargets.length;
5012
- const checkedCount = this.rowCheckboxTargets.filter((cb) => cb.checked).length;
5013
- if (checkedCount === 0) {
5014
- this.selectAllTarget.checked = false;
5015
- this.selectAllTarget.indeterminate = false;
5016
- } else if (checkedCount === total) {
5017
- this.selectAllTarget.checked = true;
5018
- this.selectAllTarget.indeterminate = false;
5019
- } else {
5020
- this.selectAllTarget.checked = false;
5021
- this.selectAllTarget.indeterminate = true;
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 };