134 lines
5.3 KiB
Markdown
134 lines
5.3 KiB
Markdown
# 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
|
|
|
|
```js
|
|
// 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
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```js
|
|
function populateROData(roData) {
|
|
// CRITICAL: clear old services first
|
|
quoteStateManager.clearSelectedServices();
|
|
|
|
const elements = getQuoteDomElements();
|
|
// ... populate customer info ...
|
|
// ... add RO services ...
|
|
}
|
|
```
|