1import { Controller } from "@hotwired/stimulus" 2import { escapeHtml, escapeAttr } from "controllers/utils/game_search_shared" 3 4// Wraps a nested seatup-game-search instance to support picking 2-6 candidate games 5// for a flexible FindATable listing. Each `seatup-game-search:gameSelected` event is 6// captured and converted into a chip with a hidden field; the inner search resets 7// so the host can pick another. 8// 9// Usage: 10// <div data-controller="seatup-candidate-picker"> 11// <div data-seatup-candidate-picker-target="search" 12// data-controller="seatup-game-search" 13// data-action="seatup-game-search:gameSelected->seatup-candidate-picker#capture"> 14// <%= render Seatup::GameSearchComponent.new(form:, field_name: :_temp, show_label: false) %> 15// </div> 16// <div data-seatup-candidate-picker-target="chips"></div> 17// <p data-seatup-candidate-picker-target="hint"></p> 18// </div> 19export default class extends Controller { 20 static targets = ["chips", "search", "hint", "count"] 21 static values = { 22 fieldName: { type: String, default: "find_a_table_listing[candidate_game_ids][]" }, 23 min: { type: Number, default: 2 }, 24 max: { type: Number, default: 6 } 25 } 26 27 connect() { 28 // The reused GameSearchComponent renders top-level <input name="bgg_id"> and 29 // <input name="bgg_name"> for its host-a-table flow. They'd clash with the 30 // specific-game panel's inputs at submit time, so strip them here. 31 this.searchTarget 32 .querySelectorAll('input[name="bgg_id"], input[name="bgg_name"]') 33 .forEach((el) => el.remove()) 34 35 this.updateCount() 36 } 37 38 capture(event) { 39 const detail = event.detail || {} 40 const id = detail.gameId 41 const name = detail.gameName || "" 42 const thumb = detail.gameThumbnail || "" 43 44 // Library games only â BGG-only picks don't have a Game record yet. 45 if (!id) { 46 this.flashHint("Pick a game from your library (the green Library badge).") 47 this.clearInner() 48 return 49 } 50 if (this.isAlreadyPicked(id)) { 51 this.flashHint("That game is already in your shortlist.") 52 this.clearInner() 53 return 54 } 55 if (this.chipCount() >= this.maxValue) { 56 this.flashHint(`You can only pick up to ${this.maxValue} games.`) 57 this.clearInner() 58 return 59 } 60 61 this.appendChip(id, name, thumb) 62 this.clearInner() 63 this.updateCount() 64 } 65 66 remove(event) { 67 event.preventDefault() 68 const chip = event.currentTarget.closest("[data-candidate-id]") 69 chip?.remove() 70 this.updateCount() 71 } 72 73 clearInner() { 74 // Reset the nested seatup-game-search controller so the host can pick another game. 75 // Defer to a microtask so we run *after* `selectGame` has finished updating the UI 76 // (it dispatches the gameSelected event mid-method, before showing the selected chip). 77 queueMicrotask(() => { 78 const inner = this.application.getControllerForElementAndIdentifier( 79 this.searchTarget, 80 "seatup-game-search" 81 ) 82 if (inner) { 83 inner.clearSelection() 84 } else { 85 const btn = this.searchTarget.querySelector( 86 '[data-action*="seatup-game-search#clearSelection"]' 87 ) 88 btn?.click() 89 } 90 }) 91 } 92 93 isAlreadyPicked(id) { 94 return Boolean(this.chipsTarget.querySelector(`[data-candidate-id="${CSS.escape(id)}"]`)) 95 } 96 97 chipCount() { 98 return this.chipsTarget.querySelectorAll("[data-candidate-id]").length 99 } 100 101 appendChip(id, name, thumb) { 102 const thumbHtml = thumb 103 ? `<img src="${thumb}" alt="" class="h-7 w-7 rounded-full object-cover">` 104 : `<div class="h-7 w-7 rounded-full bg-gray-700"></div>` 105 106 this.chipsTarget.insertAdjacentHTML("beforeend", ` 107 <span class="inline-flex items-center gap-2 rounded-full bg-indigo-500/15 pl-1 pr-1 py-1 text-sm text-indigo-200 ring-1 ring-indigo-500/30" 108 data-candidate-id="${id}" 109 data-candidate-name="${escapeAttr(name)}"> 110 ${thumbHtml} 111 <span class="truncate max-w-[12rem]">${escapeHtml(name)}</span> 112 <button type="button" 113 class="rounded-full p-1 text-indigo-300 hover:text-white hover:bg-indigo-500/30" 114 data-action="click->
114seatup-candidate-picker#remove" 115 aria-label="Remove ${escapeAttr(name)}"> 116 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="h-4 w-4"> 117 <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> 118 </svg> 119 </button> 120 <input type="hidden" name="${this.fieldNameValue}" value="${id}"> 121 </span>`) 122 } 123 124 updateCount() { 125 const count = this.chipCount() 126 if (this.hasCountTarget) this.countTarget.textContent = `${count}/${this.maxValue}` 127 if (!this.hasHintTarget) return 128 129 if (count < this.minValue) { 130 this.hintTarget.textContent = `Pick at least ${this.minValue} games â players will see all of them.` 131 this.hintTarget.className = "text-xs text-gray-500" 132 } else { 133 this.hintTarget.textContent = `Players will see all ${count} options when they join.` 134 this.hintTarget.className = "text-xs text-emerald-400" 135 } 136 } 137 138 flashHint(message) { 139 if (!this.hasHintTarget) return 140 this.hintTarget.textContent = message 141 this.hintTarget.className = "text-xs text-amber-400" 142 clearTimeout(this._flashTimer) 143 this._flashTimer = setTimeout(() => this.updateCount(), 2500) 144 } 145}
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.