chore: remove orphaned list filter shells after B128 omnibox migration
Delete CampaignSearchForm, CampaignFilterChips, collapsible/mobile filter components and useCampaignFilterValues. Slim useCampaignListFilterNavigation to navigate-only; drop unused territory sort serializers.
This commit is contained in:
parent
f59d4a6736
commit
cf78c61757
8 changed files with 7 additions and 493 deletions
|
|
@ -1,66 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { ChevronDownIcon, FilterIcon, XIcon } from 'lucide-react'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* The collapsible filter panel of the list filter shells (P3-F — extracted
|
||||
* from ActivityFilters/SupporterFilters, ~30 near-verbatim lines each):
|
||||
* always-open on desktop, a disclosure card with 44 px targets on mobile,
|
||||
* and the "Limpar filtros" footer when any filter is active.
|
||||
*/
|
||||
export const CampaignCollapsibleFilterPanel = ({
|
||||
panelId,
|
||||
hasFilters,
|
||||
onClear,
|
||||
children,
|
||||
}: {
|
||||
panelId: string
|
||||
hasFilters: boolean
|
||||
onClear: () => void
|
||||
children: ReactNode
|
||||
}) => {
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="rounded-[6px] border bg-card">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="min-h-11 w-full justify-start rounded-[6px] px-3 lg:hidden"
|
||||
aria-expanded={mobileFiltersOpen}
|
||||
aria-controls={panelId}
|
||||
onClick={() => setMobileFiltersOpen((open) => !open)}
|
||||
>
|
||||
<FilterIcon data-icon="inline-start" aria-hidden="true" />
|
||||
<span>Filtros</span>
|
||||
<ChevronDownIcon data-icon="inline-end" className="ml-auto" aria-hidden="true" />
|
||||
</Button>
|
||||
<div
|
||||
id={panelId}
|
||||
className={cn(
|
||||
mobileFiltersOpen ? 'block' : 'hidden',
|
||||
'border-t p-4 lg:block lg:border-t-0',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
{hasFilters ? (
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="min-h-11 rounded-[6px]"
|
||||
onClick={onClear}
|
||||
>
|
||||
<XIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Limpar filtros
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState, type MouseEvent } from 'react'
|
||||
|
||||
import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export type CampaignFilterChip = {
|
||||
href: string
|
||||
label: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Status/kind chips that navigate via URL. Optimistic on the chip (the tapped
|
||||
* one activates at once), honest pending on the results (shared boundary
|
||||
* transition dims them) — "Feel the action".
|
||||
*/
|
||||
export const CampaignFilterChips = ({
|
||||
ariaLabel,
|
||||
chips,
|
||||
}: {
|
||||
ariaLabel: string
|
||||
chips: CampaignFilterChip[]
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const { isPending, startTransition } = useCampaignListTransition()
|
||||
const [optimisticHref, setOptimisticHref] = useState<string | null>(null)
|
||||
|
||||
const activeHref =
|
||||
isPending && optimisticHref !== null
|
||||
? optimisticHref
|
||||
: (chips.find((chip) => chip.active)?.href ?? null)
|
||||
|
||||
const interceptNavigation = (event: MouseEvent<HTMLAnchorElement>, href: string) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
setOptimisticHref(href)
|
||||
startTransition(() => {
|
||||
router.push(href)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<nav aria-label={ariaLabel} aria-busy={isPending} className="flex flex-wrap gap-2">
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{isPending ? 'Atualizando resultados…' : ''}
|
||||
</p>
|
||||
{chips.map((chip) => (
|
||||
<Button
|
||||
key={chip.href}
|
||||
asChild
|
||||
variant={chip.href === activeHref ? 'default' : 'outline'}
|
||||
className="min-h-11"
|
||||
>
|
||||
<Link href={chip.href} onClick={(event) => interceptNavigation(event, chip.href)}>
|
||||
{chip.label}
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
|
||||
import { orderFilterOptionsSelectedFirst } from '@/lib/listFilterOptions'
|
||||
|
||||
type CampaignMobileMultiFilterOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export const CampaignMobileMultiFilterField = ({
|
||||
id,
|
||||
label,
|
||||
emptyLabel,
|
||||
options,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
id: string
|
||||
label: string
|
||||
emptyLabel: string
|
||||
options: CampaignMobileMultiFilterOption[]
|
||||
selected: readonly string[]
|
||||
onToggle: (value: string) => void
|
||||
}) => {
|
||||
// No snapshot: the NativeSelect closes on every choice, so the next open
|
||||
// already wants the just-toggled value at the top.
|
||||
const { ordered } = orderFilterOptionsSelectedFirst(options, selected)
|
||||
const selectedSet = new Set(selected)
|
||||
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
<NativeSelect
|
||||
id={id}
|
||||
value=""
|
||||
onChange={(event) => {
|
||||
if (event.target.value) onToggle(event.target.value)
|
||||
}}
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
<NativeSelectOption value="">
|
||||
{selected.length ? `${selected.length} selecionado(s) — alterar` : emptyLabel}
|
||||
</NativeSelectOption>
|
||||
{ordered.map((option) => (
|
||||
<NativeSelectOption key={option.value} value={option.value}>
|
||||
{selectedSet.has(option.value) ? `✓ ${option.label}` : option.label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
|
||||
import { SEARCH_DEBOUNCE_MS } from '@/components/campaign/shared/useCampaignListFilterNavigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
/**
|
||||
* Name-search form for simple list pages (lideranças, organizações,
|
||||
* dobradinhas): keeps the input value locally (optimistic) and navigates in a
|
||||
* transition so the results region dims instead of a dead full-page wait.
|
||||
*/
|
||||
export const CampaignSearchForm = ({
|
||||
ariaLabel,
|
||||
placeholder,
|
||||
initialQuery,
|
||||
basePath,
|
||||
filterParams = '',
|
||||
}: {
|
||||
ariaLabel: string
|
||||
placeholder: string
|
||||
initialQuery: string
|
||||
/** List route; the query lands as `?q=` (serializable — no function props across the RSC boundary). */
|
||||
basePath: string
|
||||
/**
|
||||
* The ACTIVE filters' canonical serialization (minus `q`/`page`), produced
|
||||
* server-side by the domain's own serializer (P3-F): submitting the search
|
||||
* must not silently drop the other filters, which the hand-rolled `?q=`
|
||||
* used to do.
|
||||
*/
|
||||
filterParams?: string
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const { isPending, startTransition } = useCampaignListTransition()
|
||||
const [query, setQuery] = useState(initialQuery)
|
||||
|
||||
const hrefForQuery = (value: string) => {
|
||||
const params = new URLSearchParams(filterParams)
|
||||
if (value) params.set('q', value)
|
||||
const queryString = params.toString()
|
||||
return queryString ? `${basePath}?${queryString}` : basePath
|
||||
}
|
||||
|
||||
const navigateIfChanged = (value: string) => {
|
||||
// No-op guard (same policy as `useCampaignListFilterNavigation`): the URL
|
||||
// already carries exactly this query — don't pay an RSC round-trip.
|
||||
if (value === initialQuery) return
|
||||
startTransition(() => {
|
||||
router.replace(hrefForQuery(value), { scroll: false })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Search-as-you-type (P3-F unified idiom — this form used to navigate only
|
||||
* on explicit submit): the debounce commits what the user settled on; the
|
||||
* Buscar button remains as the immediate path for keyboard users in a hurry.
|
||||
*/
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
},
|
||||
[],
|
||||
)
|
||||
const onQueryChange = (value: string) => {
|
||||
setQuery(value)
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => {
|
||||
navigateIfChanged(value)
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
role="search"
|
||||
className="flex gap-2 transition-opacity data-[pending=true]:opacity-70"
|
||||
data-pending={isPending || undefined}
|
||||
aria-busy={isPending}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
navigateIfChanged(query)
|
||||
}}
|
||||
>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{isPending ? 'Atualizando resultados…' : ''}
|
||||
</p>
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
aria-label={ariaLabel}
|
||||
className="min-h-11 w-full max-w-md rounded-[6px] border border-input bg-background px-3 text-sm outline-none focus-visible:border-primary focus-visible:ring-[3px] focus-visible:ring-primary/30"
|
||||
/>
|
||||
<Button type="submit" variant="secondary" className="min-h-11" disabled={isPending}>
|
||||
Buscar
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
|
||||
|
||||
/**
|
||||
* The filter-values state machine of the list filter shells (P3-F — extracted
|
||||
* from ActivityFilters/SupporterFilters, where it was spelled twice WITHOUT
|
||||
* the no-op guard): local mirror for instant feedback, committed-href
|
||||
* deduplication so re-selecting the already-active value never pays an RSC
|
||||
* round-trip, and the pending transition the results region dims with.
|
||||
*
|
||||
* `toHref` comes from the domain's own serializer — the hook never decides
|
||||
* what is canonical.
|
||||
*/
|
||||
export const useCampaignFilterValues = <Values>({
|
||||
committedValues,
|
||||
toHref,
|
||||
}: {
|
||||
/** Values parsed from the URL on the server — fresh on every render. */
|
||||
committedValues: Values
|
||||
/** Domain serializer; must canonicalize and pin the page reset it wants. */
|
||||
toHref: (values: Values) => string
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const { isPending, startTransition } = useCampaignListTransition()
|
||||
const valuesRef = useRef(committedValues)
|
||||
const [values, setValues] = useState(committedValues)
|
||||
|
||||
// The committed URL's href, written in an effect: an interrupted transition
|
||||
// render is abandoned before commit, and a later navigation must not be
|
||||
// deduplicated against state that never landed (same lesson as
|
||||
// `useCampaignListFilterNavigation`'s stateRef).
|
||||
const committedHrefRef = useRef(toHref(committedValues))
|
||||
useEffect(() => {
|
||||
committedHrefRef.current = toHref(committedValues)
|
||||
})
|
||||
|
||||
const replaceValues = (next: Values) => {
|
||||
valuesRef.current = next
|
||||
setValues(next)
|
||||
const href = toHref(next)
|
||||
if (href === committedHrefRef.current) return
|
||||
startTransition(() => {
|
||||
router.replace(href, { scroll: false })
|
||||
})
|
||||
}
|
||||
|
||||
/** Functional update against the LATEST values, not the render that scheduled it. */
|
||||
const updateValues = (updater: (current: Values) => Values) => {
|
||||
replaceValues(updater(valuesRef.current))
|
||||
}
|
||||
|
||||
/** Mirror a keystroke locally WITHOUT navigating (the debounce commits later). */
|
||||
const setLocalValues = (updater: (current: Values) => Values) => {
|
||||
const next = updater(valuesRef.current)
|
||||
valuesRef.current = next
|
||||
setValues(next)
|
||||
}
|
||||
|
||||
return { values, isPending, replaceValues, updateValues, setLocalValues }
|
||||
}
|
||||
|
|
@ -1,35 +1,14 @@
|
|||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
|
||||
import { normalizedText } from '@/utilities/campaignListUrl'
|
||||
|
||||
/**
|
||||
* Long for search-as-you-type on purpose: every commit is an RSC round trip
|
||||
* over a mobile connection, and the field team types município names slowly.
|
||||
*/
|
||||
export const SEARCH_DEBOUNCE_MS = 1000
|
||||
|
||||
type StateWithOptionalQ = { q?: string }
|
||||
|
||||
const readCommittedQ = (state: object): string | undefined =>
|
||||
'q' in state ? (state as StateWithOptionalQ).q : undefined
|
||||
|
||||
const withDraftQ = <State extends object>(state: State, q: string): State =>
|
||||
({ ...state, q }) as State
|
||||
|
||||
/**
|
||||
* Navigation scaffold shared by the list filter shells that still use a
|
||||
* debounced search box (territórios, dobradinhas). Municípios moved to the
|
||||
* omnibox in B127 but still calls `navigateWithSearch` from the same hook.
|
||||
*
|
||||
* The hook never decides what is canonical — `toHref` comes from the domain's
|
||||
* own URL module (`buildXListHref`, which already canonicalizes), so the three
|
||||
* URL contracts stay independent instead of collapsing into one DSL. Raw
|
||||
* keystrokes are handed to `toHref` untrimmed for the same reason: trimming is
|
||||
* the serializer's job, and doing it here too would be a second policy.
|
||||
* Navigation scaffold for list filter shells (B127/B128 omnibox). The hook never
|
||||
* decides what is canonical — `toHref` comes from the domain's own URL module
|
||||
* (`buildXListHref`, which already canonicalizes).
|
||||
*/
|
||||
export const useCampaignListFilterNavigation = <State extends object>({
|
||||
state,
|
||||
|
|
@ -42,52 +21,13 @@ export const useCampaignListFilterNavigation = <State extends object>({
|
|||
}) => {
|
||||
const router = useRouter()
|
||||
const { isPending, startTransition } = useCampaignListTransition()
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [search, setSearch] = useState(readCommittedQ(state) ?? '')
|
||||
|
||||
/**
|
||||
* Follow the URL when the committed query changes under us — back/forward and
|
||||
* the empty state's "Limpar busca e filtros" navigate from outside this shell.
|
||||
* Without this the box would keep showing a query the URL no longer has, and
|
||||
* the next filter touch would put it back (`navigateWithSearch` carries the
|
||||
* box). A pending debounce wins: that text is the user's, not the URL's.
|
||||
*/
|
||||
const [committedQ, setCommittedQ] = useState(readCommittedQ(state))
|
||||
const stateQ = readCommittedQ(state)
|
||||
if (stateQ !== committedQ) {
|
||||
setCommittedQ(stateQ)
|
||||
if (!debounceRef.current) setSearch(stateQ ?? '')
|
||||
}
|
||||
|
||||
/**
|
||||
* The controls that navigate from *outside* this shell — sortable heads,
|
||||
* pagination — change `state` without touching the pending timer. Reading the
|
||||
* committed state through a ref keeps a timer scheduled before such a
|
||||
* navigation from reverting it a second later. Written in an effect, not
|
||||
* during render: an interrupted transition render is abandoned before commit,
|
||||
* and the timer must not resolve against state that never landed.
|
||||
*/
|
||||
const stateRef = useRef(state)
|
||||
useEffect(() => {
|
||||
stateRef.current = state
|
||||
})
|
||||
|
||||
const clearDebounce = () => {
|
||||
if (!debounceRef.current) return
|
||||
clearTimeout(debounceRef.current)
|
||||
debounceRef.current = null
|
||||
}
|
||||
|
||||
useEffect(() => clearDebounce, [])
|
||||
|
||||
/**
|
||||
* Cancels any pending search, then navigates — unless `next` serializes to
|
||||
* what is already committed. Both sides pin the same page, so the guard asks
|
||||
* "do the filters and sort change?", not "is this the current URL?": a
|
||||
* re-submitted identical search on page 3 leaves the user on page 3.
|
||||
*/
|
||||
const navigateTo = (next: State) => {
|
||||
clearDebounce()
|
||||
const navigate = (next: State) => {
|
||||
const nextHref = toHref(next)
|
||||
if (nextHref === toHref(stateRef.current)) return
|
||||
startTransition(() => {
|
||||
|
|
@ -95,58 +35,8 @@ export const useCampaignListFilterNavigation = <State extends object>({
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates carrying the search the user has typed but not yet committed —
|
||||
* otherwise touching a filter mid-typing would drop it while the input keeps
|
||||
* showing the text.
|
||||
*/
|
||||
const navigateWithSearch = (next: State) => {
|
||||
navigateTo(withDraftQ(next, search))
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Limpar" buttons: empties the box and drops `q` in the same gesture.
|
||||
* Exists so no caller has to know that `navigateWithSearch` would re-commit
|
||||
* the search being cleared, because `setSearch('')` has not flushed yet.
|
||||
*/
|
||||
const clearSearchAndNavigate = (next: State) => {
|
||||
setSearch('')
|
||||
navigateTo(next)
|
||||
}
|
||||
|
||||
/**
|
||||
* The search input's only handler: mirrors the keystroke and (re)arms the
|
||||
* debounce in one call, so no caller can wire up half of it.
|
||||
*
|
||||
* Typing back to the committed value still arms a timer — it cancels the
|
||||
* pending navigation rather than merely skipping a new one, because the guard
|
||||
* lives in `navigateTo` and the timer is free to resolve to a no-op.
|
||||
*/
|
||||
const onSearchChange = (value: string) => {
|
||||
setSearch(value)
|
||||
clearDebounce()
|
||||
debounceRef.current = setTimeout(() => {
|
||||
navigateTo(withDraftQ(stateRef.current, value))
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
return {
|
||||
search,
|
||||
onSearchChange,
|
||||
/**
|
||||
* What the active-filters summary should describe. The draft wins while the
|
||||
* box is non-empty; an emptied box falls back to the committed `q` so the
|
||||
* summary does not flicker off during the debounce, before the results it
|
||||
* describes have actually changed.
|
||||
*/
|
||||
draftQ: normalizedText(search) || readCommittedQ(state),
|
||||
isPending,
|
||||
/**
|
||||
* Navigate with the caller's state as-is (no draft-search merge). Used by
|
||||
* shells that own committed `q` themselves (B127 omnibox).
|
||||
*/
|
||||
navigate: navigateTo,
|
||||
navigateWithSearch,
|
||||
clearSearchAndNavigate,
|
||||
navigate,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ const omniboxQueryMatches = (
|
|||
export const omniboxGroupMatches = (group: string, needle: string): boolean =>
|
||||
omniboxQueryMatches(group, needle)
|
||||
|
||||
export const SUGGESTION_CAP_PER_GROUP = 8
|
||||
const SUGGESTION_CAP_PER_GROUP = 8
|
||||
|
||||
export type OmniboxSuggestionSeed = CampaignListOmniboxSuggestion & {
|
||||
/** When true, show even with an empty query (dimension shortcuts). */
|
||||
|
|
|
|||
|
|
@ -165,17 +165,3 @@ const sortOptionLabel = (key: TerritoryListSortKey, dir: TerritoryListSortDirect
|
|||
export const territoryListSortOptions = territoryListSortKeys.flatMap((key) =>
|
||||
(['asc', 'desc'] as const).map((dir) => ({ key, dir, label: sortOptionLabel(key, dir) })),
|
||||
)
|
||||
|
||||
export const serializeTerritorySortValue = (
|
||||
key: TerritoryListSortKey,
|
||||
dir: TerritoryListSortDirection,
|
||||
): string => `${key}|${dir}`
|
||||
|
||||
export const parseTerritorySortValue = (
|
||||
value: string,
|
||||
): { key: TerritoryListSortKey; dir: TerritoryListSortDirection } | null => {
|
||||
const [key, dir] = value.split('|')
|
||||
if (!territoryListSortKeySet.has(key)) return null
|
||||
if (!territoryListSortDirSet.has(dir as TerritoryListSortDirection)) return null
|
||||
return { key: key as TerritoryListSortKey, dir: dir as TerritoryListSortDirection }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue