@unabridged/midwest 0.20.1 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![Rails Tests](https://github.com/unabridged/midwest/actions/workflows/rails-tests.yml/badge.svg?branch=main)](https://github.com/unabridged/midwest/actions/workflows/rails-tests.yml)
4
4
  [![Bridgetown Tests](https://github.com/unabridged/midwest/actions/workflows/bridgetown-tests.yml/badge.svg?branch=main)](https://github.com/unabridged/midwest/actions/workflows/bridgetown-tests.yml)
5
5
  [![Ruby](https://img.shields.io/badge/Ruby-3.3_%7C_3.4-CC342D?logo=ruby&logoColor=white)](https://github.com/unabridged/midwest/actions/workflows/ci.yml)
6
- [![Coverage](https://img.shields.io/badge/coverage-96%25-brightgreen)](https://github.com/unabridged/midwest/actions/workflows/ci.yml)
6
+ [![Coverage](https://img.shields.io/badge/coverage-95%25-brightgreen)](https://github.com/unabridged/midwest/actions/workflows/ci.yml)
7
7
 
8
8
  Midwest Component Library is an implementation of the Midwest Design System using [ViewComponent](https://github.com/github/view_component).
9
9
 
@@ -28,6 +28,7 @@ import Pagination from '../../../components/midwest/pagination_component/paginat
28
28
  import ShowIf from '../../../components/midwest/show_if_component/show_if_component_controller'
29
29
  import TreeView from '../../../components/midwest/tree_view_component/tree_view_component_controller'
30
30
  import CommandPalette from '../../../components/midwest/command_palette_component/command_palette_component_controller'
31
+ import Repeatable from '../../../components/midwest/form/repeatable_component/repeatable_component_controller'
31
32
  /* IMPORTS */
32
33
 
33
34
  export { Card, Banner, CountdownTimer, Chart }
@@ -63,5 +64,6 @@ export function registerMidwestControllers (application: any) {
63
64
  application.register('midwest-show-if', ShowIf)
64
65
  application.register('midwest-tree-view', TreeView)
65
66
  application.register('midwest-command-palette', CommandPalette)
67
+ application.register('midwest-repeatable', Repeatable)
66
68
  /* EXPORTS */
67
69
  }
@@ -3193,12 +3193,14 @@ class FormLiveSummary extends Controller {
3193
3193
  this.form?.addEventListener("change", this.render);
3194
3194
  this.form?.addEventListener("midwest-autocomplete:change", this.render);
3195
3195
  this.form?.addEventListener("midwest-color-picker:update", this.render);
3196
+ this.form?.addEventListener("midwest-repeatable:change", this.render);
3196
3197
  }
3197
3198
  disconnect() {
3198
3199
  this.form?.removeEventListener("input", this.render);
3199
3200
  this.form?.removeEventListener("change", this.render);
3200
3201
  this.form?.removeEventListener("midwest-autocomplete:change", this.render);
3201
3202
  this.form?.removeEventListener("midwest-color-picker:update", this.render);
3203
+ this.form?.removeEventListener("midwest-repeatable:change", this.render);
3202
3204
  }
3203
3205
  get form() {
3204
3206
  return this.element.closest("form");
@@ -3250,6 +3252,8 @@ class FormLiveSummary extends Controller {
3250
3252
  return;
3251
3253
  if (element.closest(".midwest-tree-view"))
3252
3254
  return;
3255
+ if (element.closest(".midwest-repeatable"))
3256
+ return;
3253
3257
  if (element instanceof HTMLInputElement) {
3254
3258
  if (SKIPPED_INPUT_TYPES.has(element.type))
3255
3259
  return;
@@ -3330,6 +3334,33 @@ class FormLiveSummary extends Controller {
3330
3334
  });
3331
3335
  fields.push({ label, value: values.join(", ") || "\u2014" });
3332
3336
  });
3337
+ form.querySelectorAll(".midwest-repeatable").forEach((repeatable) => {
3338
+ if (this.element.contains(repeatable))
3339
+ return;
3340
+ const groupLabel = repeatable.querySelector(".midwest-form-group__top .midwest-label")?.textContent?.trim() || "Items";
3341
+ const items = Array.from(
3342
+ repeatable.querySelectorAll('[data-midwest-repeatable-target="item"]')
3343
+ ).filter((item) => {
3344
+ const destroy = item.querySelector('[data-midwest-repeatable-target="destroy"]');
3345
+ return destroy?.value !== "1";
3346
+ });
3347
+ items.forEach((item, i) => {
3348
+ const values = Array.from(
3349
+ item.querySelectorAll(
3350
+ 'input:not([type="hidden"]), textarea, select'
3351
+ )
3352
+ ).map((input) => {
3353
+ if (input instanceof HTMLSelectElement) {
3354
+ return Array.from(input.selectedOptions).map((o) => o.text).join(", ");
3355
+ }
3356
+ return input.value;
3357
+ }).filter(Boolean);
3358
+ fields.push({
3359
+ label: items.length > 1 ? `${groupLabel} ${i + 1}` : groupLabel,
3360
+ value: values.join(" \xB7 ") || "\u2014"
3361
+ });
3362
+ });
3363
+ });
3333
3364
  return fields;
3334
3365
  }
3335
3366
  // Builds a small inline colour swatch to display alongside a colour value.
@@ -5578,6 +5609,250 @@ class CommandPalette extends Controller {
5578
5609
  }
5579
5610
  }
5580
5611
 
5612
+ class Repeatable extends Controller {
5613
+ static targets = ["container", "item", "empty", "template", "addButton", "removeButton", "destroy"];
5614
+ static values = {
5615
+ min: { type: Number, default: 0 },
5616
+ max: { type: Number, default: null },
5617
+ templateId: { type: String, default: "" },
5618
+ items: { type: Array, default: [] }
5619
+ };
5620
+ // ── Lifecycle ──────────────────────────────────────────────
5621
+ connect() {
5622
+ this.reindex();
5623
+ }
5624
+ // ── Actions ────────────────────────────────────────────────
5625
+ add(event) {
5626
+ event?.preventDefault();
5627
+ if (this.maxValue !== null && this.itemsValue.length >= this.maxValue)
5628
+ return;
5629
+ const template = this.findTemplate();
5630
+ if (!template)
5631
+ return;
5632
+ const newItem = template.cloneNode(true);
5633
+ const nextIndex = this.nextAvailableIndex;
5634
+ newItem.removeAttribute("id");
5635
+ newItem.style.removeProperty("display");
5636
+ newItem.setAttribute("data-midwest-repeatable-target", "item");
5637
+ this.updateItemIndices(newItem, nextIndex);
5638
+ this.resetItemFields(newItem);
5639
+ newItem.classList.add("midwest-repeatable-item--adding");
5640
+ this.containerTarget.appendChild(newItem);
5641
+ setTimeout(() => {
5642
+ newItem.classList.remove("midwest-repeatable-item--adding");
5643
+ }, 300);
5644
+ this.reindex();
5645
+ }
5646
+ remove(event) {
5647
+ event?.preventDefault();
5648
+ const button = event.currentTarget;
5649
+ const item = button.closest('[data-midwest-repeatable-target="item"]');
5650
+ if (!item)
5651
+ return;
5652
+ if (this.activeItems.length <= this.minValue)
5653
+ return;
5654
+ const destroyInput = item.querySelector('[data-midwest-repeatable-target="destroy"]');
5655
+ if (destroyInput) {
5656
+ destroyInput.value = "1";
5657
+ }
5658
+ item.classList.add("midwest-repeatable-item--removing");
5659
+ setTimeout(() => {
5660
+ item.remove();
5661
+ this.reindex();
5662
+ }, 300);
5663
+ }
5664
+ removeAll(event) {
5665
+ event?.preventDefault();
5666
+ const active = this.activeItems;
5667
+ if (active.length <= this.minValue)
5668
+ return;
5669
+ const toRemove = active.slice(this.minValue);
5670
+ toRemove.forEach((item) => {
5671
+ const destroyInput = item.querySelector('[data-midwest-repeatable-target="destroy"]');
5672
+ if (destroyInput)
5673
+ destroyInput.value = "1";
5674
+ item.classList.add("midwest-repeatable-item--removing");
5675
+ });
5676
+ setTimeout(() => {
5677
+ toRemove.forEach((item) => item.remove());
5678
+ this.reindex();
5679
+ }, 300);
5680
+ }
5681
+ // ── Core: reindex builds state from DOM ────────────────────
5682
+ reindex() {
5683
+ const items = this.activeItems;
5684
+ const states = items.map((item) => {
5685
+ const index = this.extractIndex(item);
5686
+ const { valid, errors } = this.validateItem(item);
5687
+ return { index, valid, errors };
5688
+ });
5689
+ this.itemsValue = states;
5690
+ this.setFieldsetErrors(states);
5691
+ this.updateUI();
5692
+ }
5693
+ // ── Validation ─────────────────────────────────────────────
5694
+ validateItem(item) {
5695
+ const fields = item.querySelectorAll(
5696
+ 'input:not([type="hidden"]), textarea, select'
5697
+ );
5698
+ const errors = [];
5699
+ let valid = true;
5700
+ fields.forEach((field) => {
5701
+ if (!field.checkValidity()) {
5702
+ valid = false;
5703
+ const label = item.querySelector(`label[for="${field.id}"]`);
5704
+ const name = label?.textContent?.trim() || field.name || "Field";
5705
+ errors.push(`${name}: ${field.validationMessage}`);
5706
+ }
5707
+ });
5708
+ return { valid, errors };
5709
+ }
5710
+ // ── Fieldset error management ──────────────────────────────
5711
+ setFieldsetErrors(states) {
5712
+ const invalidItems = states.filter((s) => !s.valid);
5713
+ if (invalidItems.length > 0) {
5714
+ this.element.setAttribute("data-repeatable-errors", JSON.stringify(
5715
+ invalidItems.map((s) => ({ index: s.index, errors: s.errors }))
5716
+ ));
5717
+ this.element.setAttribute("aria-invalid", "true");
5718
+ } else {
5719
+ this.element.removeAttribute("data-repeatable-errors");
5720
+ this.element.removeAttribute("aria-invalid");
5721
+ }
5722
+ this.activeItems.forEach((item) => {
5723
+ const index = this.extractIndex(item);
5724
+ const state = states.find((s) => s.index === index);
5725
+ if (state && !state.valid) {
5726
+ item.setAttribute("data-repeatable-invalid", "true");
5727
+ item.setAttribute("data-repeatable-item-errors", JSON.stringify(state.errors));
5728
+ } else {
5729
+ item.removeAttribute("data-repeatable-invalid");
5730
+ item.removeAttribute("data-repeatable-item-errors");
5731
+ }
5732
+ });
5733
+ }
5734
+ // ── Optional: query-selector-based index rebuild ───────────
5735
+ queryItems() {
5736
+ return Array.from(
5737
+ this.containerTarget.querySelectorAll('[data-midwest-repeatable-target="item"]')
5738
+ ).filter((item) => {
5739
+ const d = item.querySelector('[data-midwest-repeatable-target="destroy"]');
5740
+ return d?.value !== "1";
5741
+ }).map((item) => {
5742
+ const index = this.extractIndex(item);
5743
+ const { valid, errors } = this.validateItem(item);
5744
+ return { index, valid, errors };
5745
+ });
5746
+ }
5747
+ // ── Stimulus value-changed callback ────────────────────────
5748
+ itemsValueChanged() {
5749
+ this.dispatch("change", {
5750
+ detail: { count: this.itemsValue.length, items: this.itemsValue }
5751
+ });
5752
+ }
5753
+ // ── Private helpers ────────────────────────────────────────
5754
+ findTemplate() {
5755
+ const active = this.activeItems;
5756
+ if (active.length > 0)
5757
+ return active[0];
5758
+ if (this.itemTargets.length > 0)
5759
+ return this.itemTargets[0];
5760
+ if (this.hasTemplateTarget)
5761
+ return this.templateTarget;
5762
+ return null;
5763
+ }
5764
+ extractIndex(item) {
5765
+ const destroyInput = item.querySelector('[data-midwest-repeatable-target="destroy"]');
5766
+ if (destroyInput?.name) {
5767
+ const match = destroyInput.name.match(/\[(\d+)\]\[_destroy\]$/);
5768
+ if (match)
5769
+ return parseInt(match[1], 10);
5770
+ }
5771
+ const anyInput = item.querySelector("[name]");
5772
+ if (anyInput?.name) {
5773
+ const match = anyInput.name.match(/\[(\d+)\]/);
5774
+ if (match)
5775
+ return parseInt(match[1], 10);
5776
+ }
5777
+ return 0;
5778
+ }
5779
+ get nextAvailableIndex() {
5780
+ const indices = this.itemsValue.map((s) => s.index);
5781
+ if (indices.length === 0)
5782
+ return 0;
5783
+ return Math.max(...indices) + 1;
5784
+ }
5785
+ resetItemFields(item) {
5786
+ const fields = item.querySelectorAll(
5787
+ 'input:not([data-midwest-repeatable-target="destroy"]), textarea, select'
5788
+ );
5789
+ fields.forEach((field) => {
5790
+ if (field instanceof HTMLInputElement) {
5791
+ if (field.type === "checkbox" || field.type === "radio") {
5792
+ field.checked = false;
5793
+ } else {
5794
+ field.value = "";
5795
+ }
5796
+ } else if (field instanceof HTMLSelectElement) {
5797
+ field.selectedIndex = 0;
5798
+ } else {
5799
+ field.value = "";
5800
+ }
5801
+ });
5802
+ const destroyInput = item.querySelector('[data-midwest-repeatable-target="destroy"]');
5803
+ if (destroyInput)
5804
+ destroyInput.value = "0";
5805
+ item.removeAttribute("data-repeatable-invalid");
5806
+ item.removeAttribute("data-repeatable-item-errors");
5807
+ }
5808
+ updateItemIndices(item, newIndex) {
5809
+ item.querySelectorAll("[name]").forEach((input) => {
5810
+ if (input.name?.includes("[")) {
5811
+ input.name = input.name.replace(/\[(\d+|new_\w+|__INDEX__)\]\[/g, `[${newIndex}][`);
5812
+ }
5813
+ });
5814
+ item.querySelectorAll("[id]").forEach((el) => {
5815
+ if (el.id?.match(/(_\d+_|_new_\w+_|___INDEX___)/)) {
5816
+ el.id = el.id.replace(/(_\d+_|_new_\w+_|___INDEX___)/g, `_${newIndex}_`);
5817
+ }
5818
+ });
5819
+ item.querySelectorAll("[for]").forEach((label) => {
5820
+ if (label.htmlFor?.match(/(_\d+_|_new_\w+_|___INDEX___)/)) {
5821
+ label.htmlFor = label.htmlFor.replace(/(_\d+_|_new_\w+_|___INDEX___)/g, `_${newIndex}_`);
5822
+ }
5823
+ });
5824
+ const destroyInput = item.querySelector('[data-midwest-repeatable-target="destroy"]');
5825
+ if (destroyInput?.name) {
5826
+ destroyInput.name = destroyInput.name.replace(
5827
+ /\[(\d+|new_\w+|__INDEX__)\]\[_destroy\]/,
5828
+ `[${newIndex}][_destroy]`
5829
+ );
5830
+ }
5831
+ }
5832
+ updateUI() {
5833
+ const count = this.itemsValue.length;
5834
+ if (this.hasAddButtonTarget) {
5835
+ const atMax = this.maxValue !== null && count >= this.maxValue;
5836
+ this.addButtonTarget.disabled = atMax;
5837
+ this.addButtonTarget.classList.toggle("disabled", atMax);
5838
+ }
5839
+ this.removeButtonTargets.forEach((button) => {
5840
+ const atMin = count <= this.minValue;
5841
+ button.disabled = atMin;
5842
+ button.classList.toggle("disabled", atMin);
5843
+ });
5844
+ if (this.hasEmptyTarget) {
5845
+ this.emptyTarget.classList.toggle("hidden", count > 0);
5846
+ }
5847
+ }
5848
+ get activeItems() {
5849
+ return this.itemTargets.filter((item) => {
5850
+ const destroyInput = item.querySelector('[data-midwest-repeatable-target="destroy"]');
5851
+ return destroyInput?.value !== "1";
5852
+ });
5853
+ }
5854
+ }
5855
+
5581
5856
  function registerMidwestControllers(application) {
5582
5857
  application.register("midwest-card", Card);
5583
5858
  application.register("midwest-banner", Banner);
@@ -5609,6 +5884,7 @@ function registerMidwestControllers(application) {
5609
5884
  application.register("midwest-show-if", ShowIf);
5610
5885
  application.register("midwest-tree-view", TreeView);
5611
5886
  application.register("midwest-command-palette", CommandPalette);
5887
+ application.register("midwest-repeatable", Repeatable);
5612
5888
  }
5613
5889
 
5614
5890
  export { Banner, Card, Chart, CountdownTimer, registerMidwestControllers };