1import { Controller } from "@hotwired/stimulus" 2 3const REPEATER_PLATFORM_MAX_ITEMS = 200 4 5export default class extends Controller { 6 static targets = ["value", "items", "item", "template", "addButton", "countLabel", "announcement"] 7 static values = { 8 min: Number, 9 max: Number, 10 itemLabel: String, 11 summaryField: String 12 } 13 14 connect() { 15 this.refresh({ emit: false }) 16 } 17 18 async add(event) { 19 event.preventDefault() 20 if (this.itemTargets.length >= this.maximumItems()) return 21 22 await this.ensureRichTextRuntime() 23 const token = this.uniqueToken() 24 const wrapper = document.createElement("div") 25 wrapper.innerHTML = this.templateTarget.innerHTML.replaceAll("__ITEM_TOKEN__", token) 26 const item = wrapper.firstElementChild 27 if (!(item instanceof HTMLElement)) return 28 29 this.itemsTarget.appendChild(item) 30 this.setExpanded(item, true) 31 this.refresh() 32 this.emitStructuralChange() 33 34 window.requestAnimationFrame(() => this.focusFirstField(item)) 35 } 36 37 remove(event) { 38 event.preventDefault() 39 const item = event.currentTarget.closest("[data-custom-repeater-item]") 40 if (!(item instanceof HTMLElement) || this.itemTargets.length <= this.minimumItems()) return 41 42 const items = this.itemTargets 43 const removedIndex = items.indexOf(item) 44 item.remove() 45 this.refresh() 46 this.emitStructuralChange() 47 48 const remaining = this.itemTargets 49 const nextItem = remaining[Math.min(removedIndex, remaining.length - 1)] 50 const nextControl = nextItem?.querySelector("[data-custom-repeater-toggle]") 51 if (nextControl instanceof HTMLElement) { 52 nextControl.focus() 53 } else if (this.hasAddButtonTarget) { 54 this.addButtonTarget.focus() 55 } 56 } 57 58 moveUp(event) { 59 event.preventDefault() 60 this.move(event.currentTarget, -1) 61 } 62 63 moveDown(event) { 64 event.preventDefault() 65 this.move(event.currentTarget, 1) 66 } 67 68 toggle(event) { 69 event.preventDefault() 70 const item = event.currentTarget.closest("[data-custom-repeater-item]") 71 if (!(item instanceof HTMLElement)) return 72 73 this.setExpanded(item, event.currentTarget.getAttribute("aria-expanded") !== "true") 74 } 75 76 fieldChanged(event) { 77 if (event.target === this.valueTarget) return 78 79 const item = event.target.closest?.("[data-custom-repeater-item]") 80 if (item) this.updateItemSummary(item, this.itemTargets.indexOf(item)) 81 this.syncValue() 82 } 83 84 move(control, direction) { 85 const item = control.closest("[data-custom-repeater-item]") 86 if (!(item instanceof HTMLElement)) return 87 88 const items = this.itemTargets 89 const index = items.indexOf(item) 90 const targetIndex = index + direction 91 if (index < 0 || targetIndex < 0 || targetIndex >= items.length) return 92 93 const target = items[targetIndex] 94 if (direction < 0) target.before(item) 95 else target.after(item) 96 97 this.refresh() 98 this.emitStructuralChange() 99 this.announce(`${this.label()} moved to position ${targetIndex + 1} of ${items.length}.`) 100 control.focus() 101 } 102 103 refresh({ emit = true } = {}) { 104 this.itemTargets.forEach((item, index) => { 105 this.updateItemSummary(item, index) 106 this.updateControls(item, index) 107 }) 108 this.updateCount() 109 this.syncValue({ emit }) 110 } 111 112 updateItemSummary(item, index) { 113 const number = index + 1 114 item.querySelectorAll("[data-custom-repeater-item-number]").forEach((node) => { 115 node.textContent = String(number) 116 }) 117 118 const summaryNode = item.querySelector("[data-custom-repeater-item-summary]") 119 if (!summaryNode) return 120 121 const configured = this.hasSummaryFieldValue ? this.summaryFieldValue.trim() : "" 122 const summaryField = configured ? item.querySelector(`[data-custom-repeater-item-field="${CSS.escape(configured)}"]`) : null 123 summaryNode.textContent = this.snippet(this.fieldValue(summaryField)) || `${this.label()} ${number}` 124 } 125 126 updateControls(item, index) { 127 const count = this.itemTargets.length 128 const number = index + 1 129 const label = this.label().toLowerCase() 130 this.updateButton(item, "[data-custom-repeater-move-up]", index === 0, `Move ${label} ${number} up`) 131 this.updateButton(item, "[data-custom-repeater-move-down]", index === count - 1, `Move ${label} ${number} down`) 132 this.updateButton(item, "[data-custom-repeater-remove]", count <= this.minimumItems(), `Remove ${label} ${number}`) 133 } 134 135 updateButton(item, selector, disabled, label) { 136 const button = item.querySelector(selector) 137 if (!(button instanceof HTMLButtonElement)) return 138 139 button.disabled = disabled 140 button.setAttribute("aria-label", label) 141 button.setAttribute("title", label) 142 } 143 144 updateCount() { 145 const count = this.itemTargets.length 146 if (this.hasCountLabelTarget) { 147 const noun = count === 1 ? this.label().toLowerCase() : `${this.label().toLowerCase()}s` 148 this.countLabelTarget.textContent = `${count} ${noun}` 149 } 150 if (this.hasAddButtonTarget) { 151 this.addButtonTarget.disabled = count >= this.maximumItems() 152 } 153 } 154 155 syncValue({ emit = true } = {}) { 156 const serialized = JSON.stringify(this.itemTargets.map((item) => this.itemValue(item))) 157 const changed = this.valueTarget.value !== serialized 158 this.valueTarget.value = serialized 159 if (!emit || !changed) return 160 161 this.valueTarget.dispatchEvent(new Event("input", { bubbles: true })) 162 this.valueTarget.dispatchEvent(new Event("change", { bubbles: true })) 163 } 164 165 itemValue(item) { 166 const value = Object.create(null) 167 item.querySelectorAll("[data-custom-repeater-item-field]").forEach((field) => { 168 const path = field.dataset.customRepeaterItemField 169 if (!path) return 170 this.writePath(value, path, this.fieldValue(field)) 171 }) 172 return value 173 } 174 175 writePath(value, path, fieldValue) {
176 const segments = String(path).split(".").filter(Boolean) 177 let cursor = value 178 segments.forEach((segment, index) => { 179 if (index === segments.length - 1) { 180 cursor[segment] = fieldValue 181 } else { 182 const existing = cursor[segment] 183 if (!Object.prototype.hasOwnProperty.call(cursor, segment) || !existing || typeof existing !== "object" || Array.isArray(existing)) { 184 cursor[segment] = Object.create(null) 185 } 186 cursor = cursor[segment] 187 } 188 }) 189 } 190 191 fieldValue(field) { 192 if (!field) return "" 193 if (field.tagName === "TRIX-EDITOR") { 194 const inputId = field.getAttribute("input") 195 if (!inputId) return "" 196 return field.closest("[data-custom-repeater-item]")?.querySelector(`#${CSS.escape(inputId)}`)?.value || "" 197 } 198 return "value" in field ? field.value || "" : field.textContent || "" 199 } 200 201 setExpanded(item, expanded) { 202 const toggle = item.querySelector("[data-custom-repeater-toggle]") 203 const body = item.querySelector("[data-custom-repeater-item-body]") 204 const chevron = item.querySelector("[data-custom-repeater-chevron]") 205 toggle?.setAttribute("aria-expanded", String(expanded)) 206 body?.classList.toggle("hidden", !expanded) 207 chevron?.classList.toggle("rotate-90", expanded) 208 } 209 210 focusFirstField(item) { 211 const field = item.querySelector("input:not([type='hidden']), textarea, select, trix-editor") 212 if (field instanceof HTMLElement) field.focus() 213 } 214 215 snippet(value) { 216 const parsed = new DOMParser().parseFromString(String(value || ""), "text/html") 217 const text = (parsed.body?.textContent || "").replace(/\s+/g, " ").trim() 218 return text.length > 80 ? `${text.slice(0, 77)}â¦` : text 219 } 220 221 announce(message) { 222 if (!this.hasAnnouncementTarget) return 223 224 this.announcementTarget.textContent = "" 225 window.requestAnimationFrame(() => { 226 this.announcementTarget.textContent = message 227 }) 228 } 229 230 minimumItems() { 231 return Math.max(0, Number.isFinite(this.minValue) ? this.minValue : 0) 232 } 233 234 maximumItems() { 235 const configured = Number.isFinite(this.maxValue) && this.maxValue > 0 ? this.maxValue : REPEATER_PLATFORM_MAX_ITEMS 236 return Math.min(configured, REPEATER_PLATFORM_MAX_ITEMS) 237 } 238 239 label() { 240 return this.hasItemLabelValue && this.itemLabelValue.trim() ? this.itemLabelValue.trim() : "Item" 241 } 242 243 uniqueToken() { 244 if (window.crypto?.randomUUID) return window.crypto.randomUUID().replaceAll("-", "") 245 return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}` 246 } 247 248 emitStructuralChange() { 249 this.element.dispatchEvent(new CustomEvent("page-sections:changed", { bubbles: true })) 250 } 251 252 async ensureRichTextRuntime() { 253 if (!this.templateTarget.content.querySelector("trix-editor")) return 254 255 const ensure = window.ChamberHive?.ensureTrix 256 if (typeof ensure === "function") { 257 await ensure() 258 return 259 } 260 if (window.Trix) return 261 await import("trix") 262 await import("@rails/actiontext") 263 } 264}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.