fix(gui): address review — stale smoke case, tooltip trigger overlap, collapse contract docs

- The real-browser collapse smoke tracked the old chrome: visible HARNESS
  text (the wordmark svg is aria-hidden now), an 'Expand sidebar' label
  (renamed 'Open sidebar'), a 300px settle (default is 280), and an
  immediate focus assert (rail search defers focus past the slide). The
  case now tracks the brand span, polls the deferred focus, and uses the
  current labels and width.
- Tooltip treated hover and focus as one trigger: leaving with the mouse
  dropped the bubble of a still-focused anchor (and vice versa). The two
  triggers are tracked independently; the bubble hides only after both
  clear. Spec pins both orders.
- The ui-sidebar README and the bilingual collapse note still described
  the retired geometry morph; both now state the slide + crossfade
  contract, the fixed-width (never-conceding) sidebar, and the rail's
  whale-mark/tooltip chrome.
This commit is contained in:
imccyu
2026-07-24 01:30:01 +08:00
parent 9dbe11d647
commit 75d37654cb
8 changed files with 53 additions and 23 deletions

View File

@@ -34,11 +34,14 @@ interface AnchorProps {
export function Tooltip({ label, side = 'right', disabled = false, children }: { label: string; side?: TooltipSide; disabled?: boolean; children: ReactElement<AnchorProps> }) {
const anchor = useRef<HTMLElement | null>(null)
const [pos, setPos] = useState<{ x: number; y: number } | null>(null)
// Hover and focus are independent triggers: the bubble hides only after
// BOTH clear (hovering away from a focused anchor must not drop it).
const triggers = useRef({ hover: false, focus: false })
// Disabling mid-hover (e.g. clicking a rail control expands the sidebar)
// must drop an already-visible bubble: no mouseleave fires.
useEffect(() => {
if (disabled) setPos(null)
if (disabled) { triggers.current = { hover: false, focus: false }; setPos(null) }
}, [disabled])
const show = () => {
@@ -51,16 +54,18 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: {
? { x: r.right + 10, y: r.top + r.height / 2 }
: { x: r.left + r.width / 2, y: r.bottom + 8 })
}
const hide = () => { setPos(null) }
const hide = () => {
if (!triggers.current.hover && !triggers.current.focus) setPos(null)
}
return (
<>
{cloneElement(children, {
ref: anchor,
onMouseEnter: (e) => { children.props.onMouseEnter?.(e); show() },
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); hide() },
onFocus: (e) => { children.props.onFocus?.(e); show() },
onBlur: (e) => { children.props.onBlur?.(e); hide() },
onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; show() },
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; hide() },
onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; show() },
onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() },
})}
{pos !== null && (
<span className={css.bubble} data-side={side} style={{ left: pos.x, top: pos.y }} role="tooltip">