From 88f5f0e045551c57add84e4c0def051e9cdf8978 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Thu, 5 Mar 2026 06:14:11 -0800 Subject: [PATCH] Add redirect drop metadata and warning UI (#418) --- crates-cli/yaak-cli/src/context.rs | 3 +- crates-cli/yaak-cli/src/main.rs | 9 +- crates/yaak-http/src/sender.rs | 32 +++- crates/yaak-http/src/transaction.rs | 75 +++++++--- crates/yaak-models/bindings/gen_models.ts | 2 +- crates/yaak-models/src/models.rs | 4 + crates/yaak-plugins/bindings/gen_models.ts | 2 +- .../src/bindings/gen_models.ts | 2 +- plugins/auth-oauth2/src/index.ts | 1 - src-web/components/HttpResponsePane.tsx | 139 ++++++++++++++++-- src-web/components/HttpResponseTimeline.tsx | 31 +++- src-web/components/core/Tooltip.tsx | 63 +++++--- 12 files changed, 292 insertions(+), 71 deletions(-) diff --git a/crates-cli/yaak-cli/src/context.rs b/crates-cli/yaak-cli/src/context.rs index 518b4767..0e1a38f3 100644 --- a/crates-cli/yaak-cli/src/context.rs +++ b/crates-cli/yaak-cli/src/context.rs @@ -47,8 +47,7 @@ impl CliContext { std::process::exit(1); } }; - let encryption_manager = - Arc::new(EncryptionManager::new(query_manager.clone(), app_id)); + let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id)); Self { data_dir, diff --git a/crates-cli/yaak-cli/src/main.rs b/crates-cli/yaak-cli/src/main.rs index 33f79362..ba439c1f 100644 --- a/crates-cli/yaak-cli/src/main.rs +++ b/crates-cli/yaak-cli/src/main.rs @@ -162,8 +162,7 @@ fn resolve_send_execution_context( AnyRequest::GrpcRequest(r) => (Some(r.id), r.workspace_id), AnyRequest::WebsocketRequest(r) => (Some(r.id), r.workspace_id), }; - let cookie_jar_id = - resolve_cookie_jar_id(context, &workspace_id, explicit_cookie_jar_id)?; + let cookie_jar_id = resolve_cookie_jar_id(context, &workspace_id, explicit_cookie_jar_id)?; return Ok(CliExecutionContext { request_id, workspace_id: Some(workspace_id), @@ -184,8 +183,7 @@ fn resolve_send_execution_context( } if let Ok(workspace) = context.db().get_workspace(id) { - let cookie_jar_id = - resolve_cookie_jar_id(context, &workspace.id, explicit_cookie_jar_id)?; + let cookie_jar_id = resolve_cookie_jar_id(context, &workspace.id, explicit_cookie_jar_id)?; return Ok(CliExecutionContext { request_id: None, workspace_id: Some(workspace.id), @@ -213,8 +211,7 @@ fn resolve_request_execution_context( AnyRequest::GrpcRequest(r) => r.workspace_id, AnyRequest::WebsocketRequest(r) => r.workspace_id, }; - let cookie_jar_id = - resolve_cookie_jar_id(context, &workspace_id, explicit_cookie_jar_id)?; + let cookie_jar_id = resolve_cookie_jar_id(context, &workspace_id, explicit_cookie_jar_id)?; Ok(CliExecutionContext { request_id: Some(request_id.to_string()), diff --git a/crates/yaak-http/src/sender.rs b/crates/yaak-http/src/sender.rs index 9928ee36..32059a68 100644 --- a/crates/yaak-http/src/sender.rs +++ b/crates/yaak-http/src/sender.rs @@ -30,6 +30,8 @@ pub enum HttpResponseEvent { url: String, status: u16, behavior: RedirectBehavior, + dropped_body: bool, + dropped_headers: Vec, }, SendUrl { method: String, @@ -67,12 +69,28 @@ impl Display for HttpResponseEvent { match self { HttpResponseEvent::Setting(name, value) => write!(f, "* Setting {}={}", name, value), HttpResponseEvent::Info(s) => write!(f, "* {}", s), - HttpResponseEvent::Redirect { url, status, behavior } => { + HttpResponseEvent::Redirect { + url, + status, + behavior, + dropped_body, + dropped_headers, + } => { let behavior_str = match behavior { RedirectBehavior::Preserve => "preserve", RedirectBehavior::DropBody => "drop body", }; - write!(f, "* Redirect {} -> {} ({})", status, url, behavior_str) + let body_str = if *dropped_body { ", body dropped" } else { "" }; + let headers_str = if dropped_headers.is_empty() { + String::new() + } else { + format!(", headers dropped: {}", dropped_headers.join(", ")) + }; + write!( + f, + "* Redirect {} -> {} ({}{}{})", + status, url, behavior_str, body_str, headers_str + ) } HttpResponseEvent::SendUrl { method, @@ -130,13 +148,21 @@ impl From for yaak_models::models::HttpResponseEventData { match event { HttpResponseEvent::Setting(name, value) => D::Setting { name, value }, HttpResponseEvent::Info(message) => D::Info { message }, - HttpResponseEvent::Redirect { url, status, behavior } => D::Redirect { + HttpResponseEvent::Redirect { + url, + status, + behavior, + dropped_body, + dropped_headers, + } => D::Redirect { url, status, behavior: match behavior { RedirectBehavior::Preserve => "preserve".to_string(), RedirectBehavior::DropBody => "drop_body".to_string(), }, + dropped_body, + dropped_headers, }, HttpResponseEvent::SendUrl { method, diff --git a/crates/yaak-http/src/transaction.rs b/crates/yaak-http/src/transaction.rs index 353187d1..e7f135ae 100644 --- a/crates/yaak-http/src/transaction.rs +++ b/crates/yaak-http/src/transaction.rs @@ -1,7 +1,7 @@ use crate::cookies::CookieStore; use crate::error::Result; use crate::sender::{HttpResponse, HttpResponseEvent, HttpSender, RedirectBehavior}; -use crate::types::SendableHttpRequest; +use crate::types::{SendableBody, SendableHttpRequest}; use log::debug; use tokio::sync::mpsc; use tokio::sync::watch::Receiver; @@ -87,6 +87,11 @@ impl HttpTransaction { }; // Build request for this iteration + let preserved_body = match ¤t_body { + Some(SendableBody::Bytes(b)) => Some(SendableBody::Bytes(b.clone())), + _ => None, + }; + let request_had_body = current_body.is_some(); let req = SendableHttpRequest { url: current_url.clone(), method: current_method.clone(), @@ -182,8 +187,6 @@ impl HttpTransaction { format!("{}/{}", base_path, location) }; - Self::remove_sensitive_headers(&mut current_headers, &previous_url, ¤t_url); - // Determine redirect behavior based on status code and method let behavior = if status == 303 { // 303 See Other always changes to GET @@ -197,11 +200,8 @@ impl HttpTransaction { RedirectBehavior::Preserve }; - send_event(HttpResponseEvent::Redirect { - url: current_url.clone(), - status, - behavior: behavior.clone(), - }); + let mut dropped_headers = + Self::remove_sensitive_headers(&mut current_headers, &previous_url, ¤t_url); // Handle method changes for certain redirect codes if matches!(behavior, RedirectBehavior::DropBody) { @@ -211,13 +211,40 @@ impl HttpTransaction { // Remove content-related headers current_headers.retain(|h| { let name_lower = h.0.to_lowercase(); - !name_lower.starts_with("content-") && name_lower != "transfer-encoding" + let should_drop = + name_lower.starts_with("content-") || name_lower == "transfer-encoding"; + if should_drop { + Self::push_header_if_missing(&mut dropped_headers, &h.0); + } + !should_drop }); } - // Reset body for next iteration (since it was moved in the send call) - // For redirects that change method to GET or for all redirects since body was consumed - current_body = None; + // Restore body for Preserve redirects (307/308), drop for others. + // Stream bodies can't be replayed (same limitation as reqwest). + current_body = if matches!(behavior, RedirectBehavior::Preserve) { + if request_had_body && preserved_body.is_none() { + // Stream body was consumed and can't be replayed (same as reqwest) + return Err(crate::error::Error::RequestError( + "Cannot follow redirect: request body was a stream and cannot be resent" + .to_string(), + )); + } + preserved_body + } else { + None + }; + + // Body was dropped if the request had one but we can't resend it + let dropped_body = request_had_body && current_body.is_none(); + + send_event(HttpResponseEvent::Redirect { + url: current_url.clone(), + status, + behavior: behavior.clone(), + dropped_body, + dropped_headers, + }); redirect_count += 1; } @@ -231,7 +258,8 @@ impl HttpTransaction { headers: &mut Vec<(String, String)>, previous_url: &str, next_url: &str, - ) { + ) -> Vec { + let mut dropped_headers = Vec::new(); let previous_host = Url::parse(previous_url).ok().and_then(|u| { u.host_str().map(|h| format!("{}:{}", h, u.port_or_known_default().unwrap_or(0))) }); @@ -241,13 +269,24 @@ impl HttpTransaction { if previous_host != next_host { headers.retain(|h| { let name_lower = h.0.to_lowercase(); - name_lower != "authorization" - && name_lower != "cookie" - && name_lower != "cookie2" - && name_lower != "proxy-authorization" - && name_lower != "www-authenticate" + let should_drop = name_lower == "authorization" + || name_lower == "cookie" + || name_lower == "cookie2" + || name_lower == "proxy-authorization" + || name_lower == "www-authenticate"; + if should_drop { + Self::push_header_if_missing(&mut dropped_headers, &h.0); + } + !should_drop }); } + dropped_headers + } + + fn push_header_if_missing(headers: &mut Vec, name: &str) { + if !headers.iter().any(|h| h.eq_ignore_ascii_case(name)) { + headers.push(name.to_string()); + } } /// Check if a status code indicates a redirect diff --git a/crates/yaak-models/bindings/gen_models.ts b/crates/yaak-models/bindings/gen_models.ts index da7cf754..8fcf3467 100644 --- a/crates/yaak-models/bindings/gen_models.ts +++ b/crates/yaak-models/bindings/gen_models.ts @@ -49,7 +49,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea * This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support. * The `From` impl is in yaak-http to avoid circular dependencies. */ -export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array, duration: bigint, overridden: boolean, }; +export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array, duration: bigint, overridden: boolean, }; export type HttpResponseHeader = { name: string, value: string, }; diff --git a/crates/yaak-models/src/models.rs b/crates/yaak-models/src/models.rs index 119c775b..9c206b0a 100644 --- a/crates/yaak-models/src/models.rs +++ b/crates/yaak-models/src/models.rs @@ -1499,6 +1499,10 @@ pub enum HttpResponseEventData { url: String, status: u16, behavior: String, + #[serde(default)] + dropped_body: bool, + #[serde(default)] + dropped_headers: Vec, }, SendUrl { method: String, diff --git a/crates/yaak-plugins/bindings/gen_models.ts b/crates/yaak-plugins/bindings/gen_models.ts index a8f342f4..5d02b60b 100644 --- a/crates/yaak-plugins/bindings/gen_models.ts +++ b/crates/yaak-plugins/bindings/gen_models.ts @@ -62,7 +62,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea * This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support. * The `From` impl is in yaak-http to avoid circular dependencies. */ -export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array, duration: bigint, overridden: boolean, }; +export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array, duration: bigint, overridden: boolean, }; export type HttpResponseHeader = { name: string, value: string, }; diff --git a/packages/plugin-runtime-types/src/bindings/gen_models.ts b/packages/plugin-runtime-types/src/bindings/gen_models.ts index a8f342f4..5d02b60b 100644 --- a/packages/plugin-runtime-types/src/bindings/gen_models.ts +++ b/packages/plugin-runtime-types/src/bindings/gen_models.ts @@ -62,7 +62,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea * This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support. * The `From` impl is in yaak-http to avoid circular dependencies. */ -export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array, duration: bigint, overridden: boolean, }; +export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array, duration: bigint, overridden: boolean, }; export type HttpResponseHeader = { name: string, value: string, }; diff --git a/plugins/auth-oauth2/src/index.ts b/plugins/auth-oauth2/src/index.ts index 07bfec8d..3dd55a2f 100644 --- a/plugins/auth-oauth2/src/index.ts +++ b/plugins/auth-oauth2/src/index.ts @@ -9,7 +9,6 @@ import type { Algorithm } from 'jsonwebtoken'; import { buildHostedCallbackRedirectUri, DEFAULT_LOCALHOST_PORT, - HOSTED_CALLBACK_URL_BASE, stopActiveServer, } from './callbackServer'; import { diff --git a/src-web/components/HttpResponsePane.tsx b/src-web/components/HttpResponsePane.tsx index de22aaf1..6f1059e7 100644 --- a/src-web/components/HttpResponsePane.tsx +++ b/src-web/components/HttpResponsePane.tsx @@ -1,4 +1,4 @@ -import type { HttpResponse } from '@yaakapp-internal/models'; +import type { HttpResponse, HttpResponseEvent } from '@yaakapp-internal/models'; import classNames from 'classnames'; import type { ComponentType, CSSProperties } from 'react'; import { lazy, Suspense, useMemo } from 'react'; @@ -18,11 +18,14 @@ import { CountBadge } from './core/CountBadge'; import { HotkeyList } from './core/HotkeyList'; import { HttpResponseDurationTag } from './core/HttpResponseDurationTag'; import { HttpStatusTag } from './core/HttpStatusTag'; +import { Icon } from './core/Icon'; import { LoadingIcon } from './core/LoadingIcon'; +import { PillButton } from './core/PillButton'; import { SizeTag } from './core/SizeTag'; import { HStack, VStack } from './core/Stacks'; import type { TabItem } from './core/Tabs/Tabs'; import { TabContent, Tabs } from './core/Tabs/Tabs'; +import { Tooltip } from './core/Tooltip'; import { EmptyStateText } from './EmptyStateText'; import { ErrorBoundary } from './ErrorBoundary'; import { HttpResponseTimeline } from './HttpResponseTimeline'; @@ -57,6 +60,11 @@ const TAB_TIMELINE = 'timeline'; export type TimelineViewMode = 'timeline' | 'text'; +interface RedirectDropWarning { + droppedBodyCount: number; + droppedHeaders: string[]; +} + export function HttpResponsePane({ style, className, activeRequestId }: Props) { const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId); const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId); @@ -65,6 +73,12 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence; const responseEvents = useHttpResponseEvents(activeResponse); + const redirectDropWarning = useMemo( + () => getRedirectDropWarning(responseEvents.data), + [responseEvents.data], + ); + const shouldShowRedirectDropWarning = + activeResponse?.state === 'closed' && redirectDropWarning != null; const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]); @@ -162,32 +176,77 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { )} > {activeResponse && ( - - {activeResponse.state !== 'closed' && } - - - - - - -
+ + {activeResponse.state !== 'closed' && } + + + + + + + {shouldShowRedirectDropWarning ? ( + + + Redirect changed this request + + {redirectDropWarning.droppedBodyCount > 0 && ( + + Body dropped on {redirectDropWarning.droppedBodyCount}{' '} + {redirectDropWarning.droppedBodyCount === 1 + ? 'redirect hop' + : 'redirect hops'} + + )} + {redirectDropWarning.droppedHeaders.length > 0 && ( + + Headers dropped:{' '} + + {redirectDropWarning.droppedHeaders.join(', ')} + + + )} + See Timeline for details. + + } + > + + } + > + + {getRedirectWarningLabel(redirectDropWarning)} + + + + + ) : ( + + )} +
- +
)}
@@ -274,6 +333,54 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { ); } +function getRedirectDropWarning( + events: HttpResponseEvent[] | undefined, +): RedirectDropWarning | null { + if (events == null || events.length === 0) return null; + + let droppedBodyCount = 0; + const droppedHeaders = new Set(); + for (const e of events) { + const event = e.event; + if (event.type !== 'redirect') { + continue; + } + + if (event.dropped_body) { + droppedBodyCount += 1; + } + for (const headerName of event.dropped_headers ?? []) { + pushHeaderName(droppedHeaders, headerName); + } + } + + if (droppedBodyCount === 0 && droppedHeaders.size === 0) { + return null; + } + + return { + droppedBodyCount, + droppedHeaders: Array.from(droppedHeaders).sort(), + }; +} + +function pushHeaderName(headers: Set, headerName: string): void { + const existing = Array.from(headers).find((h) => h.toLowerCase() === headerName.toLowerCase()); + if (existing == null) { + headers.add(headerName); + } +} + +function getRedirectWarningLabel(warning: RedirectDropWarning): string { + if (warning.droppedBodyCount > 0 && warning.droppedHeaders.length > 0) { + return 'Dropped body and headers'; + } + if (warning.droppedBodyCount > 0) { + return 'Dropped body'; + } + return 'Dropped headers'; +} + function EnsureCompleteResponse({ response, Component, diff --git a/src-web/components/HttpResponseTimeline.tsx b/src-web/components/HttpResponseTimeline.tsx index 939d653c..22840853 100644 --- a/src-web/components/HttpResponseTimeline.tsx +++ b/src-web/components/HttpResponseTimeline.tsx @@ -187,6 +187,7 @@ function EventDetails({ // Redirect - show status, URL, and behavior if (e.type === 'redirect') { + const droppedHeaders = e.dropped_headers ?? []; return ( @@ -196,6 +197,10 @@ function EventDetails({ {e.behavior === 'drop_body' ? 'Drop body, change to GET' : 'Preserve method and body'} + {e.dropped_body ? 'Yes' : 'No'} + + {droppedHeaders.length > 0 ? droppedHeaders.join(', ') : '--'} + ); } @@ -268,7 +273,17 @@ function getEventTextParts(event: HttpResponseEventData): EventTextParts { return { prefix: '<', text: `${event.name}: ${event.value}` }; case 'redirect': { const behavior = event.behavior === 'drop_body' ? 'drop body' : 'preserve'; - return { prefix: '*', text: `Redirect ${event.status} -> ${event.url} (${behavior})` }; + const droppedHeaders = event.dropped_headers ?? []; + const dropped = [ + event.dropped_body ? 'body dropped' : null, + droppedHeaders.length > 0 ? `headers dropped: ${droppedHeaders.join(', ')}` : null, + ] + .filter(Boolean) + .join(', '); + return { + prefix: '*', + text: `Redirect ${event.status} -> ${event.url} (${behavior}${dropped ? `, ${dropped}` : ''})`, + }; } case 'setting': return { prefix: '*', text: `Setting ${event.name}=${event.value}` }; @@ -323,13 +338,23 @@ function getEventDisplay(event: HttpResponseEventData): EventDisplay { label: 'Info', summary: event.message, }; - case 'redirect': + case 'redirect': { + const droppedHeaders = event.dropped_headers ?? []; + const dropped = [ + event.dropped_body ? 'drop body' : null, + droppedHeaders.length > 0 + ? `drop ${droppedHeaders.length} ${droppedHeaders.length === 1 ? 'header' : 'headers'}` + : null, + ] + .filter(Boolean) + .join(', '); return { icon: 'arrow_big_right_dash', color: 'success', label: 'Redirect', - summary: `Redirecting ${event.status} ${event.url}${event.behavior === 'drop_body' ? ' (drop body)' : ''}`, + summary: `Redirecting ${event.status} ${event.url}${dropped ? ` (${dropped})` : ''}`, }; + } case 'send_url': return { icon: 'arrow_big_up_dash', diff --git a/src-web/components/core/Tooltip.tsx b/src-web/components/core/Tooltip.tsx index 0ec86063..563eb854 100644 --- a/src-web/components/core/Tooltip.tsx +++ b/src-web/components/core/Tooltip.tsx @@ -20,8 +20,15 @@ const hiddenStyles: CSSProperties = { opacity: 0, }; +type TooltipPosition = 'top' | 'bottom'; + +interface TooltipOpenState { + styles: CSSProperties; + position: TooltipPosition; +} + export function Tooltip({ children, className, content, tabIndex, size = 'md' }: TooltipProps) { - const [isOpen, setIsOpen] = useState(); + const [openState, setOpenState] = useState(null); const triggerRef = useRef(null); const tooltipRef = useRef(null); const showTimeout = useRef(undefined); @@ -29,16 +36,25 @@ export function Tooltip({ children, className, content, tabIndex, size = 'md' }: const handleOpenImmediate = () => { if (triggerRef.current == null || tooltipRef.current == null) return; clearTimeout(showTimeout.current); - setIsOpen(undefined); const triggerRect = triggerRef.current.getBoundingClientRect(); const tooltipRect = tooltipRef.current.getBoundingClientRect(); - const docRect = document.documentElement.getBoundingClientRect(); + const viewportHeight = document.documentElement.clientHeight; + + const margin = 8; + const spaceAbove = Math.max(0, triggerRect.top - margin); + const spaceBelow = Math.max(0, viewportHeight - triggerRect.bottom - margin); + const preferBottom = spaceAbove < tooltipRect.height + margin && spaceBelow > spaceAbove; + const position: TooltipPosition = preferBottom ? 'bottom' : 'top'; + const styles: CSSProperties = { - bottom: docRect.height - triggerRect.top, left: Math.max(0, triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2), - maxHeight: triggerRect.top, + maxHeight: position === 'top' ? spaceAbove : spaceBelow, + ...(position === 'top' + ? { bottom: viewportHeight - triggerRect.top } + : { top: triggerRect.bottom }), }; - setIsOpen(styles); + + setOpenState({ styles, position }); }; const handleOpen = () => { @@ -48,16 +64,16 @@ export function Tooltip({ children, className, content, tabIndex, size = 'md' }: const handleClose = () => { clearTimeout(showTimeout.current); - setIsOpen(undefined); + setOpenState(null); }; const handleToggleImmediate = () => { - if (isOpen) handleClose(); + if (openState) handleClose(); else handleOpenImmediate(); }; const handleKeyDown = (e: KeyboardEvent) => { - if (isOpen && e.key === 'Escape') { + if (openState && e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); handleClose(); @@ -71,10 +87,10 @@ export function Tooltip({ children, className, content, tabIndex, size = 'md' }: - + {/** biome-ignore lint/a11y/useSemanticElements: Needs to be usable in other buttons */} Triangle - +