Initial commit: plan, spec, tickets and dashboard skill templates
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
49f8f49b65
39 files changed
+10736
No files matched your search
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
const GAP = 8
|
||||
|
||||
type Rect = { top: number; bottom: number; left: number; width: number }
|
||||
type Placed = { top: number; left: number }
|
||||
|
||||
/**
|
||||
* A tooltip that is actually part of the design.
|
||||
*
|
||||
* Replaces `title=""`, which cannot be styled, cannot be themed, appears after a
|
||||
* delay the page does not control, and renders in the OS's own font at the
|
||||
* cursor rather than against the thing it describes.
|
||||
*
|
||||
* Positioning is deliberately in two stages, and the order matters:
|
||||
*
|
||||
* 1. The anchor's rect is captured in the open handler, and the panel is drawn
|
||||
* immediately from it using a transform (`translate(-50%, -100%)`) — the
|
||||
* browser centres and lifts it with no measurement at all, so it is
|
||||
* correctly placed on the very first paint.
|
||||
* 2. A layout effect then measures the rendered panel and only refines: flip
|
||||
* below when there is no room above, clamp into the viewport at the edges.
|
||||
*
|
||||
* An earlier version skipped straight to stage 2 — render at 0,0 hidden, measure,
|
||||
* then position. When that measurement did not land the tooltip stayed invisible
|
||||
* in the corner, which is the worst failure available: the information silently
|
||||
* disappears. Now a failed refinement costs at most some clipping at a screen
|
||||
* edge, and the tooltip is always visible.
|
||||
*
|
||||
* Because the pane behind it scrolls, the captured rect can go stale — so any
|
||||
* scroll closes the tooltip rather than leaving it pointing at nothing.
|
||||
*
|
||||
* It is `aria-hidden` on purpose: the same fact is in the trigger's own
|
||||
* accessible name (see the field input's `aria-label`), so a screen reader loses
|
||||
* nothing when this never appears. Opens on hover AND on keyboard focus.
|
||||
*/
|
||||
export function Tooltip({
|
||||
label,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
/** Applied to the wrapper, which stands in for the element it replaces. */
|
||||
className?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const anchor = useRef<HTMLSpanElement>(null)
|
||||
const panel = useRef<HTMLDivElement>(null)
|
||||
const [rect, setRect] = useState<Rect | null>(null)
|
||||
const [placed, setPlaced] = useState<Placed | null>(null)
|
||||
|
||||
const open = () => {
|
||||
const r = anchor.current?.getBoundingClientRect()
|
||||
if (r) setRect({ top: r.top, bottom: r.bottom, left: r.left, width: r.width })
|
||||
}
|
||||
const close = () => {
|
||||
setRect(null)
|
||||
setPlaced(null)
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!rect) return
|
||||
const p = panel.current?.getBoundingClientRect()
|
||||
if (!p) return
|
||||
const flip = rect.top - p.height - GAP < GAP
|
||||
const top = flip ? rect.bottom + GAP : rect.top - p.height - GAP
|
||||
const wanted = rect.left + rect.width / 2 - p.width / 2
|
||||
const left = Math.max(GAP, Math.min(wanted, window.innerWidth - p.width - GAP))
|
||||
// Only take over from the transform when the transform would actually be
|
||||
// wrong; otherwise leave the first-paint placement exactly as it is.
|
||||
const drifted = Math.abs(left - wanted) > 0.5 || flip
|
||||
setPlaced(drifted ? { top, left } : null)
|
||||
}, [rect, label])
|
||||
|
||||
useEffect(() => {
|
||||
if (!rect) return
|
||||
// `true` for the capture phase: the scroll happens inside the review pane,
|
||||
// and scroll events on an inner element do not bubble to window.
|
||||
window.addEventListener('scroll', close, true)
|
||||
window.addEventListener('resize', close)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', close, true)
|
||||
window.removeEventListener('resize', close)
|
||||
}
|
||||
}, [rect])
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={anchor}
|
||||
className={className}
|
||||
// focus/blur do not bubble natively, but React synthesises these from
|
||||
// focusin/focusout, which do — so focusing the inner input opens it.
|
||||
onMouseEnter={open}
|
||||
onMouseLeave={close}
|
||||
onFocus={open}
|
||||
onBlur={close}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
|
||||
{rect &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={panel}
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none fixed z-50 w-max max-w-[18rem] rounded-lg px-2.5 py-1.5 font-plex text-[11px] font-medium leading-snug shadow-lg"
|
||||
style={
|
||||
placed
|
||||
? { top: placed.top, left: placed.left, backgroundColor: 'var(--ink)', color: 'var(--surface)' }
|
||||
: {
|
||||
top: rect.top - GAP,
|
||||
left: rect.left + rect.width / 2,
|
||||
// Centres and lifts with no measurement — this is what makes
|
||||
// the first paint correct rather than hidden.
|
||||
transform: 'translate(-50%, -100%)',
|
||||
// Inverted from the surface it floats over: a panel sharing
|
||||
// its host's background reads as part of the page instead of
|
||||
// above it. Both directions clear AAA against their own text.
|
||||
backgroundColor: 'var(--ink)',
|
||||
color: 'var(--surface)',
|
||||
}
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user