Composables

useCommandPalette

Composable singleton della command palette - registro dei comandi, ricerca, esecuzione e fonti di risultati asincrone.

Panoramica

useCommandPalette espone lo state e le API della command palette. Lo state è un singleton a livello di modulo: tutte le chiamate condividono isOpen, searchQuery, il registro dei comandi, le fonti di risultati e il binding Ctrl+K (Cmd+K su macOS).

Il binding viene installato alla prima chiamata come scorciatoia di useShortcuts, in uno scope separato che sopravvive ai componenti: funziona anche con il focus in un campo di testo e rispetta la configurazione globale delle scorciatoie (con enabled: false si disattiva anche Ctrl+K).

I comandi passati in initialCommands e le fonti registrate con registerSource vengono rimossi automaticamente quando lo scope del chiamante viene distrutto (tipicamente allo smontaggio del componente).

Firma

function useCommandPalette(
  initialCommands?: Command[],
  options?: UseCommandPaletteOptions
): UseCommandPaletteReturn

Parametri

ParametroTipoDefaultDescrizione
initialCommandsCommand[][]Comandi da registrare per lo scope corrente. Vengono rimossi al dispose
optionsUseCommandPaletteOptions{}Callback di errore e tempi delle fonti asincrone

Command

interface Command {
  id: string
  label: string
  description?: string
  category?: string
  icon?: string
  keywords?: string[]
  shortcut?: string
  handler: (context?: CommandHandlerContext) => void | Promise<void>
  enabled?: boolean
}

interface CommandHandlerContext {
  signal: AbortSignal
}
CampoTipoDescrizione
idstringIdentificatore univoco. Una seconda registrazione con lo stesso id viene ignorata (avviso in console in sviluppo)
labelstringEtichetta principale della riga
descriptionstringTesto secondario sotto l'etichetta
categorystringGruppo del comando: header di sezione senza query, testo a destra della riga durante la ricerca
iconstringClasse di un icon font (pi pi-home) oppure un glifo testuale (emoji o carattere)
keywordsstring[]Parole non visualizzate che partecipano alla ricerca
shortcutstringCombinazione mostrata come badge nella riga (es. Alt+H). Solo informativa: non registra la scorciatoia
handler(context?) => void | Promise<void>Eseguito alla scelta. Riceve { signal }, abortito se la palette si chiude durante l'esecuzione: si può passare a fetch
enabledbooleanSe false, il comando è ignorato senza essere rimosso: non compare tra i risultati, con o senza query, ma resta in allCommands

Un comando senza id, label o handler validi viene scartato.

UseCommandPaletteOptions

OpzioneTipoDefaultDescrizione
onCommandError(error, command) => void—Invocata quando l'handler di un comando lancia o rifiuta. Senza, l'errore va in console.error
onRenderError(error) => void—Invocata quando CommandPalette intercetta un errore di rendering di un comando malformato, per inoltrarlo al monitoring
sourceTimeoutnumber500Millisecondi entro cui una fonte deve rispondere
sourceDebouncenumber150Millisecondi dall'ultima modifica della query prima di interrogare le fonti

Le opzioni sono condivise come il resto dello state: vale quella dell'ultima chiamata che la passa, e il valore precedente viene ripristinato quando il suo scope viene distrutto.

Return

ProprietàTipoDescrizione
isOpenRef<boolean>Stato di apertura della palette
searchQueryRef<string>Testo della ricerca, collegato all'input di CommandPalette
filteredCommandsComputedRef<Command[]>Comandi che corrispondono alla query, seguiti dai risultati delle fonti
sourceResultsComputedRef<Command[]>Solo i risultati delle fonti per la query corrente, raggruppati per category
isLoadingSourcesComputedRef<boolean>true mentre ci sono fonti interrogate (o in attesa del debounce) che non hanno risposto
allCommandsReadonly<Ref<Command[]>>Registro completo dei comandi
isExecutingReadonly<Ref<boolean>>true mentre l'handler di un comando è in esecuzione
lastErrorRef<unknown>Ultimo errore di esecuzione, null se nessuno
toggle() => voidApre o chiude la palette
open() => voidApre la palette, azzerando query ed errore
close() => voidChiude la palette, azzera la query e abortisce ricerche e handler in corso
clearSearch() => voidSvuota la query senza chiudere
executeCommand(command: Command) => Promise<void>Esegue l'handler del comando
addCommand(command: Command) => voidAggiunge un comando. Ignorato se l'id esiste già
removeCommand(id: string) => voidRimuove il comando con quell'id
registerSource(source: CommandSource) => voidAggiunge una fonte di risultati. Un id già presente viene sostituito
unregisterSource(id: string) => voidToglie la fonte con quell'id, con i suoi risultati

Il return type è UseCommandPaletteReturn.

Ricerca

filteredCommands confronta la query, senza spazi iniziali e finali e senza distinzione tra maiuscole e minuscole, con:

  • command.label
  • command.description
  • ogni elemento di command.keywords

Il match è per sottostringa (includes). Con la query vuota filteredCommands contiene tutti i comandi abilitati del registro: quelli con enabled: false sono sempre esclusi. Fino alla 1.2.0 lo erano solo durante una ricerca, e all'apertura della palette comparivano ed erano eseguibili.

Se la query comincia con il prefisso di una fonte (vedi Prefisso), i comandi registrati vengono nascosti.

Esecuzione

executeCommand è invocato da CommandPalette con Enter o con il click, ma si può chiamare anche dal codice:

  • mentre un comando è in esecuzione (isExecuting) le altre invocazioni sono ignorate, così un doppio Enter non lo lancia due volte
  • l'handler riceve { signal }, abortito da close(): un handler che fa I/O può interrompersi se l'utente chiude la palette
  • se l'handler termina senza errori, la palette si chiude
  • se l'handler lancia o rifiuta, la palette resta aperta: l'errore va in lastError, CommandPalette mostra un messaggio, e l'errore è passato a onCommandError (o a console.error se non c'è). Una nuova ricerca o la riapertura azzerano lastError

Fonti di risultati asincrone

Una fonte fornisce comandi calcolati sulla query, per ciò che non si può elencare in anticipo: articoli, clienti, documenti. Ha la forma di MFEPaletteSource di @pzeta/mfe-contracts, con Command (e quindi handler) al posto di MFECommand.

interface CommandSource {
  id: string
  prefix?: string
  search(query: string, signal: AbortSignal): Promise<Command[]>
}
CampoDescrizione
idIdentificatore univoco. Una registerSource con un id già registrato sostituisce la fonte, nella stessa posizione
prefixSe presente, la fonte risponde solo alle query che cominciano così e le riceve senza prefisso
searchRestituisce i comandi per la query. signal viene abortito quando la query è superata o scade il tempo

Ciclo di una ricerca

  1. L'utente scrive: i comandi registrati che corrispondono compaiono subito, perché restano sincroni
  2. Le fonti da interrogare passano «in attesa» (isLoadingSources) e CommandPalette mostra «Ricerca in corso...»
  3. Trascorso sourceDebounce dall'ultimo tasto, ogni fonte riceve search(query, signal)
  4. I risultati di una fonte compaiono appena arrivano, senza attendere le altre, dopo i comandi registrati e raggruppati per category (prima quelli senza categoria)

Le fonti senza prefisso non vengono interrogate con la query vuota: la palette mostra già tutti i comandi.

Annullamento e scadenza

  • Query superata: a ogni modifica della query e alla chiusura della palette, il signal della ricerca precedente viene abortito e i suoi risultati, anche se arrivano dopo, vengono ignorati. Passare signal a fetch interrompe la richiesta HTTP
  • Scadenza: una fonte che non risponde entro sourceTimeout riceve l'abort e non contribuisce più a quella query
  • Errori: una fonte che rifiuta o lancia non contribuisce (avviso in console in sviluppo). In nessuno di questi casi vengono bloccate le altre fonti o i comandi registrati

I risultati vengono validati: un esito che non è un array, un comando malformato o con enabled: false sono scartati. Un risultato con lo stesso id di un comando già presente nella lista viene scartato, perché duplicherebbe la riga.

Prefisso

Con prefix: '@' la fonte risponde a @rossi e riceve rossi (spazi dopo il prefisso compresi: @ rossi dà lo stesso risultato). Il solo prefisso (@) interroga la fonte con la query vuota, per esempio per mostrare elementi recenti.

Con un prefisso attivo l'utente ha scelto dove cercare: vengono interrogate solo le fonti con quel prefisso, e i comandi registrati e le fonti senza prefisso sono nascosti. Se due fonti dichiarano prefissi annidati ($ e $$), vince il più lungo.

