The "Municípios" column of /campanha/dobradinhas reuses MunicipalityPortfolioCell unchanged, writing the inverted side of the relation (municipality.stateDeputies) through a single transactional batch action (setStateDeputyMunicipalitiesBatch): per-município advisory lock, all-or-nothing on a partial-scope batch instead of a silent partial apply, no floor (a dobradinha can hold zero municípios). stateDeputiesArraySchema's cap moved 20 → 435 to fit a whole território batch from the município side. The post-implementation critique found two P1s, and both were fixed instead of deferred. First: MunicipalityPortfolioCell and LeadershipStateDeputyRelationCell had grown the same chip-editing machine twice — optimistic delta, undo, ARIA 1.2 combobox, floor/ceiling, pointer-fine/coarse split — with incompatible contracts. This was the 3rd call site B34+ was waiting for, so RelationChipCell is now the single engine and both cells shrank to thin domain wrappers (chip/ search building, FormData, copy — nothing interaction-specific left in either). LeadershipStateDeputyRelationCell also gained an ownerName prop and its first dedicated test suite, which it shipped without before this. Second: an advisor could see, and attempt to remove, a município chip outside the portfolio they administer, failing silently at the document level. MunicipalityPortfolioCell now filters the visible chips by addableIds instead of only gating what can be added, so an advisor sees and can only touch municípios they administer. No migration, collection, or Consent. Docs updated: plan as-built, roadmap, AGENTS.md, project notebook.
813 lines
31 KiB
TypeScript
813 lines
31 KiB
TypeScript
'use client'
|
||
|
||
import { XIcon } from 'lucide-react'
|
||
import Link from 'next/link'
|
||
import {
|
||
useCallback,
|
||
useEffect,
|
||
useId,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
useTransition,
|
||
type KeyboardEvent,
|
||
type ReactNode,
|
||
} from 'react'
|
||
import { toast } from 'sonner'
|
||
|
||
import { CampaignCellEditOverlay } from '@/components/campaign/shared/CampaignCellEditOverlay'
|
||
import { Badge } from '@/components/ui/Badge'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/Popover'
|
||
import { sameIdSet } from '@/lib/sameIdSet'
|
||
import { cn } from '@/lib/utils'
|
||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||
|
||
const COLLAPSED_CHIP_ROWS = 3
|
||
/** Matches `gap-1.5` on the chip row, and `max-h-18` (72px) is this math at a 20px chip. */
|
||
const CHIP_GAP_PX = 6
|
||
|
||
const resultsMessage = (count: number): string => {
|
||
if (count === 0) return 'Nenhum resultado.'
|
||
return count === 1 ? '1 resultado.' : `${count} resultados.`
|
||
}
|
||
|
||
const withDelta = (
|
||
base: readonly number[],
|
||
changedIds: readonly number[],
|
||
assigned: boolean,
|
||
): number[] => {
|
||
const next = new Set(base)
|
||
for (const id of changedIds) {
|
||
if (assigned) next.add(id)
|
||
else next.delete(id)
|
||
}
|
||
return [...next]
|
||
}
|
||
|
||
type SuggestionSurface = 'inline' | 'drawer'
|
||
|
||
export type RelationChip = {
|
||
key: string
|
||
label: string
|
||
/** Omit for a chip that is not a link (e.g. a batch/territory chip). */
|
||
href?: string
|
||
/** Rendered before the label, e.g. a territory pin. */
|
||
icon?: ReactNode
|
||
/** Rendered after the label as a small muted count. */
|
||
trailingLabel?: string
|
||
/** Ids removing (or re-adding, on undo) this chip touches — usually one. */
|
||
ids: number[]
|
||
/** Appended to the label in the remove control's aria-label, e.g. ` — 2 municípios`. */
|
||
removalSuffix?: string
|
||
}
|
||
|
||
export type RelationSearchHit = {
|
||
key: string
|
||
label: string
|
||
/** Second line under the suggestion. Omit for a flat, single-line list. */
|
||
description?: string
|
||
ids: number[]
|
||
}
|
||
|
||
export type RelationChipCellCopy = {
|
||
searchPlaceholder: string
|
||
/** Shown once the cell already has chips. Defaults to "Adicionar…". */
|
||
addPlaceholder?: string
|
||
searchLabel: string
|
||
suggestionsLabel: string
|
||
emptyDrawerMessage: string
|
||
savingMessage: string
|
||
savedMessage: string
|
||
removedMessage: (count: number) => string
|
||
/** Only invoked when `minItems` is actually configured. */
|
||
floorMessage?: (min: number) => string
|
||
/** Only invoked when `maxItems` is actually configured. */
|
||
capMessage?: (max: number) => string
|
||
}
|
||
|
||
type RelationChipCellProps = {
|
||
/** `null` in draft mode (a row that does not exist yet). */
|
||
ownerId: number | null
|
||
/** Whose relation this is — spoken in the aria-labels and the Drawer description. */
|
||
ownerName: string
|
||
ids: number[]
|
||
buildChips: (ids: number[]) => RelationChip[]
|
||
searchHits: (query: string, assignedIds: ReadonlySet<number>) => RelationSearchHit[]
|
||
/** Floor the relation enforces server-side. */
|
||
minItems?: number
|
||
/**
|
||
* Ceiling the relation enforces server-side. Checked here so a batch that would
|
||
* blow the cap is refused with the reason BEFORE the optimistic apply — the
|
||
* server rejection would otherwise revert a whole batch with a message that
|
||
* never says which limit was hit.
|
||
*/
|
||
maxItems?: number
|
||
/** Draft mode (new row): keep changes local and report them upward. */
|
||
draft?: boolean
|
||
onDraftChange?: (ids: number[]) => void
|
||
buildFormData: (changedIds: number[], assigned: boolean) => FormData
|
||
commitAction: (
|
||
state: CampaignFormActionState,
|
||
formData: FormData,
|
||
) => Promise<CampaignFormActionState>
|
||
drawerTitle: string
|
||
/** Full aria-label for the coarse-pointer, cell-wide Drawer trigger. */
|
||
triggerLabel: string
|
||
updateErrorMessage: string
|
||
copy: RelationChipCellCopy
|
||
/** Clamp rest chips to 3 rows + "Ver mais…". Default true; pass false when few chips are expected. */
|
||
measureOverflow?: boolean
|
||
}
|
||
|
||
/**
|
||
* Shared interaction machine behind every bounded-set relation edited by chips
|
||
* (município portfolios, leadership↔dobradinha membership): optimistic delta
|
||
* commits that survive concurrent toggles, an undo toast scoped to exactly what
|
||
* changed, an ARIA 1.2 combobox for search/suggestions, a floor/ceiling refused
|
||
* with a spoken reason before the optimistic apply, and a pointer-fine inline
|
||
* editor / pointer-coarse Drawer split. What differs between relations — chip
|
||
* content, search, the write's field names, copy — is entirely prop-driven;
|
||
* nothing domain-specific lives here.
|
||
*/
|
||
export const RelationChipCell = ({
|
||
ownerId,
|
||
ownerName,
|
||
ids,
|
||
buildChips,
|
||
searchHits,
|
||
minItems = 0,
|
||
maxItems,
|
||
draft = false,
|
||
onDraftChange,
|
||
buildFormData,
|
||
commitAction,
|
||
drawerTitle,
|
||
triggerLabel,
|
||
updateErrorMessage,
|
||
copy,
|
||
measureOverflow = true,
|
||
}: RelationChipCellProps) => {
|
||
const [query, setQuery] = useState('')
|
||
const [open, setOpen] = useState(false)
|
||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||
const [optimistic, setOptimistic] = useState<number[] | null>(null)
|
||
const [expanded, setExpanded] = useState(false)
|
||
/** `null` means "render every chip so the next effect can measure them". */
|
||
const [visibleChipCount, setVisibleChipCount] = useState<number | null>(null)
|
||
const [measureToken, setMeasureToken] = useState(0)
|
||
const lastRowWidth = useRef(0)
|
||
const [isPending, startTransition] = useTransition()
|
||
const [feedback, setFeedback] = useState<
|
||
{ kind: 'saved' } | { kind: 'error'; message: string } | null
|
||
>(null)
|
||
/** Active suggestion (ARIA 1.2 combobox): index 0 is pre-selected, so Enter always picks. */
|
||
const [activeIndex, setActiveIndex] = useState(0)
|
||
/** Only keyboard movement should scroll: the mouse is already where it points. */
|
||
const activeFromKeyboard = useRef(false)
|
||
const rootRef = useRef<HTMLDivElement>(null)
|
||
const inputRef = useRef<HTMLInputElement>(null)
|
||
const chipRowRef = useRef<HTMLDivElement>(null)
|
||
const baseId = useId()
|
||
const listboxId = (surface: SuggestionSurface) => `${baseId}-${surface}-listbox`
|
||
const optionId = useCallback(
|
||
(surface: SuggestionSurface, index: number) => `${baseId}-${surface}-option-${index}`,
|
||
[baseId],
|
||
)
|
||
|
||
useEffect(() => {
|
||
setOptimistic((current) => {
|
||
if (!current) return null
|
||
return sameIdSet(current, ids) ? null : current
|
||
})
|
||
}, [ids])
|
||
|
||
const effectiveIds = optimistic ?? ids
|
||
|
||
/**
|
||
* The baseline a deferred delta falls back to, which cannot be the render's
|
||
* `ids`: the toast's "Desfazer" outlives the render that created it, and by
|
||
* the time it fires the optimistic value has usually reconciled to null — so
|
||
* the functional updater takes the fallback branch and would rebuild the row
|
||
* from a set that predates every edit made since. That divergence is
|
||
* permanent, because the reconcile effect above only clears on an exact match.
|
||
*/
|
||
const latestIds = useRef(ids)
|
||
useEffect(() => {
|
||
latestIds.current = effectiveIds
|
||
}, [effectiveIds])
|
||
|
||
const assignedIds = useMemo(() => new Set(effectiveIds), [effectiveIds])
|
||
const chips = useMemo(() => buildChips(effectiveIds), [effectiveIds, buildChips])
|
||
|
||
const searching = query.trim().length > 0
|
||
/**
|
||
* Per surface, because Radix can close the popover with text still in the
|
||
* input — and because the inline list is PORTALED, so `pointer-coarse:hidden`
|
||
* on the cell would not hide it while the Drawer is up.
|
||
*/
|
||
const inlineSuggesting = searching && open && !drawerOpen
|
||
const drawerSuggesting = searching && drawerOpen
|
||
const suggesting = inlineSuggesting || drawerSuggesting
|
||
const hits = useMemo(
|
||
() => (suggesting ? searchHits(query, assignedIds) : []),
|
||
[suggesting, query, searchHits, assignedIds],
|
||
)
|
||
|
||
const hitsKey = hits.map((hit) => hit.key).join('|')
|
||
useEffect(() => {
|
||
setActiveIndex(0)
|
||
}, [hitsKey])
|
||
|
||
const activeSurface: SuggestionSurface = drawerOpen ? 'drawer' : 'inline'
|
||
useEffect(() => {
|
||
if (!activeFromKeyboard.current) return
|
||
document.getElementById(optionId(activeSurface, activeIndex))?.scrollIntoView({
|
||
block: 'nearest',
|
||
})
|
||
}, [activeIndex, activeSurface, optionId])
|
||
|
||
/**
|
||
* The token is what makes a stale measurement recoverable: an invalidation
|
||
* batched with a measurement would otherwise settle back on `null` without
|
||
* changing any dependency, leaving the cell unmeasured forever.
|
||
*/
|
||
const invalidateMeasurement = useCallback(() => {
|
||
setVisibleChipCount(null)
|
||
setMeasureToken((token) => token + 1)
|
||
}, [])
|
||
|
||
// Keyed by content, not array identity: a re-render that rebuilds the same
|
||
// chips must not throw away a measurement that is still valid. The remove
|
||
// button is absolutely positioned, so revealing it never changes a chip's box.
|
||
const chipsKey = chips.map((chip) => chip.key).join('|')
|
||
useEffect(() => {
|
||
if (!measureOverflow) return
|
||
invalidateMeasurement()
|
||
}, [chipsKey, measureOverflow, invalidateMeasurement])
|
||
|
||
useEffect(() => {
|
||
if (!measureOverflow) return
|
||
const row = chipRowRef.current
|
||
if (!row) return
|
||
// Seeded here so the observer's initial callback isn't read as a resize.
|
||
lastRowWidth.current = row.clientWidth
|
||
const observer = new ResizeObserver(() => {
|
||
if (row.clientWidth === lastRowWidth.current) return
|
||
lastRowWidth.current = row.clientWidth
|
||
invalidateMeasurement()
|
||
})
|
||
observer.observe(row)
|
||
return () => observer.disconnect()
|
||
}, [measureOverflow, invalidateMeasurement])
|
||
|
||
/**
|
||
* Runs on the frames where every chip is in the DOM, so the packing of the
|
||
* three visible rows — and the room the toggle needs on the third one — is
|
||
* read from the real layout instead of estimated from label lengths.
|
||
*/
|
||
useEffect(() => {
|
||
if (!measureOverflow) return
|
||
if (visibleChipCount !== null) return
|
||
const row = chipRowRef.current
|
||
if (!row) return
|
||
|
||
const chipElements = [...row.querySelectorAll<HTMLElement>('[data-relation-chip]')]
|
||
if (chipElements.length === 0) {
|
||
setVisibleChipCount(0)
|
||
return
|
||
}
|
||
|
||
const rowRect = row.getBoundingClientRect()
|
||
const chipRects = chipElements.map((element) => element.getBoundingClientRect())
|
||
const lastVisibleTop =
|
||
chipRects[0].top + (chipRects[0].height + CHIP_GAP_PX) * (COLLAPSED_CHIP_ROWS - 1)
|
||
let fitting = chipRects.filter((rect) => rect.top <= lastVisibleTop + 1).length
|
||
|
||
// The toggle — and the search input — must share the last visible line,
|
||
// otherwise they wrap into a line nobody asked for. The input's box is read
|
||
// as its `min-width`, not its rect: `flex-1` makes the rendered width the
|
||
// leftover of the row, which would reserve the whole line. A zero-width
|
||
// rect means the input is `display: none` (coarse pointer), where the row
|
||
// has no trailing control to make room for at all.
|
||
const toggleWidth =
|
||
row.querySelector<HTMLElement>('[data-relation-toggle]')?.getBoundingClientRect().width ?? 0
|
||
const inputElement = inputRef.current
|
||
const inputWidth =
|
||
inputElement && inputElement.getBoundingClientRect().width > 0
|
||
? Number.parseFloat(getComputedStyle(inputElement).minWidth) || 0
|
||
: 0
|
||
const trailingWidth = toggleWidth + CHIP_GAP_PX + inputWidth
|
||
while (fitting > 0 && fitting < chipElements.length) {
|
||
const trailing = chipRects[fitting - 1]
|
||
if (trailing.right + CHIP_GAP_PX + trailingWidth <= rowRect.right) break
|
||
fitting -= 1
|
||
}
|
||
|
||
setVisibleChipCount(fitting)
|
||
}, [measureOverflow, measureToken, visibleChipCount])
|
||
|
||
/**
|
||
* Every delta is applied functionally, forward and back: two chips toggled in
|
||
* the same burst — or an "Desfazer" pressed after a later add — would otherwise
|
||
* each compute from the set their own render captured and drop the other's work.
|
||
*/
|
||
const commit = (changedIds: number[], assigned: boolean) => {
|
||
if (changedIds.length === 0) return
|
||
|
||
if (draft) {
|
||
// Advanced synchronously as well: the parent holds the draft in state, so
|
||
// two toggles batched into one tick would otherwise both read the prop of
|
||
// the render they were queued from and the second would drop the first.
|
||
const next = withDelta(latestIds.current, changedIds, assigned)
|
||
latestIds.current = next
|
||
onDraftChange?.(next)
|
||
} else {
|
||
setOptimistic((current) => withDelta(current ?? latestIds.current, changedIds, assigned))
|
||
}
|
||
setQuery('')
|
||
setOpen(false)
|
||
setFeedback(null)
|
||
|
||
if (draft) return
|
||
if (ownerId === null) return
|
||
|
||
const formData = buildFormData(changedIds, assigned)
|
||
|
||
startTransition(async () => {
|
||
const result = await commitAction({}, formData)
|
||
if (result.status === 'success') {
|
||
setFeedback({ kind: 'saved' })
|
||
// A batch removal drops several links in one tap, and the row it left
|
||
// behind carries no trace of what was there. Single removals are one
|
||
// search away from being restored and get no toast.
|
||
if (!assigned && changedIds.length > 1) {
|
||
toast.success(copy.removedMessage(changedIds.length), {
|
||
action: { label: 'Desfazer', onClick: () => commit(changedIds, true) },
|
||
})
|
||
}
|
||
return
|
||
}
|
||
// Undo only THIS delta: reverting to the server baseline would also wipe
|
||
// a sibling toggle from the same burst that already saved.
|
||
setOptimistic((current) => withDelta(current ?? latestIds.current, changedIds, !assigned))
|
||
const message = result.message ?? updateErrorMessage
|
||
setFeedback({ kind: 'error', message })
|
||
toast.error(message)
|
||
})
|
||
}
|
||
|
||
/** The relation's floor, refused here so the server never has to say no. */
|
||
const removalFloorReason = (chip: RelationChip): string | null =>
|
||
assignedIds.size - chip.ids.length < minItems
|
||
? (copy.floorMessage ?? ((min) => `${ownerName} precisa de pelo menos ${min}.`))(minItems)
|
||
: null
|
||
|
||
const additionCapReason = (additions: number[]): string | null =>
|
||
maxItems !== undefined && assignedIds.size + additions.length > maxItems
|
||
? (copy.capMessage ?? ((max) => `${ownerName} aceita no máximo ${max}.`))(maxItems)
|
||
: null
|
||
|
||
const chipRemoveLabel = (chip: RelationChip, floorReason: string | null): string =>
|
||
floorReason
|
||
? `${floorReason} — não é possível remover ${chip.label}`
|
||
: `Remover ${chip.label}${chip.removalSuffix ?? ''}`
|
||
|
||
/**
|
||
* Refusals explain themselves on attempt instead of going silent: the control
|
||
* keeps `aria-disabled` (so it stays in the tab order and can still be
|
||
* activated) rather than `disabled`, which would hide the only copy of the
|
||
* reason from every keyboard user.
|
||
*/
|
||
const attemptRemove = (chip: RelationChip) => {
|
||
const floorReason = removalFloorReason(chip)
|
||
if (floorReason) {
|
||
toast.error(`${floorReason}.`)
|
||
return
|
||
}
|
||
commit(chip.ids, false)
|
||
}
|
||
|
||
const pickHit = (hit: RelationSearchHit) => {
|
||
const capReason = additionCapReason(hit.ids)
|
||
if (capReason) {
|
||
toast.error(capReason)
|
||
return
|
||
}
|
||
commit(hit.ids, true)
|
||
}
|
||
|
||
const closeSuggestions = () => {
|
||
setOpen(false)
|
||
setQuery('')
|
||
}
|
||
|
||
/**
|
||
* ARIA 1.2 combobox keyboard contract, shared by the inline input and the
|
||
* Drawer's: Up/Down/Home/End move the active option, Enter picks it, Escape
|
||
* abandons the search. Without it the suggestions are mouse-only.
|
||
*/
|
||
const onSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||
if (hits.length === 0) return
|
||
event.preventDefault()
|
||
const delta = event.key === 'ArrowDown' ? 1 : -1
|
||
activeFromKeyboard.current = true
|
||
setActiveIndex((current) => (current + delta + hits.length) % hits.length)
|
||
return
|
||
}
|
||
if (event.key === 'Home' || event.key === 'End') {
|
||
if (hits.length === 0) return
|
||
event.preventDefault()
|
||
activeFromKeyboard.current = true
|
||
setActiveIndex(event.key === 'Home' ? 0 : hits.length - 1)
|
||
return
|
||
}
|
||
if (event.key === 'Enter') {
|
||
const hit = hits[activeIndex]
|
||
if (!hit) return
|
||
event.preventDefault()
|
||
pickHit(hit)
|
||
return
|
||
}
|
||
if (event.key === 'Escape') {
|
||
if (drawerOpen) {
|
||
setQuery('')
|
||
return
|
||
}
|
||
closeSuggestions()
|
||
inputRef.current?.blur()
|
||
}
|
||
}
|
||
|
||
// Editing needs every chip reachable, so focusing the cell always expands it.
|
||
const showAllChips = !measureOverflow || expanded || open
|
||
const measuring = measureOverflow && visibleChipCount === null
|
||
const hasHiddenChips =
|
||
measureOverflow && visibleChipCount !== null && visibleChipCount < chips.length
|
||
const visibleChips =
|
||
!measureOverflow || showAllChips || measuring || visibleChipCount === null
|
||
? chips
|
||
: chips.slice(0, visibleChipCount)
|
||
/**
|
||
* Only the pre-measurement render needs a height cap; once the chip list is
|
||
* trimmed the row is three lines tall on its own, and capping it again would
|
||
* clip whatever shares the last line (the search input is taller than a chip).
|
||
*/
|
||
const clamping = measuring && !showAllChips
|
||
|
||
/**
|
||
* One region, four things to say, in this order: a save in flight outranks
|
||
* everything; a failure outranks the search; and the search outranks a past
|
||
* success, otherwise the saved message would shadow every later result count
|
||
* for the rest of the session.
|
||
*/
|
||
const statusMessage = isPending
|
||
? copy.savingMessage
|
||
: feedback?.kind === 'error'
|
||
? feedback.message
|
||
: suggesting
|
||
? resultsMessage(hits.length)
|
||
: feedback?.kind === 'saved'
|
||
? copy.savedMessage
|
||
: ''
|
||
|
||
/**
|
||
* Mounted while measuring so its width is reserved on the last line, but kept
|
||
* out of the tab order and the accessibility tree until the overflow it
|
||
* announces is known. On a coarse pointer the cell-wide Drawer trigger sits
|
||
* above it, so a tap opens the full list instead of expanding in place.
|
||
*/
|
||
const expandToggle =
|
||
measureOverflow && (measuring || hasHiddenChips) ? (
|
||
<button
|
||
type="button"
|
||
data-relation-toggle
|
||
tabIndex={measuring ? -1 : undefined}
|
||
aria-hidden={measuring || undefined}
|
||
className={cn(
|
||
// `min-h-6`: 24px is the SC 2.5.8 target floor for a text button.
|
||
'inline-flex min-h-6 items-center px-1 text-xs font-medium text-primary underline-offset-4 hover:underline',
|
||
measuring && 'invisible pointer-events-none',
|
||
)}
|
||
onClick={(event) => {
|
||
event.stopPropagation()
|
||
const next = !showAllChips
|
||
setExpanded(next)
|
||
// Focus is what keeps the cell open — collapsing must release it.
|
||
if (!next) {
|
||
setOpen(false)
|
||
inputRef.current?.blur()
|
||
}
|
||
}}
|
||
>
|
||
{showAllChips ? 'Ver menos' : 'Ver mais…'}
|
||
</button>
|
||
) : null
|
||
|
||
const chipBody = (chip: RelationChip) => (
|
||
<>
|
||
{chip.icon}
|
||
<span className="truncate">{chip.label}</span>
|
||
{chip.trailingLabel !== undefined ? (
|
||
<span className="shrink-0 text-xs tabular-nums opacity-70">{chip.trailingLabel}</span>
|
||
) : null}
|
||
</>
|
||
)
|
||
|
||
/**
|
||
* The remove button is a sibling of the chip link (never nested in it) and sits
|
||
* absolutely on its corner, so revealing it on hover does not resize the chip
|
||
* or move the row. The `before:` box grows the pointer target to 24×24 (WCAG
|
||
* 2.2 SC 2.5.8) without growing the 16px circle over a 20px chip. Fine
|
||
* pointers only: on touch the whole cell opens the Drawer, where the same "×"
|
||
* is permanently visible at a real tap size.
|
||
*/
|
||
const inlineChip = (chip: RelationChip) => {
|
||
const floorReason = removalFloorReason(chip)
|
||
return (
|
||
<span
|
||
key={chip.key}
|
||
data-relation-chip
|
||
className="group/chip relative inline-flex max-w-full"
|
||
>
|
||
{chip.href ? (
|
||
<Badge variant="secondary" className="max-w-full font-normal" asChild>
|
||
<Link href={chip.href} className="truncate underline-offset-4 hover:underline">
|
||
{chipBody(chip)}
|
||
</Link>
|
||
</Badge>
|
||
) : (
|
||
<Badge variant="secondary" className="max-w-full gap-1 font-normal">
|
||
{chipBody(chip)}
|
||
</Badge>
|
||
)}
|
||
<button
|
||
type="button"
|
||
data-chip-remove
|
||
aria-disabled={floorReason !== null || undefined}
|
||
aria-label={chipRemoveLabel(chip, floorReason)}
|
||
className={cn(
|
||
// `border-muted-foreground/70`: the default 12%-alpha `border` token
|
||
// does not reach 3:1 against a `secondary` chip (SC 1.4.11).
|
||
'absolute -top-1 -right-1 hidden size-4 place-items-center rounded-full border border-muted-foreground/70 bg-background text-muted-foreground opacity-0 transition-opacity pointer-fine:grid',
|
||
"before:absolute before:-inset-1 before:content-['']",
|
||
'group-hover/chip:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
|
||
floorReason
|
||
? 'cursor-not-allowed'
|
||
: 'hover:border-destructive hover:bg-destructive/10 hover:text-destructive hover:opacity-100',
|
||
)}
|
||
onClick={(event) => {
|
||
event.stopPropagation()
|
||
attemptRemove(chip)
|
||
}}
|
||
>
|
||
<XIcon className="size-2.5" aria-hidden="true" />
|
||
</button>
|
||
</span>
|
||
)
|
||
}
|
||
|
||
const suggestionOption = (hit: RelationSearchHit, index: number, surface: SuggestionSurface) => {
|
||
const capped = additionCapReason(hit.ids) !== null
|
||
const active = index === activeIndex
|
||
return (
|
||
<button
|
||
key={hit.key}
|
||
id={optionId(surface, index)}
|
||
type="button"
|
||
role="option"
|
||
aria-selected={active}
|
||
aria-disabled={capped || undefined}
|
||
tabIndex={-1}
|
||
className={cn(
|
||
'flex w-full items-start gap-2 rounded-md px-2 py-2 text-left text-sm',
|
||
active && 'bg-muted',
|
||
capped ? 'opacity-60' : 'hover:bg-muted',
|
||
)}
|
||
onMouseDown={(event) => event.preventDefault()}
|
||
onMouseEnter={() => {
|
||
activeFromKeyboard.current = false
|
||
setActiveIndex(index)
|
||
}}
|
||
onClick={() => pickHit(hit)}
|
||
>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block truncate font-medium">{hit.label}</span>
|
||
{hit.description ? (
|
||
<span className="block text-xs text-muted-foreground">
|
||
{hit.description}
|
||
{capped ? ' · excede o limite' : ''}
|
||
</span>
|
||
) : null}
|
||
</span>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* The listbox stays mounted while empty (the input's `aria-controls` must
|
||
* resolve) and the "no results" copy sits OUTSIDE it — inside, a screen
|
||
* reader would count it as a selectable option.
|
||
*/
|
||
const suggestionList = (surface: SuggestionSurface) => (
|
||
<>
|
||
<div role="listbox" id={listboxId(surface)} aria-label={copy.suggestionsLabel}>
|
||
{hits.map((hit, index) => suggestionOption(hit, index, surface))}
|
||
</div>
|
||
{hits.length === 0 ? (
|
||
<p className="px-2 py-2 text-sm text-muted-foreground">Nenhum resultado.</p>
|
||
) : null}
|
||
</>
|
||
)
|
||
|
||
const activeOptionId = hits.length > 0 ? optionId(activeSurface, activeIndex) : undefined
|
||
|
||
return (
|
||
<div
|
||
ref={rootRef}
|
||
// `aria-busy` + the dimmed row are the only feedback a saved chip gets:
|
||
// the write is fire-and-forget, and a cell that never reacts reads as
|
||
// "nothing happened" until the next navigation proves otherwise.
|
||
aria-busy={isPending || undefined}
|
||
// Same box in both pointer modes so nothing reflows when the row is touched.
|
||
className="relative min-w-56 rounded-md border border-transparent p-1 outline-none pointer-fine:hover:bg-muted/40 pointer-fine:focus-within:bg-muted/40"
|
||
// Reached only on a fine pointer: the coarse overlay below covers the cell
|
||
// and stops the click, so the pointer policy stays in the media queries.
|
||
onClick={(event) => {
|
||
const target = event.target as HTMLElement
|
||
if (target.closest('[data-chip-remove]') || target.closest('a')) return
|
||
inputRef.current?.focus()
|
||
setOpen(true)
|
||
}}
|
||
>
|
||
<Popover open={inlineSuggesting} onOpenChange={setOpen}>
|
||
<PopoverAnchor asChild>
|
||
<div
|
||
ref={chipRowRef}
|
||
className={cn(
|
||
'flex min-h-8 flex-wrap items-center gap-1.5',
|
||
clamping && 'max-h-18 overflow-hidden',
|
||
isPending && 'opacity-60 transition-opacity',
|
||
)}
|
||
>
|
||
{chips.length === 0 && !searching ? (
|
||
<span className="px-1 text-sm text-muted-foreground pointer-fine:hidden">—</span>
|
||
) : null}
|
||
{visibleChips.map(inlineChip)}
|
||
{expandToggle}
|
||
<input
|
||
ref={inputRef}
|
||
value={query}
|
||
role="combobox"
|
||
aria-expanded={inlineSuggesting}
|
||
aria-controls={listboxId('inline')}
|
||
aria-autocomplete="list"
|
||
aria-activedescendant={inlineSuggesting ? activeOptionId : undefined}
|
||
onChange={(event) => {
|
||
setQuery(event.currentTarget.value)
|
||
setFeedback(null)
|
||
setOpen(true)
|
||
}}
|
||
onFocus={() => setOpen(true)}
|
||
onBlur={() => {
|
||
window.setTimeout(() => {
|
||
if (!rootRef.current?.contains(document.activeElement)) {
|
||
closeSuggestions()
|
||
}
|
||
}, 120)
|
||
}}
|
||
onKeyDown={onSearchKeyDown}
|
||
placeholder={
|
||
chips.length ? (copy.addPlaceholder ?? 'Adicionar…') : copy.searchPlaceholder
|
||
}
|
||
aria-label={copy.searchLabel}
|
||
className="hidden min-h-8 min-w-32 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground pointer-fine:block"
|
||
/>
|
||
</div>
|
||
</PopoverAnchor>
|
||
{/* Portaled: the table's scroll container would clip an in-flow listbox. */}
|
||
<PopoverContent
|
||
align="start"
|
||
className="max-h-64 w-(--radix-popover-trigger-width) overflow-auto p-1"
|
||
// The input owns focus the whole time — the list is a suggestion
|
||
// surface, not a dialog, and must never steal or restore focus.
|
||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||
>
|
||
{suggestionList('inline')}
|
||
</PopoverContent>
|
||
</Popover>
|
||
|
||
{/*
|
||
* Coarse pointer: the cell is one big trigger, because a thumb scrolling a
|
||
* table cannot be asked to tell "open the entity" from "delete it". The
|
||
* links it covers come back inside the Drawer.
|
||
*
|
||
* `variant` is hardcoded because the container takes its policy from the
|
||
* call site (B42) — and here the policy is the `pointer:` media query on
|
||
* the trigger, not the viewport, so a touch laptop gets this sheet and a
|
||
* tablet with a mouse gets the inline editor above. `hover:bg-transparent`
|
||
* undoes the shared class's hover: this trigger is an invisible pane over
|
||
* the whole cell, and tinting it would tint the chips it covers.
|
||
*/}
|
||
<CampaignCellEditOverlay
|
||
variant="sheet"
|
||
open={drawerOpen}
|
||
// The container's trigger lets the click bubble to the cell root, which
|
||
// opens the inline suggestions — harmless while the sheet is up, but it
|
||
// would surface a portaled listbox on a touch device the moment the
|
||
// sheet closed. Clearing the search on close settles both.
|
||
//
|
||
// Guarded on the sheet having actually been open: a closed Drawer still
|
||
// reports `onOpenChange(false)` when a dismiss elsewhere on the page
|
||
// sweeps the layer stack, and unguarded this erased whatever the fine
|
||
// pointer had typed into the inline input.
|
||
onOpenChange={(next) => {
|
||
setDrawerOpen(next)
|
||
if (!next && drawerOpen) closeSuggestions()
|
||
}}
|
||
title={drawerTitle}
|
||
description={ownerName}
|
||
trigger={null}
|
||
triggerLabel={triggerLabel}
|
||
triggerClassName="absolute inset-0 hover:bg-transparent pointer-fine:hidden"
|
||
triggerBusy={isPending}
|
||
sheetBodyClassName="gap-3"
|
||
>
|
||
{/*
|
||
* Rendered unmounted, like the repo's other in-cell sheets: base-ui
|
||
* keeps the popup mounted through a ~450 ms exit transition, so gating
|
||
* this on `drawerOpen` animated an empty collapsing box out of view. The
|
||
* allocation it saved was measured and is not a cost — the closed portal
|
||
* emits no DOM, and the class string is a tailwind-merge cache hit.
|
||
*/}
|
||
<>
|
||
{chips.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">{copy.emptyDrawerMessage}</p>
|
||
) : (
|
||
<ul className="flex flex-col gap-1">
|
||
{chips.map((chip) => {
|
||
const floorReason = removalFloorReason(chip)
|
||
return (
|
||
<li
|
||
key={chip.key}
|
||
className="flex min-h-11 items-center justify-between gap-2 rounded-md border px-2"
|
||
>
|
||
{chip.href ? (
|
||
<Link
|
||
href={chip.href}
|
||
className="flex min-h-11 min-w-0 flex-1 items-center underline-offset-4 hover:underline"
|
||
>
|
||
<Badge variant="secondary" className="min-w-0 font-normal">
|
||
{chipBody(chip)}
|
||
</Badge>
|
||
</Link>
|
||
) : (
|
||
<Badge variant="secondary" className="min-w-0 gap-1 font-normal">
|
||
{chipBody(chip)}
|
||
</Badge>
|
||
)}
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className={cn('size-11 shrink-0', floorReason && 'opacity-50')}
|
||
aria-disabled={floorReason !== null || undefined}
|
||
aria-label={chipRemoveLabel(chip, floorReason)}
|
||
onClick={() => attemptRemove(chip)}
|
||
>
|
||
<XIcon className="size-4" aria-hidden="true" />
|
||
</Button>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
)}
|
||
<Input
|
||
value={query}
|
||
role="combobox"
|
||
aria-expanded={drawerSuggesting}
|
||
aria-controls={listboxId('drawer')}
|
||
aria-autocomplete="list"
|
||
aria-activedescendant={drawerSuggesting ? activeOptionId : undefined}
|
||
onChange={(event) => {
|
||
setQuery(event.currentTarget.value)
|
||
setFeedback(null)
|
||
}}
|
||
onKeyDown={onSearchKeyDown}
|
||
placeholder={copy.searchPlaceholder}
|
||
aria-label={copy.searchLabel}
|
||
className="min-h-11"
|
||
/>
|
||
{searching ? <div className="flex flex-col gap-1">{suggestionList('drawer')}</div> : null}
|
||
</>
|
||
</CampaignCellEditOverlay>
|
||
|
||
<p className="sr-only" role="status" aria-live="polite">
|
||
{statusMessage}
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|