Ajoute TicketPicker, popup modale réutilisable de sélection de ticket, socle du chemin critique du sprint UI rework (#17 et #19 en dépendent). - TicketPicker.tsx : popup de recherche/sélection réutilisable - useTicketSearch.ts : hook de recherche/filtrage extrait et partagé - TicketFacetsBar.tsx : barre de facettes extraite de TicketsPanel pour réutilisation (listing principal + picker) - shared/ui/zIndex.ts : échelle z-index centralisée - TicketsPanel : consomme la barre de facettes extraite Tests : TicketPicker.test.tsx 8/8, suite tickets 37/37, tsc --noEmit clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
127 lines
4.1 KiB
TypeScript
127 lines
4.1 KiB
TypeScript
/**
|
|
* Reusable ticket filter/search bar (UI rework #18): a free-text search input
|
|
* plus the multi-select status/priority checkbox facets. Extracted from
|
|
* {@link TicketsPanel} so both the main listing and the {@link TicketPicker}
|
|
* popup share one implementation and stay in sync.
|
|
*
|
|
* Purely controlled/presentational — it holds no state and knows nothing about
|
|
* the gateway; the owner (a view-model or {@link useTicketSearch}) drives every
|
|
* value and toggle. Facet semantics (ticket #12): OR within a facet, AND across;
|
|
* an empty set ⇒ that facet is unconstrained.
|
|
*
|
|
* The assignee filter stays with the main listing (it needs the project's agent
|
|
* roster and is out of the picker's scope), so it is intentionally not here.
|
|
*/
|
|
|
|
import type { TicketPriority, TicketStatus } from "@/domain";
|
|
import { Button, Input } from "@/shared";
|
|
import {
|
|
PriorityBadge,
|
|
StatusBadge,
|
|
TICKET_PRIORITIES,
|
|
TICKET_STATUSES,
|
|
priorityLabel,
|
|
statusLabel,
|
|
} from "./ticketMeta";
|
|
|
|
export interface TicketFacetsBarProps {
|
|
/** Current free-text search value (controlled). */
|
|
text: string;
|
|
/** Emitted on every keystroke with the raw input value. */
|
|
onTextChange: (text: string) => void;
|
|
/** Currently-selected status facet values. */
|
|
statuses: TicketStatus[];
|
|
/** Currently-selected priority facet values. */
|
|
priorities: TicketPriority[];
|
|
onToggleStatus: (status: TicketStatus) => void;
|
|
onTogglePriority: (priority: TicketPriority) => void;
|
|
/**
|
|
* Clears both facets. When provided, a « Effacer » button appears while any
|
|
* facet is active. Omit to hide the affordance (the caller clears elsewhere).
|
|
*/
|
|
onClearFacets?: () => void;
|
|
/** Placeholder for the search input. */
|
|
searchPlaceholder?: string;
|
|
}
|
|
|
|
export function TicketFacetsBar({
|
|
text,
|
|
onTextChange,
|
|
statuses,
|
|
priorities,
|
|
onToggleStatus,
|
|
onTogglePriority,
|
|
onClearFacets,
|
|
searchPlaceholder = "Search title/description…",
|
|
}: TicketFacetsBarProps) {
|
|
const hasFacets = statuses.length > 0 || priorities.length > 0;
|
|
return (
|
|
<div className="flex flex-col gap-2">
|
|
<Input
|
|
aria-label="search tickets"
|
|
placeholder={searchPlaceholder}
|
|
value={text}
|
|
onChange={(e) => onTextChange(e.target.value)}
|
|
/>
|
|
{/* Multi-select facets (ticket #12): OR within a facet, AND across; no
|
|
box ticked ⇒ that facet is unconstrained. */}
|
|
<fieldset
|
|
aria-label="filter by status"
|
|
className="flex flex-wrap items-center gap-x-3 gap-y-1"
|
|
>
|
|
<legend className="mr-1 text-xs font-medium text-muted">Statut</legend>
|
|
{TICKET_STATUSES.map((s) => (
|
|
<label
|
|
key={s}
|
|
className="flex cursor-pointer items-center gap-1.5 text-xs"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
aria-label={`filter status ${statusLabel(s)}`}
|
|
className="accent-primary"
|
|
checked={statuses.includes(s)}
|
|
onChange={() => onToggleStatus(s)}
|
|
/>
|
|
<StatusBadge status={s} />
|
|
</label>
|
|
))}
|
|
</fieldset>
|
|
<fieldset
|
|
aria-label="filter by priority"
|
|
className="flex flex-wrap items-center gap-x-3 gap-y-1"
|
|
>
|
|
<legend className="mr-1 text-xs font-medium text-muted">
|
|
Priorité
|
|
</legend>
|
|
{TICKET_PRIORITIES.map((p) => (
|
|
<label
|
|
key={p}
|
|
className="flex cursor-pointer items-center gap-1.5 text-xs"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
aria-label={`filter priority ${priorityLabel(p)}`}
|
|
className="accent-primary"
|
|
checked={priorities.includes(p)}
|
|
onChange={() => onTogglePriority(p)}
|
|
/>
|
|
<PriorityBadge priority={p} />
|
|
</label>
|
|
))}
|
|
</fieldset>
|
|
{onClearFacets && hasFacets && (
|
|
<div>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
aria-label="clear filters"
|
|
onClick={onClearFacets}
|
|
>
|
|
Effacer
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|