PageSourceSearch

https://www.georgetowncoc.com/assets/controllers/custom_repeater_controller-a5a49e55.js

js georgetowncoc.com collected 2026-09-25 04:04:22 UTC 8,822 bytes, 264 lines download raw bytes

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.