feat: Implement compatibility strategies for responses and chat endpoints
- Added `routeCompatibility.ts` to handle response compatibility for different endpoints. - Introduced `chatEndpointStrategy.ts` to manage chat endpoint recovery and downgrading logic. - Created tests for chat formats and input file handling in `chatFormatsCore.test.ts` and `inputFile.test.ts`. - Developed `endpointCompatibility.ts` to define compatibility checks and header building for various endpoints. - Implemented protocol lifecycle management in `protocolLifecycle.ts` and corresponding tests in `protocolModel.test.ts`. - Enhanced tooltip functionality with a new `TooltipLayer` component and associated tests. - Refactored existing components to improve tooltip handling and ensure proper rendering in the application.
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
type TooltipSide = 'top' | 'bottom';
|
||||
type TooltipAlign = 'start' | 'center' | 'end';
|
||||
|
||||
type ActiveTooltip = {
|
||||
target: HTMLElement;
|
||||
text: string;
|
||||
side: TooltipSide;
|
||||
align: TooltipAlign;
|
||||
};
|
||||
|
||||
type TooltipPosition = {
|
||||
left: number;
|
||||
top: number;
|
||||
arrowLeft: number;
|
||||
side: TooltipSide;
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function readTooltipSide(target: HTMLElement): TooltipSide {
|
||||
return target.getAttribute('data-tooltip-side') === 'bottom' ? 'bottom' : 'top';
|
||||
}
|
||||
|
||||
function readTooltipAlign(target: HTMLElement): TooltipAlign {
|
||||
const align = target.getAttribute('data-tooltip-align');
|
||||
if (align === 'start' || align === 'end') return align;
|
||||
return 'center';
|
||||
}
|
||||
|
||||
function resolveTooltipTarget(eventTarget: EventTarget | null): HTMLElement | null {
|
||||
if (!(eventTarget instanceof Element)) return null;
|
||||
const target = eventTarget.closest<HTMLElement>('[data-tooltip]');
|
||||
if (!target) return null;
|
||||
const text = target.getAttribute('data-tooltip');
|
||||
return text && text.trim() ? target : null;
|
||||
}
|
||||
|
||||
export default function TooltipLayer() {
|
||||
const [activeTooltip, setActiveTooltip] = useState<ActiveTooltip | null>(null);
|
||||
const [position, setPosition] = useState<TooltipPosition | null>(null);
|
||||
const bubbleRef = useRef<HTMLDivElement | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
const cancelFrame = useCallback(() => {
|
||||
if (rafRef.current === null || typeof window === 'undefined') return;
|
||||
window.cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}, []);
|
||||
|
||||
const hideTooltip = useCallback(() => {
|
||||
cancelFrame();
|
||||
setActiveTooltip(null);
|
||||
setPosition(null);
|
||||
}, [cancelFrame]);
|
||||
|
||||
const showTooltipForTarget = useCallback((target: HTMLElement | null) => {
|
||||
if (!target) {
|
||||
hideTooltip();
|
||||
return;
|
||||
}
|
||||
const text = target.getAttribute('data-tooltip')?.trim();
|
||||
if (!text) {
|
||||
hideTooltip();
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveTooltip({
|
||||
target,
|
||||
text,
|
||||
side: readTooltipSide(target),
|
||||
align: readTooltipAlign(target),
|
||||
});
|
||||
}, [hideTooltip]);
|
||||
|
||||
const refreshPosition = useCallback(() => {
|
||||
if (!activeTooltip || !bubbleRef.current || typeof window === 'undefined') return;
|
||||
if (!activeTooltip.target.isConnected) {
|
||||
hideTooltip();
|
||||
return;
|
||||
}
|
||||
|
||||
const targetRect = activeTooltip.target.getBoundingClientRect();
|
||||
const bubbleRect = bubbleRef.current.getBoundingClientRect();
|
||||
const viewportPadding = 12;
|
||||
const gap = 10;
|
||||
|
||||
let left = targetRect.left;
|
||||
if (activeTooltip.align === 'center') {
|
||||
left = targetRect.left + targetRect.width / 2 - bubbleRect.width / 2;
|
||||
} else if (activeTooltip.align === 'end') {
|
||||
left = targetRect.right - bubbleRect.width;
|
||||
}
|
||||
|
||||
let top = activeTooltip.side === 'bottom'
|
||||
? targetRect.bottom + gap
|
||||
: targetRect.top - gap - bubbleRect.height;
|
||||
|
||||
left = clamp(left, viewportPadding, window.innerWidth - viewportPadding - bubbleRect.width);
|
||||
top = clamp(top, viewportPadding, window.innerHeight - viewportPadding - bubbleRect.height);
|
||||
|
||||
const targetCenter = targetRect.left + targetRect.width / 2;
|
||||
const arrowLeft = clamp(targetCenter - left, 14, bubbleRect.width - 14);
|
||||
|
||||
setPosition({
|
||||
left,
|
||||
top,
|
||||
arrowLeft,
|
||||
side: activeTooltip.side,
|
||||
});
|
||||
}, [activeTooltip, hideTooltip]);
|
||||
|
||||
const scheduleRefresh = useCallback(() => {
|
||||
cancelFrame();
|
||||
if (typeof window === 'undefined') return;
|
||||
rafRef.current = window.requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
refreshPosition();
|
||||
});
|
||||
}, [cancelFrame, refreshPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.body.dataset.tooltipPortal = 'true';
|
||||
return () => {
|
||||
delete document.body.dataset.tooltipPortal;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const handleMouseOver = (event: MouseEvent) => {
|
||||
showTooltipForTarget(resolveTooltipTarget(event.target));
|
||||
};
|
||||
|
||||
const handleFocusIn = (event: FocusEvent) => {
|
||||
showTooltipForTarget(resolveTooltipTarget(event.target));
|
||||
};
|
||||
|
||||
const handleMouseOut = (event: MouseEvent) => {
|
||||
if (!activeTooltip) return;
|
||||
const nextTarget = resolveTooltipTarget(event.relatedTarget);
|
||||
if (nextTarget === activeTooltip.target) return;
|
||||
if (event.relatedTarget instanceof Node && activeTooltip.target.contains(event.relatedTarget)) return;
|
||||
hideTooltip();
|
||||
};
|
||||
|
||||
const handleFocusOut = (event: FocusEvent) => {
|
||||
if (!activeTooltip) return;
|
||||
const nextTarget = resolveTooltipTarget(event.relatedTarget);
|
||||
if (nextTarget === activeTooltip.target) return;
|
||||
if (event.relatedTarget instanceof Node && activeTooltip.target.contains(event.relatedTarget)) return;
|
||||
hideTooltip();
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: Event) => {
|
||||
if (!activeTooltip) return;
|
||||
if (event.target instanceof Node && activeTooltip.target.contains(event.target)) return;
|
||||
hideTooltip();
|
||||
};
|
||||
|
||||
document.addEventListener('mouseover', handleMouseOver);
|
||||
document.addEventListener('focusin', handleFocusIn);
|
||||
document.addEventListener('mouseout', handleMouseOut);
|
||||
document.addEventListener('focusout', handleFocusOut);
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mouseover', handleMouseOver);
|
||||
document.removeEventListener('focusin', handleFocusIn);
|
||||
document.removeEventListener('mouseout', handleMouseOut);
|
||||
document.removeEventListener('focusout', handleFocusOut);
|
||||
document.removeEventListener('pointerdown', handlePointerDown);
|
||||
};
|
||||
}, [activeTooltip, hideTooltip, showTooltipForTarget]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!activeTooltip) return;
|
||||
setPosition(null);
|
||||
scheduleRefresh();
|
||||
}, [activeTooltip, scheduleRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTooltip || typeof window === 'undefined') return;
|
||||
const handleViewportChange = () => scheduleRefresh();
|
||||
window.addEventListener('resize', handleViewportChange);
|
||||
window.addEventListener('scroll', handleViewportChange, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleViewportChange);
|
||||
window.removeEventListener('scroll', handleViewportChange, true);
|
||||
};
|
||||
}, [activeTooltip, scheduleRefresh]);
|
||||
|
||||
useEffect(() => () => cancelFrame(), [cancelFrame]);
|
||||
|
||||
if (!activeTooltip || typeof document === 'undefined') return null;
|
||||
|
||||
const tooltip = (
|
||||
<div className="tooltip-layer" aria-hidden="true">
|
||||
<div
|
||||
ref={bubbleRef}
|
||||
className={`tooltip-bubble tooltip-bubble-${position?.side ?? activeTooltip.side} ${position ? 'is-visible' : ''}`.trim()}
|
||||
style={position ? {
|
||||
position: 'fixed',
|
||||
left: position.left,
|
||||
top: position.top,
|
||||
} : {
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 0,
|
||||
visibility: 'hidden',
|
||||
}}
|
||||
>
|
||||
{activeTooltip.text}
|
||||
<span
|
||||
className={`tooltip-bubble-arrow tooltip-bubble-arrow-${position?.side ?? activeTooltip.side}`}
|
||||
style={position ? { left: position.arrowLeft } : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(tooltip, document.body);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('TooltipLayer component', () => {
|
||||
it('uses a portal-based fixed tooltip layer and is mounted by App', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/components/TooltipLayer.tsx'), 'utf8');
|
||||
const appSource = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain('createPortal');
|
||||
expect(source).toContain('[data-tooltip]');
|
||||
expect(source).toContain("position: 'fixed'");
|
||||
expect(source).toContain('document.body.dataset.tooltipPortal');
|
||||
expect(appSource).toContain("import TooltipLayer from './components/TooltipLayer.js'");
|
||||
expect(appSource).toContain('<TooltipLayer />');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user