Skip to content

Component-reuse violations: SearchPicker/Modal forks, Badge reimplemented 3x, FormError/EmptyState bypassed, missing shared hooks #1816

Description

@steilerDev

Severity: Medium (aggregate; two High items) — found during a deep project review (2026-07-06) against the Component Reuse Policy in CLAUDE.md. Compliant-for-balance: all *Pickers except the one below correctly wrap SearchPicker; all *Modal components use Modal; SuggestionBadge is legitimately not a Badge variant.

Parallel implementations of shared components

  • HIGH — components/milestones/WorkItemSelector.tsx is a full SearchPicker fork: reimplements debounced search (:77,167), click-outside close (:88-111), Escape handling (:98-102), dropdown open/loading/error state — even though WorkItemPicker (a SearchPicker wrapper for the same entity) already exists. Its own doc comment admits it "can replace linker search logic". Bonus: inline zIndex: 1100 in JS (:40).
  • HIGH — components/KeyboardShortcutsHelp/KeyboardShortcutsHelp.tsx:13-26 is a hand-rolled modal: own role="dialog" + backdrop + close button; unlike Modal it has no portal, no Escape handler, no initial-focus management.
  • components/BudgetHealthIndicator/BudgetHealthIndicator.tsx:60-64 reimplements Badge (own <span className={styles.badge}> + status→class variant map — literally Badge's variants: BadgeVariantMap API).
  • components/TimelineStatusCards/UpcomingMilestonesCard.tsx:51 — inline badge span with local .badgeGreen/.badgeRed classes.
  • components/SubsidyPipelineCard/SubsidyPipelineCard.tsx:51-56 — third private re-creation of Badge's variant-map pattern.
  • documents/DocumentSkeleton.tsx — parallel skeleton implementation (own shimmer CSS) rather than extending Skeleton (judgment call: card-shaped placeholders don't fit Skeleton's current props — policy says extend with props, not fork).
  • components/milestones/MilestonePanel.tsx hand-rolls dialog plumbing (createPortal :140,626, role="dialog" :101,364, own Escape :192-195) — judgment call (slide-in panel), but the plumbing should be extracted/shared.

Shared components bypassed wholesale

  • Ad-hoc error banners instead of FormError in 15+ places: <div className={styles.errorBanner} role="alert"> at pages/VendorsPage:460,594, pages/BudgetSourcesPage:757,769,950,1308, pages/WorkItemDetailPage:1333, pages/InvoicesPage:667, pages/MilestoneDetailPage:515, pages/DiaryEntryDetailPage:359, pages/MilestoneCreatePage:86, pages/HouseholdItemDetailPage:1393,1466.
  • Hand-rolled empty states duplicating EmptyState's API: pages/BudgetOverviewPage.tsx:323-325, pages/TimelinePage.tsx:589-604, plus simpler styles.emptyState divs in WorkItemDetailPage:1581,1669,1982,2016 (one with hardcoded English), InvoiceBudgetLinesSection:511,748, SubsidyProgramsPage:708, HouseholdItemDetailPage:1236, BudgetSourcesPage:938.
  • Plain-text styles.loading divs in 14+ pages instead of Skeleton (judgment call — Skeleton is line-oriented).

Missing shared hooks (logic copy-pasted)

  • No useDebounce — debounced-search logic duplicated 6×: SearchPicker.tsx:91,204, milestones/WorkItemSelector.tsx:77,167, documents/DocumentBrowser.tsx:42-53, pages/DiaryPage.tsx:57-68, pages/BudgetOverviewPage.tsx:45,153 (+ autosave debounce DiaryEntryEditPage.tsx:80,428).
  • No useClickOutside — duplicated 5×: SearchPicker.tsx:114, WorkItemSelector.tsx:88, DataTable/DataTableHeader.tsx:37, DataTable/DataTableColumnSettings.tsx:43, OverflowMenu/OverflowMenu.tsx:58.
  • Modal has no focus trapModal.tsx:32-41 only focuses the first element on mount; photos/PhotoMetadataModal.tsx:41-63 bolts a private Tab-cycling trap on top. The trap belongs in the shared Modal so every dialog gets it.

Cleanup

  • components/StatusBadge/ and components/HouseholdItemStatusBadge/ contain only test files (components were consolidated into Badge; remove stale directories).

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    Status
    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions