@unabridged/midwest 0.25.0 → 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 (24) hide show
  1. package/app/assets/javascript/midwest/index.ts +6 -0
  2. package/app/assets/javascript/midwest.js +605 -21
  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 +6 -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/split_view_component/split_view_component_controller.js +99 -0
  18. package/dist/javascript/collection/app/components/midwest/split_view_component/split_view_component_controller.js.map +1 -0
  19. package/dist/javascript/collection/app/components/midwest/table_component/table_component_controller.js +239 -15
  20. package/dist/javascript/collection/app/components/midwest/table_component/table_component_controller.js.map +1 -1
  21. package/dist/javascript/midwest.d.ts +16 -1
  22. package/dist/javascript/midwest.js +605 -21
  23. package/dist/javascript/midwest.js.map +1 -1
  24. 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;
@@ -3708,8 +3740,29 @@ class ConfirmationController extends Controller {
3708
3740
  }
3709
3741
 
3710
3742
  class Chart extends Controller {
3711
- static targets = ["tooltip", "svg"];
3712
- 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
+ ];
3713
3766
  connect() {
3714
3767
  if (this.tooltipEnabledValue && this.hasTooltipTarget) {
3715
3768
  this.attachListeners();
@@ -3789,6 +3842,105 @@ class Chart extends Controller {
3789
3842
  this.tooltipTarget.style.left = `${left}px`;
3790
3843
  this.tooltipTarget.style.top = `${top}px`;
3791
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
+ }
3792
3944
  }
3793
3945
 
3794
3946
  class Range extends Controller {
@@ -4777,7 +4929,7 @@ var MMCQ = function() {
4777
4929
  };
4778
4930
  }();
4779
4931
 
4780
- class Image extends Controller {
4932
+ let Image$1 = class Image extends Controller {
4781
4933
  static targets = ["dialog"];
4782
4934
  static values = { poster: String };
4783
4935
  connect() {
@@ -4817,7 +4969,7 @@ class Image extends Controller {
4817
4969
  this.dialogTarget.close();
4818
4970
  }
4819
4971
  }
4820
- }
4972
+ };
4821
4973
 
4822
4974
  class ColorPicker extends Controller {
4823
4975
  static targets = ["input", "swatch", "customSwatch", "colorInput"];
@@ -5013,16 +5165,69 @@ class HorizontalScroll extends Controller {
5013
5165
  }
5014
5166
 
5015
5167
  class Table extends Controller {
5016
- 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 ─────────────────────────────────────────────────────────────
5017
5183
  toggleAll() {
5018
5184
  const checked = this.selectAllTarget.checked;
5019
5185
  this.rowCheckboxTargets.forEach((cb) => {
5020
5186
  cb.checked = checked;
5021
5187
  });
5022
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
+ }
5023
5228
  navigateRow(event) {
5024
5229
  const target = event.target;
5025
- if (target.closest('a, button, input, select, textarea, [role="button"]'))
5230
+ if (target.closest('a, button, input, select, textarea, [role="button"], .editable'))
5026
5231
  return;
5027
5232
  const row = event.currentTarget;
5028
5233
  const href = row.dataset.href;
@@ -5039,22 +5244,193 @@ class Table extends Controller {
5039
5244
  }
5040
5245
  }
5041
5246
  }
5042
- updateSelectAll() {
5043
- 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)
5044
5254
  return;
5045
- const total = this.rowCheckboxTargets.length;
5046
- const checkedCount = this.rowCheckboxTargets.filter((cb) => cb.checked).length;
5047
- if (checkedCount === 0) {
5048
- this.selectAllTarget.checked = false;
5049
- this.selectAllTarget.indeterminate = false;
5050
- } else if (checkedCount === total) {
5051
- this.selectAllTarget.checked = true;
5052
- this.selectAllTarget.indeterminate = false;
5053
- } else {
5054
- this.selectAllTarget.checked = false;
5055
- 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 {
5056
5309
  }
5057
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
+ }
5058
5434
  }
5059
5435
 
5060
5436
  function observeViewport(element, options = {}) {
@@ -8888,6 +9264,211 @@ class Popover extends Controller {
8888
9264
  }
8889
9265
  }
8890
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
+
8891
9472
  function registerMidwestControllers(application) {
8892
9473
  application.register("midwest-card", Card);
8893
9474
  application.register("midwest-banner", Banner);
@@ -8908,7 +9489,7 @@ function registerMidwestControllers(application) {
8908
9489
  application.register("midwest-range", Range);
8909
9490
  application.register("midwest-rating", Rating);
8910
9491
  application.register("midwest-autocomplete", Autocomplete);
8911
- application.register("midwest-image", Image);
9492
+ application.register("midwest-image", Image$1);
8912
9493
  application.register("midwest-color-picker", ColorPicker);
8913
9494
  application.register("midwest-notification", Notification);
8914
9495
  application.register("midwest-horizontal-scroll", HorizontalScroll);
@@ -8934,6 +9515,9 @@ function registerMidwestControllers(application) {
8934
9515
  application.register("midwest-map", Map$1);
8935
9516
  application.register("midwest-address", Address);
8936
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);
8937
9521
  }
8938
9522
 
8939
9523
  export { Banner, Card, Chart, CountdownTimer, registerMidwestControllers };