Files
hermes-config/skills/self-hosting/shop-pro-quote/references/ui-pitfalls.md
T
2026-07-12 10:17:17 -04:00

5.3 KiB

UI Pitfalls & Patterns

Dropdown Stacking Context (z-index clipping)

Symptom: Service search dropdown (#service-search-results) is clipped/hidden by the quote summary section or accordion panels, despite z-index: 99999 !important.

Root cause: A parent element creates a stacking context (overflow: hidden, transform, opacity < 1, will-change). The dropdown's z-index is scoped within that parent and cannot escape. The accordion panels use overflow: hidden for collapse animations.

Fix — Portal/teleport pattern (applied in main.js and quote-tab-manager.js):

  1. In renderServiceResults(), move dropdown to #dropdown-root on first open
  2. Position with position: fixed + getBoundingClientRect() on search input
  3. Add scroll/resize listeners to keep dropdown anchored to input
// Before showing dropdown:
const dropdownRoot = document.getElementById('dropdown-root');
if (dropdownRoot && resultsEl.parentElement !== dropdownRoot) {
    dropdownRoot.appendChild(resultsEl);
}
const rect = input.getBoundingClientRect();
resultsEl.style.position = 'fixed';
resultsEl.style.top = (rect.bottom + 4) + 'px';
resultsEl.style.left = rect.left + 'px';
resultsEl.style.width = rect.width + 'px';

// In initialization, add:
const reposition = () => {
    if (resultsEl.classList.contains('hidden')) return;
    const rect = input.getBoundingClientRect();
    resultsEl.style.top = (rect.bottom + 4) + 'px';
    resultsEl.style.left = rect.left + 'px';
    resultsEl.style.width = rect.width + 'px';
};
window.addEventListener('scroll', reposition, { passive: true });
window.addEventListener('resize', reposition);

All pages have #dropdown-root at end of <body>: index.html, dashboard.html, repair-orders.html, appointments.html, customers.html.

State Clearing Before RO Population

React Custom Dropdown (v2)

When you need a styled dropdown that always opens below the trigger (native <select> lets the OS decide direction), build a custom one:

Structure

const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);

// Click-outside-to-close
useEffect(() => {
  if (!open) return;
  const handler = (e: MouseEvent) => {
    if (ref.current && !ref.current.contains(e.target as Node)) {
      setOpen(false);
    }
  };
  document.addEventListener('mousedown', handler);
  return () => document.removeEventListener('mousedown', handler);
}, [open]);

return (
  <div className="relative" ref={ref}>          {/* relative anchors the panel */}
    <button onClick={() => setOpen(o => !o)}>    {/* trigger */}
      <span>Placeholder</span>
      <ChevronDown className={open ? 'rotate-180' : ''} />
    </button>

    {open && (
      <div className="absolute z-50 mt-2 w-full max-h-72 overflow-y-auto rounded-xl border bg-white shadow-xl">
        {items.map((item, i) => (
          <button
            key={item.id}
            onClick={() => { setOpen(false); navigate(`/target/${item.id}`); }}
            className={`w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-gray-50 ${
              i > 0 ? 'border-t' : ''
            }`}
          >
            {/* Left: primary info */}
            <div className="min-w-0 flex-1">
              <div className="flex items-center gap-2">
                <span className="truncate text-sm font-medium">{item.name}</span>
                <span className="shrink-0 text-xs text-gray-400">#{item.id}</span>
              </div>
              <p className="truncate text-xs text-gray-500">{item.subtitle}</p>
            </div>
            {/* Right: secondary info */}
            <div className="shrink-0 flex flex-col items-end gap-1">
              <span className="text-sm font-semibold">{item.value}</span>
              <StatusBadge status={item.status} />
            </div>
          </button>
        ))}
      </div>
    )}
  </div>
);

Key details

Detail Why
parent <div className="relative"> Required — without it, the absolute panel positions relative to the nearest positioned ancestor (often the viewport)
z-50 Stacks above surrounding content
mt-2 8px gap between trigger and panel
mousedown listener (not click) Fires earlier, feels more responsive
open ? 'rotate-180' : '' Chevron rotation gives clear open/closed affordance
hover:bg-gray-50 with border-t Clear item boundaries + hover highlight
setOpen(false) before navigate() Prevents stale panel state if user navigates back

When to use vs. native <select>

  • Native <select>: Good for simple value-picking forms (settings, filters) where the OS dropdown look is fine
  • Custom dropdown: Good for navigation, rich item layout (multiple lines, badges, icons), or when you need the dropdown to always open downward

Bug: Clicking "Generate Quote" from a repair order loads correct customer info but retains services from whatever quote was previously open.

Fix: In populateROData() in quote-tab-manager.js, call quoteStateManager.clearSelectedServices() BEFORE any other operations:

function populateROData(roData) {
    // CRITICAL: clear old services first
    quoteStateManager.clearSelectedServices();
    
    const elements = getQuoteDomElements();
    // ... populate customer info ...
    // ... add RO services ...
}