Normalizes manufacturer as a shared catalog entity (like device categories) instead of free text on each device, giving the admin duplicate-detection nudge a reliable signal. Adds full CRUD (including Admin direct-publish, bypassing the submission queue) for categories, manufacturers, port types, and cable types, plus a Categories & Manufacturers library modal and a browse-by-manufacturer/search view in the device palette. Adds Port.builtInCable so a captive/permanently-attached cable (a keyboard's USB lead, a budget AVR's power cord) can be flagged and excluded from the BOM. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017DUU6CnxECCDeqDNYJgr5x
84 lines
3.3 KiB
TypeScript
84 lines
3.3 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
|
|
const POPOVER_WIDTH = 256
|
|
const VIEWPORT_MARGIN = 8
|
|
|
|
/**
|
|
* A small "i" affordance that reveals a block of help text on click — for a
|
|
* concept that needs more than a one-line inline hint can carry, without
|
|
* permanently taking up screen space. Click-toggled rather than hover-only
|
|
* so it works the same on touch as with a mouse, and stays open long enough
|
|
* to actually read instead of vanishing the moment the pointer drifts.
|
|
*
|
|
* Renders the popover through a portal to `document.body`, positioned via
|
|
* the trigger's own bounding rect rather than as a normal absolutely-
|
|
* positioned child — this component is mostly used inside a Modal, whose
|
|
* scrollable body clips anything (including absolutely-positioned children)
|
|
* that pokes past its edge, which cut the popover off before this existed.
|
|
*/
|
|
export default function InfoTooltip({ text }: { text: string }) {
|
|
const [open, setOpen] = useState(false)
|
|
const [position, setPosition] = useState<{ top: number; left: number } | null>(null)
|
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
|
const popoverRef = useRef<HTMLDivElement>(null)
|
|
|
|
const toggle = () => {
|
|
if (!open && buttonRef.current) {
|
|
const rect = buttonRef.current.getBoundingClientRect()
|
|
const left = Math.min(Math.max(rect.left, VIEWPORT_MARGIN), window.innerWidth - POPOVER_WIDTH - VIEWPORT_MARGIN)
|
|
setPosition({ top: rect.bottom + 4, left })
|
|
}
|
|
setOpen((v) => !v)
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
const onOutsideClick = (e: MouseEvent) => {
|
|
const target = e.target as Node
|
|
if (buttonRef.current?.contains(target) || popoverRef.current?.contains(target)) return
|
|
setOpen(false)
|
|
}
|
|
// Also close on scroll (e.g. the modal body it's usually opened inside)
|
|
// rather than tracking the trigger's position live — simplest way to
|
|
// avoid a stale popover that's drifted away from its "i" icon.
|
|
const close = () => setOpen(false)
|
|
document.addEventListener('mousedown', onOutsideClick)
|
|
document.addEventListener('scroll', close, true)
|
|
return () => {
|
|
document.removeEventListener('mousedown', onOutsideClick)
|
|
document.removeEventListener('scroll', close, true)
|
|
}
|
|
}, [open])
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
ref={buttonRef}
|
|
type="button"
|
|
onClick={toggle}
|
|
title="More info"
|
|
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-0 bg-transparent p-0 text-slate-400 hover:text-slate-600"
|
|
>
|
|
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-4 w-4">
|
|
<circle cx="10" cy="10" r="8.25" stroke="currentColor" strokeWidth="1.5" />
|
|
<path d="M10 9.5v5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
<circle cx="10" cy="6.5" r="1" fill="currentColor" />
|
|
</svg>
|
|
</button>
|
|
{open &&
|
|
position &&
|
|
createPortal(
|
|
<div
|
|
ref={popoverRef}
|
|
style={{ position: 'fixed', top: position.top, left: position.left, width: POPOVER_WIDTH }}
|
|
className="z-[60] rounded border border-slate-200 bg-white p-2 text-[11px] font-normal normal-case leading-snug text-slate-600 shadow-lg"
|
|
>
|
|
{text}
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
</>
|
|
)
|
|
}
|