PageSourceSearch

https://cdn.boardandbean.co.uk/assets/controllers/seatup_candidate_picker_controller-6e257e09.js

js boardandbean.co.uk collected 2026-10-08 09:08:08 UTC 5,486 bytes, 145 lines download raw bytes

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.