1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
require "json"

require "pro_tacts/admin/phlex"

require "pro_tacts/admin/icons"

module ProTacts
  module Admin
    # The search's input and the results under it, fetched from
    # GET /search/results as it is typed: the one search the header's
    # dialog holds on a wide screen (SearchDialog) and the search page
    # is on a phone (SearchPage). Why the results come from the server
    # rather than a filter in the page is
    # docs/plans/2026-09-30-live-search.md.
    #
    # Alpine does the fetch, waited out until typing pauses and
    # cancelled by the next one, so an answer to an old query never
    # lands over a newer; and the keys, ↑/↓ moving a highlight through
    # the result links and Enter following it, the first by default —
    # while there is a query, the recently updated list shown before
    # one being no answer for Enter to pick.
    # Enter with no result to follow submits the form the field sits
    # in, where there is one — the page's, which answers without
    # script too.
    #
    # `query` and `results` are what the field renders holding: the
    # page's own ?q= and its server-rendered answer. `history` keeps
    # the page's address on the query as it changes, so going back to
    # it from a result lands on the same answer; the dialog has no
    # address of its own to keep.
    #
    # A blank query fetches the recently updated list, so the field
    # never stands empty: the page renders it in, and the dialog
    # fetches it each time it opens blank, the book having maybe
    # changed since the screen under it loaded.
    class SearchField < Phlex::HTML
      # @rbs @query: String
      # @rbs @results: Phlex::HTML?
      # @rbs @history: bool

      # The popover the field sits in, where it sits in one, fetching
      # as it opens.
      OPEN = "mark(); $el.closest('[popover]')?.addEventListener('toggle', " \
             "event => { if (event.newState === 'open') opened() })" #: String
      private_constant :OPEN

      #: (?query: String, ?results: Phlex::HTML?, ?history: bool) -> void
      def initialize(query: "", results: nil, history: false)
        @query = query
        @results = results
        @history = history
      end

      def view_template
        div(class: "search-field", x_data: state, x_init: OPEN) do
          div(class: "filter") do
            input(type: "search", name: "q", value: @query, placeholder: "Search contacts",
                  aria_label: "Search contacts", autofocus: true, x_model: "q", x_ref: "input",
                  "@input.debounce.150ms": "search()",
                  "@keydown.down.prevent": "move(1)",
                  "@keydown.up.prevent": "move(-1)",
                  "@keydown.enter.prevent": "follow()")
            button(type: "button", class: "icon-button search-clear", data_size: "sm",
                   aria_label: "Clear search", "@click": "q = ''; search(); $refs.input.focus()") do
              render Icon.new(:x)
            end
          end
          div(class: "search-results", x_ref: "results") { render @results if @results }
        end
      end

      private

      # The field's state and the fetch. A failed fetch throws rather
      # than showing a stale or empty list: Alpine surfaces it in the
      # console, and the server has already reported whatever made it
      # fail.
      #: () -> String
      def state
        "{ q: #{JSON.generate(@query)}, history: #{@history}, active: 0, request: null, " \
          "async search() { this.request?.abort(); this.active = 0; const q = this.q.trim(); " \
          "if (this.history) history.replaceState(null, '', q === '' ? '/search' : '/search?q=' + encodeURIComponent(q)); " \
          "const request = this.request = new AbortController(); let html; " \
          "try { const response = await fetch('/search/results?q=' + encodeURIComponent(q), { signal: request.signal }); " \
          "if (!response.ok) throw new Error(`The search failed: the server answered ${response.status}.`); " \
          "html = await response.text() } " \
          "catch (error) { if (error.name === 'AbortError') return; throw error } " \
          "if (request !== this.request) return; " \
          "this.$refs.results.innerHTML = html; this.mark() }, " \
          "get links() { return [...this.$refs.results.querySelectorAll('a')] }, " \
          "move(step) { const n = this.links.length; if (n === 0) return; " \
          "this.active = (this.active + step + n) % n; this.mark() }, " \
          "mark() { const on = this.q.trim() !== ''; " \
          "this.links.forEach((link, i) => link.toggleAttribute('data-active', on && i === this.active)); " \
          "this.links[this.active]?.scrollIntoView({ block: 'nearest' }) }, " \
          "opened() { if (this.q.trim() === '') this.search() }, " \
          "follow() { const link = this.q.trim() === '' ? null : this.links[this.active]; " \
          "if (link) link.click(); else this.$el.closest('form')?.requestSubmit() } }"
      end
    end
  end
end