++ scelta di territori e proclamatori con ricerca

Al posto delle tendine (Assegna, Registro, editor delle piantine) un
campo in cui si scrive e compaiono le proposte più vicine: uguale, inizia
con, contiene, errore di battitura ("forse cercavi"); parole in qualsiasi
ordine, senza badare a maiuscole e accenti. Campo vuoto: la tendina
completa. Fatto anche per il telefono (16px, voci da 44px, scelta al tocco).

- Logica pura in resources/js/scelta.js con test node (tests/js).
- Opzioni dal server: Territorio/Proclamatore::opzioneScelta(); i
  territori già assegnati si trovano ("già assegnato a …") ma non si
  scelgono. Nomi cifrati ordinati e cercati decifrati, mai in SQL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Francesco Picone
2026-10-19 15:35:56 +02:00
parent 2873424c3c
commit fda428f242
16 changed files with 814 additions and 76 deletions

View File

@@ -0,0 +1,96 @@
{{--
Campo di scelta con ricerca, al posto di <select> per territori e
proclamatori. Si scrive e sotto compaiono le proposte più vicine (uguale,
inizia con, contiene, errore di battitura); con il campo vuoto o appena
aperto c'è la tendina completa. Logica in resources/js/scelta.js, provata
da tests/js/scelta.test.mjs; comportamento in resources/js/app.js.
id: id del campo (per la <label for>)
opzioni: array di opzioni, vedi Territorio::opzioneScelta() e
Proclamatore::opzioneScelta(); per le voci fisse in cima
['valore' => '', 'etichetta' => '…', 'speciale' => true]
segnaposto: testo nel campo vuoto
disabilitato: true per bloccare il campo (territorio già preselezionato)
vuoto: il valore di "nessuna scelta": '' (default) per le proprietà
stringa, null per quelle ?int
Il valore va a Livewire come con un <select>: wire:model sul componente.
Si ridisegna da capo quando cambiano le opzioni (wire:key con l'impronta),
perché x-data le legge una volta sola.
--}}
@props(['id', 'opzioni' => [], 'segnaposto' => 'Scrivi per cercare…', 'disabilitato' => false, 'vuoto' => ''])
@php($impronta = substr(md5(json_encode($opzioni)), 0, 12))
<div x-data="sceltaRicerca(@js(array_values($opzioni)), @js($vuoto))"
x-modelable="valore"
wire:key="scelta-{{ $id }}-{{ $impronta }}"
{{ $attributes->whereStartsWith('wire:model') }}
{{ $attributes->whereDoesntStartWith('wire:model')->class('tm-scelta') }}
x-on:click.outside="aperto && chiudi()"
x-on:keydown.escape="if (aperto) { $event.stopPropagation(); chiudi() }">
<div class="tm-scelta-campo">
<input x-ref="campo"
id="{{ $id }}"
type="text"
role="combobox"
aria-autocomplete="list"
aria-controls="{{ $id }}-elenco"
x-bind:aria-expanded="aperto ? 'true' : 'false'"
x-bind:aria-activedescendant="aperto && risultati.length ? '{{ $id }}-voce-' + evidenziato : null"
autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"
inputmode="search" enterkeyhint="done"
placeholder="{{ $segnaposto }}"
@disabled($disabilitato)
x-model="testo"
x-on:focus="$el.select(); apri()"
x-on:click="apri()"
x-on:input="scrivendo()"
x-on:keydown.arrow-down.prevent="sposta(1)"
x-on:keydown.arrow-up.prevent="sposta(-1)"
x-on:keydown.enter.prevent="scegliEvidenziato()"
x-on:keydown.tab="aperto && chiudi()"
class="tm-scelta-input">
@unless($disabilitato)
<button type="button" class="tm-scelta-pulisci" x-show="testo !== ''" x-cloak
x-on:pointerdown.prevent x-on:click="svuota()"
aria-label="Svuota il campo" tabindex="-1">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
<button type="button" class="tm-scelta-apri"
x-on:pointerdown.prevent x-on:click="alterna()"
x-bind:aria-label="aperto ? 'Chiudi l\'elenco' : 'Apri l\'elenco'" tabindex="-1">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true" x-bind:class="aperto && 'tm-scelta-giu'"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</button>
@endunless
</div>
<ul x-ref="elenco" id="{{ $id }}-elenco" role="listbox" class="tm-scelta-elenco" x-show="aperto" x-cloak>
<template x-for="(opzione, i) in risultati" :key="String(opzione.valore) + '|' + i">
<li x-bind:id="'{{ $id }}-voce-' + i"
role="option"
x-bind:aria-selected="String(opzione.valore) === String(valore ?? '') ? 'true' : 'false'"
x-bind:aria-disabled="opzione.motivo ? 'true' : null"
x-bind:data-evidenziato="i === evidenziato ? '1' : null"
class="tm-scelta-voce"
x-bind:class="{
'tm-scelta-voce-attiva': i === evidenziato,
'tm-scelta-voce-scelta': String(opzione.valore) === String(valore ?? ''),
'tm-scelta-voce-spenta': !! opzione.motivo,
'tm-scelta-voce-speciale': !! opzione.speciale,
}"
x-on:pointerdown.prevent
x-on:click="scegli(opzione, true)"
x-on:pointermove="evidenziato = i">
<span class="tm-scelta-etichetta" x-text="opzione.etichetta"></span>
<span class="tm-scelta-dettaglio" x-show="opzione.dettaglio" x-text="opzione.dettaglio"></span>
<span class="tm-scelta-motivo" x-show="opzione.motivo" x-text="opzione.motivo"></span>
<span class="tm-scelta-forse" x-show="opzione.forse">forse cercavi</span>
</li>
</template>
<li class="tm-scelta-vuoto" x-show="risultati.length === 0" role="presentation">
Nessun risultato per «<span x-text="testo"></span>»
</li>
</ul>
</div>