Esempio

<script setup lang="ts">
import { useCommandPalette } from '@pzeta/vue-scorciatoie'

const { registerSource } = useCommandPalette([], { sourceTimeout: 800 })

// Risponde a ogni query non vuota
registerSource({
  id: 'knowledge',
  search: async (query, signal) => {
    const res = await fetch(`/api/articoli?q=${encodeURIComponent(query)}`, { signal })
    const articoli: { id: number; titolo: string }[] = await res.json()
    return articoli.map((a) => ({
      id: `articolo-${a.id}`,
      label: a.titolo,
      category: 'Knowledge',
      icon: 'pi pi-book',
      handler: () => router.push(`/knowledge/${a.id}`),
    }))
  },
})

// Risponde solo a "@query"
registerSource({
  id: 'clienti',
  prefix: '@',
  search: async (query, signal) => {
    const res = await fetch(`/api/clienti?q=${encodeURIComponent(query)}`, { signal })
    const clienti: { id: number; nome: string }[] = await res.json()
    return clienti.map((c) => ({
      id: `cliente-${c.id}`,
      label: c.nome,
      category: 'Clienti',
      handler: () => router.push(`/clienti/${c.id}`),
    }))
  },
})
</script>

Gli id dei risultati devono essere unici in tutta la palette: conviene prefissarli con la fonte, come sopra.

Esempio: registrazione di base

<script setup lang="ts">
import { CommandPalette, useCommandPalette } from '@pzeta/vue-scorciatoie'

useCommandPalette([
  {
    id: 'nav-dashboard',
    label: 'Dashboard',
    icon: 'pi pi-home',
    shortcut: 'Alt+H',
    handler: () => router.push('/'),
  },
  {
    id: 'nav-anagrafica',
    label: 'Anagrafiche',
    category: 'Navigazione',
    keywords: ['clienti', 'fornitori'],
    handler: () => router.push('/anagrafica'),
  },
])
</script>

<template>
  <CommandPalette />
</template>

Esempio: errori degli handler

import { useCommandPalette } from '@pzeta/vue-scorciatoie'

useCommandPalette(
  [
    {
      id: 'esporta',
      label: 'Esporta ordini',
      handler: async (context) => {
        const res = await fetch('/api/ordini/export', { method: 'POST', signal: context?.signal })
        if (!res.ok) throw new Error(`Export non riuscito: ${res.status}`)
      },
    },
  ],
  {
    // La palette resta aperta con il messaggio; qui si notifica e si traccia
    onCommandError: (error, command) => toast.error(`${command.label}: ${String(error)}`),
  }
)

Esempio: apertura programmatica

import { watch } from 'vue'
import { useCommandPalette } from '@pzeta/vue-scorciatoie'

const { open, toggle, isOpen } = useCommandPalette()

// Da un pulsante o da un evento esterno
open()
toggle()

watch(isOpen, (aperta) => {
  console.log(aperta ? 'palette aperta' : 'palette chiusa')
})

Esempio: comandi dinamici

const { addCommand, removeCommand } = useCommandPalette()

watch(permessi, (perms) => {
  if (perms.includes('admin.users')) {
    addCommand({
      id: 'admin-users',
      label: 'Gestione utenti',
      category: 'Admin',
      handler: () => router.push('/admin/users'),
    })
  } else {
    removeCommand('admin-users')
  }
})

I comandi aggiunti con addCommand vengono rimossi anch'essi al dispose dello scope che ha chiamato useCommandPalette.

Test

Lo state è a livello di modulo e le funzioni di reset usate dai test della libreria non sono esportate dal pacchetto. Nei test di un'applicazione si ottiene uno stato pulito smontando i componenti (comandi e fonti vengono rimossi al dispose dello scope) e chiudendo la palette con close().

Note

  • open e toggle azzerano sempre la query, e la chiusura la svuota: la ricerca non sopravvive alla palette
  • searchQuery è un Ref scrivibile: si può impostare dall'esterno a palette aperta, e la ricerca sulle fonti riparte
  • In un microfrontend non si chiama useCommandPalette, che legherebbe un secondo Ctrl+K in conflitto con la shell: il modulo registra comandi e fonti con context.palette di @pzeta/mfe-contracts (dalla 3.1.0), e la shell, proprietaria della palette, li porta in queste API