@unabridged/midwest 0.20.0 → 0.21.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/README.md +1 -1
- package/app/assets/javascript/midwest/index.ts +2 -0
- package/app/assets/javascript/midwest.js +276 -0
- package/app/assets/javascript/midwest.js.map +1 -1
- package/app/assets/stylesheets/midwest.css +1 -1
- package/app/assets/stylesheets/midwest.tailwind.css +3 -1
- package/dist/css/midwest.css +1 -1
- package/dist/javascript/collection/app/assets/javascript/midwest/index.js +2 -0
- package/dist/javascript/collection/app/assets/javascript/midwest/index.js.map +1 -1
- package/dist/javascript/collection/app/components/midwest/form/live_summary_component/live_summary_component_controller.js +31 -0
- package/dist/javascript/collection/app/components/midwest/form/live_summary_component/live_summary_component_controller.js.map +1 -1
- package/dist/javascript/collection/app/components/midwest/form/repeatable_component/repeatable_component_controller.js +248 -0
- package/dist/javascript/collection/app/components/midwest/form/repeatable_component/repeatable_component_controller.js.map +1 -0
- package/dist/javascript/midwest.js +276 -0
- package/dist/javascript/midwest.js.map +1 -1
- package/package.json +1 -1
|
@@ -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 };
|