diff --git a/pages/tooltip/components-usage.page.tsx b/pages/tooltip/components-usage.page.tsx new file mode 100644 index 0000000000..97640e426b --- /dev/null +++ b/pages/tooltip/components-usage.page.tsx @@ -0,0 +1,2632 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +// import Badge from '~components/badge'; +import Box from '~components/box'; +import BreadcrumbGroup from '~components/breadcrumb-group'; +import Button from '~components/button'; +import ButtonDropdown from '~components/button-dropdown'; +import ButtonGroup from '~components/button-group'; +import Calendar from '~components/calendar'; +import Container from '~components/container'; +import DateRangePicker from '~components/date-range-picker'; +import Header from '~components/header'; +// import Icon from '~components/icon'; +// import Link from '~components/link'; +import SegmentedControl from '~components/segmented-control'; +import Select from '~components/select'; +import Slider from '~components/slider'; +import SpaceBetween from '~components/space-between'; +// import StatusIndicator from '~components/status-indicator'; +import Tabs from '~components/tabs'; +import TokenGroup from '~components/token-group'; +// import Tooltip from '~components/tooltip'; + +const actionsItems = [ + { id: 'connect', text: 'Connect', disabledReason: 'Instance must be running.', disabled: true }, + { id: 'details', text: 'View details', disabledReason: 'A single instance needs to be selected.', disabled: true }, + { + id: 'manage-state', + text: 'Manage instance state', + disabledReason: 'Instance state must not be pending or stopping.', + disabled: true, + }, + { + text: 'Instance Settings', + id: 'settings', + items: [ + { + id: 'auto-scaling', + text: 'Attach to Auto Scaling Group', + disabledReason: 'Instance must be running and not already be attached to an Auto Scaling Group.', + disabled: true, + }, + { id: 'termination-protection', text: 'Change termination protections' }, + { id: 'stop-protection', text: 'Change stop protection' }, + ], + }, +]; + +const selectableGroupItems = [ + { + text: 'Settings group', + id: 'setting-group', + items: [ + { text: 'Setting', id: 'setting', itemType: 'checkbox', checked: true, disabled: false }, + { + text: 'Disabled setting', + id: 'disabled-setting', + itemType: 'checkbox', + checked: true, + disabled: true, + disabledReason: 'This setting is disabled', + }, + ], + }, +]; + +export default function TooltipComponentsUsage() { + const [selectedTab, setSelectedTab] = React.useState('first'); + const [selectedSegment, setSelectedSegment] = React.useState('segment-1'); + const [selectedDate, setSelectedDate] = React.useState(''); + const [dateRangeValue, setDateRangeValue] = React.useState(null); + const [selectedOption, setSelectedOption] = React.useState(null); + const [tokens, setTokens] = React.useState([ + { label: 'Token 1', dismissLabel: 'Remove Token 1' }, + { label: 'Token 2', dismissLabel: 'Remove Token 2' }, + ]); + const [sliderValue, setSliderValue] = React.useState(50); + + // // Refs for public Tooltip examples - Basic Usage + // const basicTextRef = React.useRef(null); + // const basicStatusRef = React.useRef(null); + + // // Refs for Positions + // const posTopRef = React.useRef(null); + // const posRightRef = React.useRef(null); + // const posBottomRef = React.useRef(null); + // const posLeftRef = React.useRef(null); + + // // Refs for Sizes + // const smallTooltipRef = React.useRef(null); + // const mediumTooltipRef = React.useRef(null); + // const largeTooltipRef = React.useRef(null); + + // // Refs for Components + // const iconTooltipRef = React.useRef(null); + // const linkTooltipRef = React.useRef(null); + // const badgeTooltipRef = React.useRef(null); + + // // Ref for Dismiss + // const dismissTooltipRef = React.useRef(null); + + // // State for showing/hiding tooltips - Basic Usage + // const [showBasicText, setShowBasicText] = React.useState(false); + // const [showBasicStatus, setShowBasicStatus] = React.useState(false); + + // // State for Positions + // const [showPosTop, setShowPosTop] = React.useState(false); + // const [showPosRight, setShowPosRight] = React.useState(false); + // const [showPosBottom, setShowPosBottom] = React.useState(false); + // const [showPosLeft, setShowPosLeft] = React.useState(false); + + // // State for Sizes + // const [showSmallTooltip, setShowSmallTooltip] = React.useState(false); + // const [showMediumTooltip, setShowMediumTooltip] = React.useState(false); + // const [showLargeTooltip, setShowLargeTooltip] = React.useState(false); + + // // State for Components + // const [showIconTooltip, setShowIconTooltip] = React.useState(false); + // const [showLinkTooltip, setShowLinkTooltip] = React.useState(false); + // const [showBadgeTooltip, setShowBadgeTooltip] = React.useState(false); + + // // State for Dismiss + // const [showDismissTooltip, setShowDismissTooltip] = React.useState(false); + + return ( +
+

Tooltip Component Examples

+

Examples of the public Tooltip component and how tooltips are used across different Cloudscape components.

+ + +

Components with simple tooltip

+ + BreadcrumbGroup with Truncation }> + + +

Live Example

+

+ Breadcrumb items automatically show tooltips when their text is truncated. Hover over or focus on + truncated items to see the full text. +

+ +
+ + +

How the Tooltip Works

+

+ The BreadcrumbItem component conditionally renders a tooltip wrapper when text truncation is detected. + Here's the implementation: +

+ +
+
+                  {`// From: src/breadcrumb-group/item/index.tsx
+
+const BreadcrumbItemWithPopover = ({ item, isLast, ... }) => {
+  const [showTooltip, setShowTooltip] = useState(false);
+  const textRef = useRef(null);
+
+  return (
+     setShowTooltip(true)}
+      onBlur={() => setShowTooltip(false)}
+      onMouseEnter={() => setShowTooltip(true)}
+      onMouseLeave={() => setShowTooltip(false)}
+    >
+      {children}
+      {showTooltip && (
+         setShowTooltip(false)}
+        />
+      )}
+    
+  );
+};
+
+// Main BreadcrumbItem component decides when to use tooltip wrapper
+export function BreadcrumbItem({ item, isTruncated, isGhost, ... }) {
+  const breadcrumbItem = ;
+
+  return (
+    
+ {/* Tooltip wrapper ONLY used when truncated and not ghost */} + {isTruncated && !isGhost ? ( + + {breadcrumbItem} + + ) : ( + {breadcrumbItem} + )} +
+ ); +}`} +
+
+
+ + +

Key Implementation Details

+
    +
  • + Conditional Wrapper: Tooltip functionality is only added when{' '} + isTruncated && !isGhost - avoiding unnecessary overhead for non-truncated items +
  • +
  • + Self-Managed State: Each breadcrumb manages its own showTooltip state + independently +
  • +
  • + Dual Interaction Support: Responds to both keyboard focus and mouse hover to + show/hide tooltip +
  • +
  • + Full Text Display: Tooltip always shows item.text (the complete, + untruncated text) +
  • +
  • + Medium Size: Uses size="medium" for optimal readability of + breadcrumb text +
  • +
  • + Link-Based Interaction: The tooltip tracks the anchor/span element ( + textRef) which can be either a link or plain text depending on whether it's the last + item +
  • +
  • + No Position Specified: Allows automatic positioning based on available space +
  • +
  • + Ghost Item Handling: Ghost breadcrumbs (used for animations/transitions) never show + tooltips even if truncated +
  • +
+
+ + +

Flow Diagram

+
+ 1. BreadcrumbGroup detects text truncation (CSS overflow) +
+ ↓
+ 2. isTruncated=true passed to BreadcrumbItem +
+ ↓
+ 3. BreadcrumbItemWithPopover wrapper is used instead of plain Item +
+ ↓
+ 4. User hovers or focuses: showTooltip = true +
+ ↓
+ 5. Tooltip renders: displays full item.text +
+ ↓
+ 6a. User moves away/blurs: showTooltip = false +
+ 6b. User presses Escape: onDismiss sets showTooltip = false +
+
+ + +

Why This Pattern?

+

The conditional wrapper approach provides several benefits:

+
    +
  • + Performance: Non-truncated breadcrumbs don't have tooltip overhead (no state, no + event handlers) +
  • +
  • + Clean Architecture: Tooltip logic is isolated in a separate wrapper component +
  • +
  • + Simple State: No complex conditions - just show/hide based on user interaction +
  • +
  • + Consistent UX: Users can see full text for any truncated breadcrumb via hover or + keyboard focus +
  • +
+
+
+
+ + Descriptions in ButtonDropdown}> +

Dropdown items with disabled reasons show tooltips.

+ + + Actions + + + + Disabled Dropdown + + + + + Selectable example + +
+ + ButtonGroup Variants }> + + +

Live Example

+

+ ButtonGroup supports multiple item types including icon buttons, toggle buttons, and menu dropdowns. + Hover over items to see tooltips. +

+ +
+ + +

Icon Button Item Tooltip Pattern (Basic)

+

+ The basic IconButtonItem pattern is the simplest ButtonGroup tooltip implementation, supporting optional + feedback messages: +

+ +
+
+                  {`// From: src/button-group/icon-button-item.tsx
+
+interface IconButtonItemProps {
+  item: InternalIconButton;
+  showTooltip: boolean;           // Parent-controlled flag
+  showFeedback: boolean;          // Show feedback instead of tooltip
+  onTooltipDismiss: () => void;
+}
+
+const IconButtonItem = forwardRef(
+  ({ item, showTooltip, showFeedback, onTooltipDismiss }, ref) => {
+    const containerRef = React.useRef(null);
+    
+    // Simpler than toggle: no pressed state handling
+    const canShowTooltip = Boolean(showTooltip && !item.disabled && !item.loading);
+    const canShowFeedback = Boolean(showTooltip && showFeedback && item.popoverFeedback);
+
+    return (
+      
+ + + {/* Tooltip shows feedback OR text */} + {(canShowTooltip || canShowFeedback) && ( + + {item.popoverFeedback} + + )) || item.text + } + onDismiss={onTooltipDismiss} + /> + )} +
+ ); + } +);`} +
+
+
+ + +

Menu Dropdown Item Tooltip Pattern

+

+ The MenuDropdownItem uses a parent-controlled pattern with an additional condition: tooltips are hidden + when the dropdown is open. Here's how it works: +

+ +
+
+                  {`// From: src/button-group/menu-dropdown-item.tsx
+
+interface MenuDropdownItemProps {
+  item: ButtonGroupProps.MenuDropdown;
+  showTooltip: boolean;           // Parent-controlled flag
+  onTooltipDismiss: () => void;
+  onItemClick?: CancelableEventHandler<...>;
+}
+
+const MenuDropdownItem = forwardRef(
+  ({ item, showTooltip, onTooltipDismiss, ... }, ref) => {
+    const containerRef = React.useRef(null);
+
+    return (
+       (
+          
+ {/* Tooltip with additional isOpen condition */} + {!isOpen && showTooltip && !item.disabled && !item.loading && ( + + )} + +
+ )} + /> + ); + } +);`} +
+
+
+ + +

Key Differences from Other ButtonGroup Patterns

+
    +
  • + Dropdown-Aware: Adds !isOpen condition - tooltip automatically hides + when dropdown menu opens +
  • +
  • + CustomTriggerBuilder: Uses ButtonDropdown's customTriggerBuilder pattern to wrap + the trigger button with tooltip functionality +
  • +
  • + State Suppression: Like IconToggleButton, tooltip is suppressed when{' '} + disabled or loading +
  • +
  • + Ellipsis Icon: Always uses "ellipsis" icon for menu dropdown buttons +
  • +
  • + DisabledReason Support: Disabled menu dropdowns can show disabledReason{' '} + through the button's own tooltip mechanism +
  • +
+
+ + +

Tooltip Display Conditions

+
+ ALL conditions must be true: +
+ 1. !isOpen - Dropdown is NOT open +
+ 2. showTooltip - Parent says show (user hovering) +
+ 3. !item.disabled - Item is NOT disabled +
+ 4. !item.loading - Item is NOT loading +
+
+ Why hide when dropdown is open? +
+ Prevents tooltip from competing with the dropdown menu for user attention and screen space. +
+
+
+
+ + ButtonGroup with Icon Toggle }> + + +

Icon Toggle Button Tooltip Pattern

+

+ The IconToggleButtonItem extends the basic ButtonGroup pattern with support for feedback messages and + loading states. Here's how it works: +

+ +
+
+                  {`// From: src/button-group/icon-toggle-button-item.tsx
+
+interface IconToggleButtonItemProps {
+  item: InternalIconToggleButton;
+  showTooltip: boolean;           // Parent-controlled flag
+  showFeedback: boolean;          // Show feedback instead of tooltip
+  onTooltipDismiss: () => void;
+  onItemClick?: CancelableEventHandler<...>;
+}
+
+const IconToggleButtonItem = forwardRef(
+  ({ item, showTooltip, showFeedback, onTooltipDismiss }, ref) => {
+    const containerRef = React.useRef(null);
+    
+    // Select appropriate feedback content based on pressed state
+    const feedbackContent = item.pressed 
+      ? (item.pressedPopoverFeedback ?? item.popoverFeedback) 
+      : item.popoverFeedback;
+    
+    // Tooltip only shows when not disabled/loading
+    const canShowTooltip = showTooltip && !item.disabled && !item.loading;
+    
+    // Feedback replaces tooltip when available
+    const canShowFeedback = showTooltip && showFeedback && feedbackContent;
+
+    return (
+      
+ + + {/* Tooltip shows either feedback OR text */} + {(canShowTooltip || canShowFeedback) && ( + + {feedbackContent} + + )) || item.text + } + onDismiss={onTooltipDismiss} + /> + )} +
+ ); + } +);`} +
+
+
+ + +

Key Differences from Basic ButtonGroup Pattern

+
    +
  • + Feedback Support: Can display popoverFeedback or{' '} + pressedPopoverFeedback messages wrapped in LiveRegion for screen reader announcements +
  • +
  • + State-Aware Tooltip: Tooltip is suppressed when button is disabled or{' '} + loading +
  • +
  • + Dynamic Content: Tooltip content switches between: +
      +
    • Feedback message (when pressed, with LiveRegion wrapper)
    • +
    • Button text (normal state)
    • +
    +
  • +
  • + DisabledReason Suppression: When showing popover feedback, the{' '} + disabledReason tooltip is hidden to avoid conflicting tooltips +
  • +
  • + Toggle State Context: Feedback message adapts to whether button is pressed or + unpressed +
  • +
+
+ + +

Tooltip Content Priority

+
+ Priority Order: +
+ 1. Feedback (pressed state): pressedPopoverFeedback → popoverFeedback +
+ 2. Feedback (normal state): popoverFeedback +
+ 3. Default: item.text +
+
+ Suppression Rules: +
• No tooltip when disabled=true OR loading=true +
• disabledReason hidden when showFeedback=true +
+
+
+
+ + ButtonGroup with File Input }> + + +

Live Example

+

Hover over the file input button to see the Tooltip implementation.

+ { + console.log('Files changed:', detail); + }} + /> +
+ + +

How the Tooltip Works

+

+ The file input item component in ButtonGroup uses the Tooltip component to display hover text. + Here's how it's implemented: +

+ +
+
+                  {`// From: src/button-group/file-input-item.tsx
+
+interface FileInputItemProps {
+  item: ButtonGroupProps.IconFileInput;
+  showTooltip: boolean;                    // Parent-controlled flag
+  onTooltipDismiss: () => void;            // Dismissal callback
+  onFilesChange?: CancelableEventHandler<...>;
+}
+
+const FileInputItem = forwardRef(
+  ({ item, showTooltip, onTooltipDismiss, onFilesChange }, ref) => {
+    const containerRef = React.useRef(null);
+    const canShowTooltip = Boolean(showTooltip);
+
+    return (
+      
+ + + {/* Conditional tooltip rendering */} + {canShowTooltip && ( + + )} +
+ ); + } +);`} +
+
+
+ + +

Key Implementation Details

+
    +
  • + Parent-Controlled Visibility: The ButtonGroup parent manages tooltip state across all + items, preventing multiple tooltips from showing simultaneously +
  • +
  • + Container Reference: The tooltip uses trackRef={'{containerRef}'} to + position itself relative to the file input wrapper div +
  • +
  • + Track Key: Each tooltip has a unique trackKey={'{item.id}'} for proper + identification and state management +
  • +
  • + Content: The value={'{item.text}'} prop provides the tooltip text (e.g., + "Upload file") +
  • +
  • + Dismissal: The onDismiss callback notifies the parent when the tooltip + should hide (mouse leave, Escape key) +
  • +
+
+ + +

Flow Diagram

+
+ 1. User hovers over file input button +
+ ↓
+ 2. Parent (ButtonGroup) sets showTooltip={'{true}'} for this item +
+ ↓
+ 3. FileInputItem renders Tooltip component +
+ ↓
+ 4. Tooltip positions itself using trackRef and displays{' '} + item.text +
+ ↓
+ 5. User moves away or presses Escape +
+ ↓
+ 6. onDismiss fires, parent sets showTooltip={'{false}'} +
+
+
+
+ + Select & Multiselect with Disabled Options }> + + +

Live Example - Select

+

+ Open the dropdown and hover/navigate to disabled options to see tooltips explaining why they're + disabled. +

+ { setShowTooltip(true); setIsActive(true); }} + onBlur={() => { setShowTooltip(false); setIsActive(false); }} + onTouchStart={() => { setShowTooltip(true); setIsActive(true); }} + onTouchEnd={() => { setShowTooltip(false); setIsActive(false); }} + /> +
+ ); +}`} + + + + + +

Key Implementation Details

+
    +
  • + Multi-Interaction Support: Responds to mouse hover, keyboard focus, AND touch events +
  • +
  • + Separate Positioning Element: Uses invisible tooltip-thumb div for + tooltip tracking, not the input itself +
  • +
  • + Formatted Value Display: Shows valueFormatter(value) if provided, or raw + value +
  • +
  • + Active State Tracking: Uses isActive state to style the slider during + interaction +
  • +
  • + Simple State Management: Single showTooltip boolean (no suppression + logic needed) +
  • +
  • + Auto-Positioning: No position specified, allowing optimal placement above the slider +
  • +
+
+ + +

Why Separate Positioning Element?

+

+ The tooltip tracks an invisible tooltip-thumb div instead of the range input because: +

+
    +
  • + Better Positioning: Allows precise control over tooltip position relative to the + slider thumb +
  • +
  • + Style Independence: Avoids interference with native range input styling +
  • +
  • + Dynamic Positioning: The thumb div can be positioned via CSS custom properties to + follow the slider value +
  • +
+
+ + + + FileTokenGroup with Overflow Detection }> + + +

Live Example

+

+ File tokens show tooltips when their file names are truncated. Hover over file tokens with long names to + see the full name. +

+

+ Note: FileTokenGroup uses Tooltip for file name overflow display. +

+
+ + +

How the Tooltip Works

+

+ The InternalFileToken component uses an overflow detection pattern similar to Token, but with mouse-only + interaction. Here's the implementation: +

+ +
+
+                  {`// From: src/file-token-group/file-option.tsx
+
+function InternalFileToken({ file, ... }) {
+  const containerRef = useRef(null);
+  const fileNameRef = useRef(null);
+  const fileNameContainerRef = useRef(null);
+  const [showTooltip, setShowTooltip] = useState(false);
+
+  // Check if file name is truncated
+  function isEllipsisActive() {
+    const span = fileNameRef.current;
+    const container = fileNameContainerRef.current;
+    
+    if (span && container) {
+      return span.offsetWidth >= container.offsetWidth;
+    }
+    return false;
+  }
+
+  return (
+    
+
+
setShowTooltip(true)} + onMouseOut={() => setShowTooltip(false)} + ref={fileNameContainerRef} + > + {file.name} +
+ + {/* File size and last modified */} + {showFileSize && {formatFileSize(file.size)}} + {showFileLastModified && {formatDate(file.lastModified)}} +
+ + {/* Tooltip only when hovering AND truncated */} + {showTooltip && isEllipsisActive() && ( + {file.name}} + onDismiss={() => setShowTooltip(false)} + /> + )} +
+ ); +}`} +
+
+
+ + +

Key Implementation Details

+
    +
  • + Mouse-Only Interaction: Uses onMouseOver/onMouseOut instead + of focus events - file tokens aren't keyboard focusable +
  • +
  • + Runtime Overflow Check: Calls isEllipsisActive() in render to check if + truncation exists (not using ResizeObserver) +
  • +
  • + Dual Condition Display: Tooltip renders when BOTH showTooltip (mouse + over) AND isEllipsisActive() (truncated) are true +
  • +
  • + Container Tracking: Tooltip tracks containerRef (whole token), not just + the file name +
  • +
  • + TrackKey Usage: Uses trackKey={'{file.name}'} for unique identification +
  • +
  • + Styled Content: Wraps file name in InternalBox with{' '} + fontWeight="normal" for consistent styling +
  • +
  • + No Position Specified: Allows automatic positioning around the token +
  • +
+
+ + +

Comparison with Token Pattern

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AspectTokenFileToken
+ Overflow Detection + useResizeObserver (continuous)Runtime check (on render)
+ Interaction Events + Mouse + FocusMouse only (onMouseOver/Out)
+ Keyboard Access + Dynamic tabIndexNot focusable
+ Performance + State tracked, observer attachedChecked on each render
+ Use Case + Inline tokens in textFile upload lists
+
+ + +

Why This Simpler Pattern?

+
    +
  • + Non-Interactive Context: File tokens don't need keyboard focus, so mouse-only + events are sufficient +
  • +
  • + Simpler Implementation: Runtime check is simpler than ResizeObserver for this use + case +
  • +
  • + Adequate Performance: File token lists are typically small, so re-checking on render + is acceptable +
  • +
+
+
+
+ + Summary}> +
+

Components Currently Using Tooltips:

+
    +
  • + BreadcrumbGroup - Truncated breadcrumb text +
  • +
  • + ButtonDropdown - Item descriptions and disabled reasons +
  • +
  • + ButtonGroup - Menu item descriptions +
  • +
  • + Select - Option descriptions +
  • +
  • + SegmentedControl - Disabled segment explanations +
  • +
  • + Tabs - Additional tab information +
  • +
  • + Calendar - Date information +
  • +
  • + DateRangePicker - Date selection hints +
  • +
  • + TokenGroup - Truncated token labels +
  • +
  • + Button - Disabled reasons +
  • +
  • + Icon Buttons - Action explanations +
  • +
  • + Slider - Current value display +
  • +
  • + FileTokenGroup - File information tooltips +
  • +
+
+
+ + + ); +} diff --git a/pages/tooltip/hook-comparison.page.tsx b/pages/tooltip/hook-comparison.page.tsx new file mode 100644 index 0000000000..93125c5a1d --- /dev/null +++ b/pages/tooltip/hook-comparison.page.tsx @@ -0,0 +1,931 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import Badge from '~components/badge'; +import Box from '~components/box'; +import Container from '~components/container'; +import Header from '~components/header'; +import Icon from '~components/icon'; +import SpaceBetween from '~components/space-between'; +import StatusIndicator from '~components/status-indicator'; +import Tooltip from '~components/tooltip'; +import { SimpleTooltip } from '~components/tooltip/simple-tooltip'; +import { TooltipCoordinator } from '~components/tooltip/tooltip-coordinator'; +import { useTooltip } from '~components/tooltip/use-tooltip'; +import { useTooltipAdvanced } from '~components/tooltip/use-tooltip-advanced'; + +// ====== WRAPPER COMPONENT IMPLEMENTATION ====== +interface TooltipWrapperProps { + content: React.ReactNode; + position?: 'top' | 'right' | 'bottom' | 'left'; + size?: 'small' | 'medium' | 'large'; + children: React.ReactNode; + style?: React.CSSProperties; +} + +function TooltipWrapper({ content, position = 'top', size, children, style }: TooltipWrapperProps) { + const ref = React.useRef(null); + const [show, setShow] = React.useState(false); + + return ( + <> + setShow(true)} onMouseLeave={() => setShow(false)} style={style}> + {children} + + {show && } + + ); +} + +// ====== COORDINATION DEMO COMPONENT ====== +function CoordinationDemo() { + // Without coordination + const [noCoord1Target, noCoord1Tooltip] = useTooltipAdvanced({ + content: 'Button 1 - Independent', + position: 'top', + id: 'no-coord-1', + }); + const [noCoord2Target, noCoord2Tooltip] = useTooltipAdvanced({ + content: 'Button 2 - Independent', + position: 'top', + id: 'no-coord-2', + }); + + // With coordination + const [coord1Target, coord1Tooltip] = useTooltipAdvanced({ + content: 'Button 1 - Coordinated', + position: 'top', + id: 'coord-1', + }); + const [coord2Target, coord2Tooltip] = useTooltipAdvanced({ + content: 'Button 2 - Coordinated', + position: 'top', + id: 'coord-2', + }); + + // Mixed coordination + const [mixed1Target, mixed1Tooltip] = useTooltipAdvanced({ + content: 'Button 1 - Coordinated', + position: 'top', + id: 'mixed-1', + }); + const [mixed2Target, mixed2Tooltip] = useTooltipAdvanced({ + content: 'Button 2 - Independent (disableCoordination)', + position: 'top', + id: 'mixed-2', + disableCoordination: true, + }); + + return ( + + +

Without Coordination (Default)

+

+ Multiple tooltips can show at once. Try: focus Button 1 (tab), then hover Button 2. +

+
+ + Button 1 + + {noCoord1Tooltip && } + + + Button 2 + + {noCoord2Tooltip && } +
+
+          {`// No coordination - tooltips work independently
+const [target1, tooltip1] = useTooltipAdvanced({...});
+const [target2, tooltip2] = useTooltipAdvanced({...});
+
+// Both can show at once!`}
+        
+
+ + +

With Coordination

+

+ Only one tooltip at a time. Try: focus Button 1, then hover Button 2. Notice only one shows! +

+ +
+ + Button 1 + + {coord1Tooltip && } + + + Button 2 + + {coord2Tooltip && } +
+
+
+          {`// Wrap in TooltipCoordinator for mutual exclusion
+
+  
+  {tooltip1 && }
+  
+  
+  {tooltip2 && }
+
+
+// Only one tooltip shows at a time!`}
+        
+
+ + +

Mixed: Coordinated + Independent

+

+ Button 1 coordinates, Button 2 opts out. Try: focus Button 1, hover Button 2. Both show! +

+ +
+ + Button 1 + + {mixed1Tooltip && } + + + Button 2 + + {mixed2Tooltip && } +
+
+
+          {`// Button 2 opts out of coordination
+const [target2, tooltip2] = useTooltipAdvanced({
+  content: 'Independent',
+  disableCoordination: true  // ← Opts out
+});
+
+// Button 2's tooltip can show with Button 1's!`}
+        
+
+
+ ); +} + +export default function TooltipHookComparison() { + // ====== MANUAL IMPLEMENTATION (Current Pattern) ====== + const manualTopRef = React.useRef(null); + const manualRightRef = React.useRef(null); + const manualStatusRef = React.useRef(null); + + const [showManualTop, setShowManualTop] = React.useState(false); + const [showManualRight, setShowManualRight] = React.useState(false); + const [showManualStatus, setShowManualStatus] = React.useState(false); + + // ====== HOOK IMPLEMENTATION (New Pattern) ====== + const hookTop = useTooltip('Tooltip on top', { position: 'top' }); + const hookRight = useTooltip('Tooltip on right', { position: 'right' }); + const hookStatus = useTooltip(Operation completed successfully, { + position: 'top', + }); + const hookLarge = useTooltip('This is a large tooltip with more content', { + position: 'top', + size: 'large', + }); + const hookIcon = useTooltip('Click for more information', { position: 'right' }); + const hookBadge = useTooltip('Feature released this week', { position: 'top' }); + + // ====== ADVANCED HOOK IMPLEMENTATION (Proposed Pattern) ====== + const [advancedTopTarget, advancedTopTooltip] = useTooltipAdvanced({ + content: 'Advanced tooltip with proper focus/hover handling', + position: 'top', + }); + const [advancedRightTarget, advancedRightTooltip] = useTooltipAdvanced({ + content: 'Stays visible when focused or hovered', + position: 'right', + }); + const [advancedStatusTarget, advancedStatusTooltip] = useTooltipAdvanced({ + content: Handles both hover and focus properly, + position: 'top', + }); + const [advancedIconTarget, advancedIconTooltip, advancedIconApi] = useTooltipAdvanced({ + content: 'Programmatic control via API', + position: 'right', + }); + + return ( +
+

Tooltip Implementation Comparison

+

Comparing the manual pattern vs. the useTooltip hook pattern.

+ + + Pattern 1: Manual Implementation (Current)}> + + +

Code Required

+
+                {`// Per tooltip, you need:
+const topRef = React.useRef(null);
+const [showTop, setShowTop] = React.useState(false);
+
+ setShowTop(true)}
+  onMouseLeave={() => setShowTop(false)}
+>
+  Hover me
+
+{showTop && }`}
+              
+
+ + +

Examples

+
+ + setShowManualTop(true)} + onMouseLeave={() => setShowManualTop(false)} + style={{ + display: 'inline-block', + padding: '8px 16px', + border: '1px solid #ccc', + borderRadius: '4px', + cursor: 'pointer', + }} + > + Top Position + + {showManualTop && } + + + + setShowManualRight(true)} + onMouseLeave={() => setShowManualRight(false)} + style={{ + display: 'inline-block', + padding: '8px 16px', + border: '1px solid #ccc', + borderRadius: '4px', + cursor: 'pointer', + }} + > + Right Position + + {showManualRight && } + + + + setShowManualStatus(true)} + onMouseLeave={() => setShowManualStatus(false)} + style={{ textDecoration: 'underline', cursor: 'pointer' }} + > + With StatusIndicator + + {showManualStatus && ( + Operation completed successfully} + position="top" + /> + )} + +
+
+ + {/* +

Pros & Cons

+ +
+ ✅ Pros: +
    +
  • Full control over behavior
  • +
  • No magic or hidden complexity
  • +
  • Easy to customize event handlers
  • +
  • Explicit state management
  • +
+
+
+ ❌ Cons: +
    +
  • Verbose - lots of boilerplate per tooltip
  • +
  • Easy to make mistakes (duplicate refs, etc.)
  • +
  • Repetitive code
  • +
+
+
+
*/} +
+
+ + Pattern 2: useTooltip Hook (New)}> + + +

Code Required

+
+                {`// Import the hook
+import { useTooltip } from '~components/tooltip/use-tooltip';
+
+// One line per tooltip:
+const tooltip = useTooltip('Tooltip text', { position: 'top' });
+
+// Usage:
+Hover me
+{tooltip.tooltip && }`}
+              
+
+ + +

Examples

+
+ + + Top Position + + {hookTop.tooltip && } + + + + + Right Position + + {hookRight.tooltip && } + + + + + With StatusIndicator + + {hookStatus.tooltip && } + +
+
+ + +

Advanced Examples

+ + + + Large Tooltip + + {hookLarge.tooltip && } + + + + + + + {hookIcon.tooltip && } + + + + + New + + {hookBadge.tooltip && } + + +
+ + {/* +

Pros & Cons

+ +
+ ✅ Pros: +
    +
  • Much less boilerplate
  • +
  • Reusable - one hook call per tooltip
  • +
  • Cleaner code
  • +
  • Still provides manual control via show/setShow
  • +
+
+
+ ❌ Cons: +
    +
  • Need to spread triggerProps
  • +
  • Slightly more opaque (hook handles state)
  • +
  • May not fit all edge cases (use manual pattern then)
  • +
+
+
+
*/} +
+
+ + Pattern 3: Wrapper Component (Alternative)}> + + +

Code Required

+
+                {`// Create reusable wrapper component once:
+function TooltipWrapper({ content, position, children }) {
+  const ref = useRef(null);
+  const [show, setShow] = useState(false);
+  
+  return (
+    <>
+       setShow(true)}
+            onMouseLeave={() => setShow(false)}>
+        {children}
+      
+      {show && }
+    
+  );
+}
+
+// Usage - simple component wrapping:
+
+  
+`}
+              
+
+ + +

Examples

+
+ + + Top Position + + + + + + Right Position + + + + + Operation completed successfully} + position="top" + style={{ textDecoration: 'underline', cursor: 'pointer' }} + > + With StatusIndicator + + +
+
+ + +

Advanced Examples

+ + + + Large Tooltip + + + + + + + + + + + + New + + + +
+ {/* + +

Pros & Cons

+ +
+ ✅ Pros: +
    +
  • Most React-like - single component wrapping
  • +
  • Clean JSX - no spreading props or conditional rendering
  • +
  • Easiest to use - just wrap your element
  • +
  • Good for simple cases
  • +
+
+
+ ❌ Cons: +
    +
  • Adds extra DOM element (wrapper span)
  • +
  • Less flexible for styling - style goes on wrapper, not child
  • +
  • Cannot use with elements that need specific props/refs
  • +
  • Harder to customize trigger behavior
  • +
+
+
+
*/} +
+
+ + Pattern 4: useTooltipAdvanced Hook (Proposed)}> + + +

Code Required

+
+                {`// Import the advanced hook
+import { useTooltipAdvanced } from '~components/tooltip/use-tooltip-advanced';
+
+// Array destructuring with three elements:
+const [targetProps, tooltipProps, api] = useTooltipAdvanced({
+  content: 'Tooltip text',
+  position: 'top'
+});
+
+// Usage:
+
+{tooltipProps && }
+
+// Programmatic control:
+
+`}
+              
+
+ + +

Examples

+
+ + + Top Position (Try Tab) + + {advancedTopTooltip && } + + + + + Right Position (Try Tab) + + {advancedRightTooltip && } + + + + + With StatusIndicator + + {advancedStatusTooltip && } + +
+
+ + +

Programmatic Control

+ + + + + + + {advancedIconTooltip && } +
+ + + +
+
+ API visible: {advancedIconApi.isVisible ? 'true' : 'false'} +
+
+
+
+
+ + {/* +

Key Features

+
+
    +
  • + Proper hover/focus handling: Tooltip stays visible when element is either hovered + OR focused +
  • +
  • + Array destructuring API: [targetProps, tooltipProps, api] provides clean separation +
  • +
  • + All event handlers included: onMouseEnter, onMouseLeave, onFocus, onBlur +
  • +
  • + Programmatic control: api.show(), api.hide(), api.toggle(), api.isVisible +
  • +
  • + Null tooltipProps when hidden: Conditional rendering pattern {'{'}tooltipProps && + ...{'}'} +
  • +
  • + Try tabbing: Use Tab key to focus elements and see tooltip persist while focused +
  • +
+
+
*/} +
+
+ + Pattern 5: SimpleTooltip Component (Recommended)}> + + +

Code Required

+
+                {`// Import the component
+import { SimpleTooltip } from '~components/tooltip/simple-tooltip';
+
+// Usage - minimal props, maximum accessibility:
+
+  
+`}
+              
+
+ + +

Examples

+
+ + + + Top Position + + + + + + + + Right Position + + + + + + Operation completed successfully} + position="top" + > + With StatusIndicator + + +
+
+ + +

Advanced Examples

+ + + + + + + + + + New + + + + + + + + + +
+ + {/* +

Features

+ +
+ ✅ Built-in Accessibility: +
    +
  • Keyboard accessible (shows on focus, hides on blur)
  • +
  • Escape key dismisses tooltip
  • +
  • Hover and focus support
  • +
  • No ARIA annotations needed - handled internally
  • +
+
+
+ ✅ Minimal Props: +
    +
  • Only 2 required props: content and children
  • +
  • Position optional (defaults to 'top')
  • +
  • No ref management needed
  • +
  • No state management needed
  • +
+
+
+ ✅ Clean API: +
    +
  • 1-2 lines of code per tooltip
  • +
  • Simple component wrapping
  • +
  • Works with any React element
  • +
  • Passes all accessibility tests
  • +
+
+
+
*/} +
+
+ + Pattern 6: Tooltip Coordination}> + + +

Problem: Multiple Tooltips in Proximity

+

+ When tooltips are close together, one element can be focused while another is hovered, causing multiple + tooltips to show simultaneously. The TooltipCoordinator solves this by ensuring only one + tooltip is visible at a time within its scope. +

+
+ + +
+
+
+
+ ); +} diff --git a/pages/tooltip/tooltip-version-2.page.tsx b/pages/tooltip/tooltip-version-2.page.tsx new file mode 100644 index 0000000000..a58e6e4cf9 --- /dev/null +++ b/pages/tooltip/tooltip-version-2.page.tsx @@ -0,0 +1,652 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useRef, useState } from 'react'; + +import Box from '~components/box'; +import BreadcrumbGroup from '~components/breadcrumb-group'; +import Button from '~components/button'; +import ButtonGroup from '~components/button-group'; +import Calendar from '~components/calendar'; +import Container from '~components/container'; +import Header from '~components/header'; +import InternalTooltip from '~components/internal/components/tooltip'; +import SegmentedControl from '~components/segmented-control'; +import Select from '~components/select'; +import Slider from '~components/slider'; +import SpaceBetween from '~components/space-between'; +import Tabs from '~components/tabs'; +import TokenGroup from '~components/token-group'; + +/** + * shadcn-Inspired Tooltip with Current API + * + * This page demonstrates all 14 tooltip patterns using: + * - SAME TooltipProps interface (no new props) + * - shadcn-inspired improvements (pointer events, state coordination, hoverable content) + * - Optional TooltipProvider for multi-tooltip coordination + */ + +export default function ShadcnInspiredExamples() { + const [selectedDate, setSelectedDate] = useState(''); + const [selectedOption, setSelectedOption] = useState(null); + const [sliderValue, setSliderValue] = useState(50); + const [selectedTab, setSelectedTab] = useState('first'); + const [selectedSegment, setSelectedSegment] = useState('seg-1'); + const [tokens, setTokens] = useState([ + { label: 'Tag 1', dismissLabel: 'Remove Tag 1' }, + { label: 'Tag 2', dismissLabel: 'Remove Tag 2' }, + ]); + + return ( +
+

Tooltip Version 2 - All 14 Patterns

+

+ Using the exact same current API with internal improvements: pointer events, state + coordination, hoverable content. +

+ + {/*
+

What's Improved (Zero API Changes):

+
    +
  • ✅ Pointer events (better touch support)
  • +
  • ✅ State coordination (only one tooltip at a time)
  • +
  • ✅ Hoverable tooltip content (doesn't disappear)
  • +
  • ✅ CSS variables (better animations)
  • +
  • ✅ Same TooltipProps interface
  • +
+
*/} + + + Pattern 1: ButtonGroup Icon Buttons}> + +

Icon button labels using current API

+

Hover over buttons - tooltips now use pointer events for better touch support.

+ + + +
+
+                {`// Current API - unchanged
+const buttonRef = useRef(null);
+const [showTooltip, setShowTooltip] = useState(false);
+
+
setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} +> +
+ +// Now uses pointer events internally! +// Better touch support, no API changes`} +
+
+
+
+ + Pattern 2-5: ButtonGroup Variants}> + +

All ButtonGroup tooltip patterns work with improvements

+ + + +

+ improvement: When used with TooltipProvider, only one ButtonGroup tooltip shows at a + time (smooth transitions). +

+
+
+
+ + Pattern 6: Select Disabled Options}> + +

Disabled option reasons - current API, improved UX

+ setShowTooltip(true)} + onTouchEnd={() => setShowTooltip(false)} + /> + {showTooltip && ( + + )} +
+ +// benefits: +// Pointer events improve reliability +// CSS variables for better positioning`} + + + + + + Pattern 14: BreadcrumbGroup Truncation}> + +

Truncated breadcrumb text

+ +
+
+ + {/* shadcn-Inspired Benefits Summary}> +
+

What Changed Internally (No API Changes):

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AspectBeforeAfter (shadcn-inspired)
+ Event Type + Mouse eventsPointer events (better touch)
+ Multi-tooltip + Can overlap + One at a time (with Provider) +
+ Hoverable + Disappears on moveStays when hovering tooltip
+ CSS + StaticVariables for animations
+ API + TooltipProps + SAME TooltipProps +
*/} + + {/* +

All 14 Patterns Work Unchanged:

+
    +
  1. ButtonGroup IconButton
  2. +
  3. ButtonGroup IconToggle
  4. +
  5. ButtonGroup MenuDropdown
  6. +
  7. ButtonGroup FileInput
  8. +
  9. Select disabled options
  10. +
  11. SegmentedControl disabled
  12. +
  13. Tabs disabled
  14. +
  15. Calendar disabled dates
  16. +
  17. Token truncation
  18. +
  19. Button disabled
  20. +
  21. TriggerButton
  22. +
  23. Slider value
  24. +
  25. FileToken
  26. +
  27. BreadcrumbGroup
  28. +
+
*/} + {/*
+
*/} + + Optional: TooltipProvider for State Coordination}> + +

Wrap your app for better multi-tooltip UX

+
+
+                {`// Optional provider - no API changes to Tooltip component
+import { TooltipProvider } from '~components/internal/components/tooltip';
+
+
+  {/* All tooltips now coordinate - only one visible at a time */}
+  
+
+
+// Without provider: Works exactly as before
+// With provider: Better UX (smooth transitions between tooltips)`}
+              
+
+ + +

Provider Benefits:

+
    +
  • Only one tooltip visible at a time
  • +
  • Smooth transitions when hovering between elements
  • +
  • Prevents tooltip clutter
  • +
  • Opt-in (backwards compatible)
  • +
  • No props on Tooltip component
  • +
+
+
+
+ + + ); +} + +// ============================================================================ +// EXAMPLE COMPONENTS USING CURRENT API +// ============================================================================ + +function IconButtonExample() { + const copyRef = useRef(null); + const editRef = useRef(null); + const downloadRef = useRef(null); + + const [showCopy, setShowCopy] = useState(false); + const [showEdit, setShowEdit] = useState(false); + const [showDownload, setShowDownload] = useState(false); + + return ( + +
setShowCopy(true)} + onMouseLeave={() => setShowCopy(false)} + style={{ display: 'inline-block' }} + > +
+ +
setShowEdit(true)} + onMouseLeave={() => setShowEdit(false)} + style={{ display: 'inline-block' }} + > +
+ +
setShowDownload(true)} + onMouseLeave={() => setShowDownload(false)} + style={{ display: 'inline-block' }} + > +
+
+ ); +} + +function DisabledSegmentWithTooltip() { + const segmentRef = useRef(null); + const [showTooltip, setShowTooltip] = useState(false); + + return ( + +

Custom disabled segment using current Tooltip API:

+ + + {showTooltip && ( + setShowTooltip(false)} + /> + )} +
+ ); +} + +function DisabledButtonExample() { + const buttonRef = useRef(null); + const [showTooltip, setShowTooltip] = useState(false); + + return ( +
setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + style={{ display: 'inline-block' }} + > + + + {showTooltip && ( + setShowTooltip(false)} + /> + )} +
+ ); +} + +function TruncatedTokenExample() { + const tokenRef = useRef(null); + const [showTooltip, setShowTooltip] = useState(false); + const fullText = 'This is a very long token label that gets truncated'; + + return ( +
+

Custom truncated token using current API:

+ setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + onFocus={() => setShowTooltip(true)} + onBlur={() => setShowTooltip(false)} + style={{ + display: 'inline-block', + maxWidth: '200px', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + padding: '4px 8px', + border: '1px solid #ccc', + borderRadius: '4px', + cursor: 'pointer', + }} + > + {fullText} + + {showTooltip && ( + + {fullText} + + } + onDismiss={() => setShowTooltip(false)} + /> + )} +
+ ); +} + +function AppLayoutTriggerExample() { + const triggerRef = useRef(null); + const [showTooltip, setShowTooltip] = useState(false); + + return ( +
+ + {showTooltip && ( + setShowTooltip(false)} + /> + )} +
+ ); +} + +function SliderExample({ value, onChange }: { value: number; onChange: (val: number) => void }) { + const handleRef = useRef(null); + const [showTooltip, setShowTooltip] = useState(false); + + return ( +
setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + onTouchStart={() => setShowTooltip(true)} + onTouchEnd={() => setShowTooltip(false)} + > +
+ onChange(detail.value)} min={0} max={100} ariaLabel="Volume" /> + {showTooltip && ( + setShowTooltip(false)} + /> + )} +
+ ); +} diff --git a/pages/tooltip/wrapper-component.page.tsx b/pages/tooltip/wrapper-component.page.tsx new file mode 100644 index 0000000000..690600074a --- /dev/null +++ b/pages/tooltip/wrapper-component.page.tsx @@ -0,0 +1,661 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import Box from '~components/box'; +import Button from '~components/button'; +import Container from '~components/container'; +import Header from '~components/header'; +import InternalTooltip from '~components/internal/components/tooltip'; +// import { IconProps } from '~components/icon/interfaces'; +import Slider from '~components/slider'; +import SpaceBetween from '~components/space-between'; + +// ============================================================================ +// TOOLTIP AS WRAPPER COMPONENT +// ============================================================================ + +interface TooltipProps { + content: React.ReactNode; + children: React.ReactElement; + variant?: 'label' | 'disabled-reason' | 'truncation' | 'feedback' | 'value-display'; + position?: 'top' | 'right' | 'bottom' | 'left'; + size?: 'small' | 'medium' | 'large'; +} + +function Tooltip({ content, children, variant = 'label', position, size = 'small' }: TooltipProps) { + const wrapperRef = useRef(null); + const [showTooltip, setShowTooltip] = useState(false); + const [isTruncated, setIsTruncated] = useState(false); + const [isMobile, setIsMobile] = useState(false); + const descriptionId = useRef(`tooltip-desc-${Math.random().toString(36).slice(2, 11)}`).current; + + // Mobile detection + useEffect(() => { + const mediaQuery = window.matchMedia('(pointer: coarse)'); + setIsMobile(mediaQuery.matches); + + const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches); + mediaQuery.addEventListener('change', handler); + return () => mediaQuery.removeEventListener('change', handler); + }, []); + + // Truncation detection + useEffect(() => { + if (variant === 'truncation' && wrapperRef.current) { + const checkOverflow = () => { + const wrapper = wrapperRef.current; + if (wrapper) { + const contentEl = wrapper.querySelector('[data-truncation-target]') as HTMLElement; + if (contentEl) { + const isOverflowing = contentEl.scrollWidth > contentEl.clientWidth; + setIsTruncated(isOverflowing); + } + } + }; + + checkOverflow(); + const observer = new ResizeObserver(checkOverflow); + if (wrapperRef.current) { + observer.observe(wrapperRef.current); + } + + return () => { + observer.disconnect(); + }; + } + }, [variant]); + + // Determine trigger based on mobile and variant + const shouldHandleHover = !isMobile || variant === 'value-display'; + const shouldHandleTouch = variant === 'value-display'; + + const show = useCallback(() => setShowTooltip(true), []); + const hide = useCallback(() => setShowTooltip(false), []); + + // Should show logic + const shouldShow = useMemo(() => { + if (variant === 'truncation' && !isTruncated) { + return false; + } + return showTooltip; + }, [variant, isTruncated, showTooltip]); + + // Hidden description for disabled-reason variant + const hiddenDescription = variant === 'disabled-reason' && ( + + ); + + // Tooltip value with LiveRegion if needed + const tooltipValue = useMemo(() => { + if (variant === 'feedback' || variant === 'truncation') { + return ( + + {content} + + ); + } + return content; + }, [variant, content]); + + // Enhanced children with ARIA props for disabled-reason + const enhancedChild = useMemo(() => { + if (variant === 'disabled-reason') { + return React.cloneElement(children, { + 'aria-describedby': descriptionId, + } as any); + } + if (variant === 'label') { + return React.cloneElement(children, { + ariaLabel: typeof content === 'string' ? content : undefined, + } as any); + } + if (variant === 'truncation') { + return React.cloneElement(children, { + 'data-truncation-target': true, + 'aria-label': typeof content === 'string' ? content : undefined, + tabIndex: isTruncated ? 0 : undefined, + } as any); + } + return children; + }, [children, variant, content, descriptionId, isTruncated]); + + return ( +
+ {enhancedChild} + {hiddenDescription} + {shouldShow && ( + + )} +
+ ); +} + +// ============================================================================ +// DEMO PAGE +// ============================================================================ + +export default function TooltipWrapperComponent() { + const [sliderValue, setSliderValue] = useState(50); + const [copied, setCopied] = useState(false); + + return ( +
+

Tooltip as Wrapper Component

+

Declarative wrapper syntax

+ + + Use Case 1: Icon Button Labels}> + +

Simple API (variant="label")

+

Hover over the buttons to see tooltips. Works on desktop, adapts to mobile.

+ + + +
+ + + What Happens: +
    +
  • Wraps child in a div
  • +
  • Adds event handlers to div
  • +
  • Sets aria-label on Button
  • +
  • Desktop: Shows on hover + focus
  • +
  • Mobile: Focus only (screen reader accessible)
  • +
+
+ + + + Use Case 2: Disabled with Reasons}> + +

Critical Information (variant="disabled-reason")

+

Hover over disabled button to see reason. Auto-manages all ARIA.

+ + + + + + + + + + + +
+
+                {`
+  
+
+
+// Auto-generates:
+// - Hidden 
for screen readers +// - aria-describedby="unique" on Button +// - Visual tooltip on hover/focus +// - Keeps button focusable (aria-disabled pattern) +// - WCAG compliant by default`} +
+
+ + + Accessibility: +
    +
  • aria-describedby auto-created
  • +
  • Hidden description for screen readers
  • +
  • Button stays focusable
  • +
  • Mobile: Screen reader gets reason
  • +
+
+
+
+ + Use Case 3: Action Feedback}> + +

Dynamic Feedback (variant="feedback")

+

Click to see feedback message. Auto-announces to screen readers.

+ + + + + +
+
+                {`
+  
+
+
+// Auto-wraps content in LiveRegion
+// Screen readers announce "Copied!"`}
+              
+
+
+
+ + Use Case 4: Value Display}> + +

Interactive Values (variant="value-display")

+

Drag slider to see value. Works with touch on mobile!

+ + + setSliderValue(detail.value)} + min={0} + max={100} + ariaLabel="Volume" + /> + + +
+
+                {`
+  
+
+
+`}
+              
+
+ + + Mobile Support: +
    +
  • Touch events: Shows while dragging
  • +
  • Desktop: Shows on hover + drag
  • +
  • Automatic - no configuration needed
  • +
+
+
+
+ + {/* Comparison: Component vs Hook}> + + +

Wrapper Component API (This Page)

+
+
+                  {`// Simplest - just wrap
+
+  
+
+
+ + +

Hook API (Previous Page)

+
+
+                  {`// Hook gives full control
+const tooltip = useTooltip({ 
+  content: "Copy",
+  variant: "label" 
+});
+
+
+
+ +// Pros: +// Full control over focus +// Works with any component +// No cloneElement +// Better for complex cases + +// Cons: +// More verbose (6 lines) +// Requires wrapper div`} +
+
+
+
+
+ + How the Wrapper Component Works}> + +

Implementation Details

+

The wrapper component uses React.cloneElement to enhance the child with ARIA props:

+ +
+
+                {`function Tooltip({ content, children, variant }) {
+  const wrapperRef = useRef(null);
+  const [showTooltip, setShowTooltip] = useState(false);
+
+  // Auto-detect mobile
+  const isMobile = useMobileDetection();
+  
+  // Enhance child with ARIA props
+  const enhancedChild = React.cloneElement(children, {
+    // For 'label' variant
+    ariaLabel: variant === 'label' ? content : undefined,
+    
+    // For 'disabled-reason' variant  
+    'aria-describedby': variant === 'disabled-reason' ? descId : undefined,
+  });
+
+  return (
+    
setShowTooltip(true) : undefined} + onMouseLeave={!isMobile ? () => setShowTooltip(false) : undefined} + // All devices: focus + onFocus={() => setShowTooltip(true)} + onBlur={() => setShowTooltip(false)} + // Mobile value-display: touch + onTouchStart={variant === 'value-display' ? () => setShowTooltip(true) : undefined} + onTouchEnd={variant === 'value-display' ? () => setShowTooltip(false) : undefined} + > + {enhancedChild} + {hiddenDescription} + {showTooltip && ( + + )} +
+ ); +}`} +
+
+ + +

Key Mechanisms:

+
    +
  • + Wrapper Div: Captures events before they reach child +
  • +
  • + React.cloneElement: Injects ARIA props into child +
  • +
  • + Auto-Mobile Detection: Uses (pointer: coarse) media query +
  • +
  • + Conditional Events: Hover suppressed on mobile, touch added for value-display +
  • +
+
+
+
*/} + + {/* When to Use Component vs Hook}> +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ScenarioUse ComponentUse Hook
Simple icon button✅ Perfect fitWorks but verbose
Disabled with reason✅ Clean syntaxMore control
Action feedback✅ SimpleWhen manual show/hide needed
Complex focus stylingMay not work✅ Use hook
Multiple coordinated tooltipsDifficult✅ Use hook
Existing event handlers on childWorks (wrapper intercepts)✅ Better control
+
+
*/} + + {/* Wrapper Component Limitations}> + + +

Limitation 1: Extra Wrapper Div

+

Component adds a wrapper div around your content:

+
+
+                  {`
+  
+ +// May affect layout in flex/grid contexts`} + +
+ + + +

Limitation 2: React.cloneElement

+

Uses cloneElement to inject ARIA props - may not work with all components:

+
+
+                  {`// Works with most components
+
+  
+
+
+ + +

Limitation 3: Focus Control

+

Can't access child's internal focus state:

+
+
+                  {`// Wrapper detects focus on wrapper div
+// Can't see if Button has custom focus styling
+
+// If you need to sync focus state:
+// - Use hook API instead
+// - Gives you full control over focus behavior`}
+                
+
+
+ + */} + + {/* Best Practices}> + + +

DO: Use for Simple Cases

+
    +
  • Icon button labels
  • +
  • Disabled reasons on standard components
  • +
  • Action feedback messages
  • +
  • Value display (sliders, pickers)
  • +
+
+ + +

Consider Hook For:

+
    +
  • Complex focus requirements
  • +
  • Custom components with special needs
  • +
  • Multiple coordinated tooltips
  • +
  • Fine-grained event control
  • +
  • Layout-sensitive contexts (flex/grid)
  • +
+
+ + +

💡 Pro Tips:

+
    +
  • Start with wrapper - simplest for most cases
  • +
  • Switch to hook if you hit limitations
  • +
  • Both APIs use same underlying logic
  • +
  • Can mix and match in same application
  • +
+
+
+
*/} + + {/* Summary}> +
+

Wrapper Component Benefits:

+ +
Simplest API - Just wrap your component
+
Declarative - Reads naturally in JSX
+
Zero boilerplate - No state, no refs, no events
+
Auto-everything - Mobile, ARIA, events handled
+
90% use cases - Perfect for common scenarios
+
+ + +

Trade-off:

+

+ Adds wrapper div + uses cloneElement. For 90% of cases this is fine. For complex focus needs or custom + components, use the hook API instead. +

+
+
+
*/} + + + ); +} diff --git a/src/internal/do-not-use/tooltip.ts b/src/internal/do-not-use/tooltip.ts index 7fa0ae38d4..5eddddf626 100644 --- a/src/internal/do-not-use/tooltip.ts +++ b/src/internal/do-not-use/tooltip.ts @@ -1,6 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import InternalTooltip, { TooltipProps } from '../components/tooltip/index.js'; +import { TooltipProps } from '../../tooltip/interfaces.js'; +import InternalTooltip from '../../tooltip/internal.js'; + export type InternalTooltipProps = TooltipProps; +export default InternalTooltip; export { InternalTooltip }; diff --git a/src/tooltip/__integ__/tooltip.test.ts b/src/tooltip/__integ__/tooltip.test.ts new file mode 100644 index 0000000000..79f80594a6 --- /dev/null +++ b/src/tooltip/__integ__/tooltip.test.ts @@ -0,0 +1,38 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects'; +import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; + +import createWrapper from '../../../../../lib/components/test-utils/selectors'; + +import tooltipStyles from '../../../../../lib/components/internal/components/tooltip/styles.selectors.js'; + +test( + 'should not close any wrapping modals when the tooltip detects an Escape keypress', + useBrowser(async browser => { + await browser.url('/#/light/modal/with-tooltip'); + const page = new BasePageObject(browser); + + const openButtonSelector = createWrapper().findButton().toSelector(); + await page.waitForVisible(openButtonSelector); + await page.click(openButtonSelector); + + const modal = createWrapper().findModal(); + const slider = modal.findContent().findSlider(); + await page.waitForVisible(slider.toSelector()); + + // Slider on the page is set at 50% on purpose. `hoverElement` will move the + // mouse to the center of the track where the "thumb" is. + await page.hoverElement(slider.findNativeInput().toSelector()); + await page.waitForVisible(`.${tooltipStyles.root}`); + + // Press once to close the tooltip + await page.keys(['Escape']); + await expect(page.isDisplayed(`.${tooltipStyles.root}`)).resolves.toBe(false); + await expect(page.isDisplayed(modal.toSelector())).resolves.toBe(true); + + // Press again to close the modal + await page.keys(['Escape']); + await expect(page.isDisplayed(modal.toSelector())).resolves.toBe(false); + }) +); diff --git a/src/tooltip/__tests__/simple-tooltip.test.tsx b/src/tooltip/__tests__/simple-tooltip.test.tsx new file mode 100644 index 0000000000..130465038a --- /dev/null +++ b/src/tooltip/__tests__/simple-tooltip.test.tsx @@ -0,0 +1,57 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render } from '@testing-library/react'; + +import createWrapper from '../../../lib/components/test-utils/dom'; +import { SimpleTooltip } from '../simple-tooltip'; + +describe('SimpleTooltip', () => { + it('renders children correctly', () => { + const { container } = render( + + + + ); + + const wrapper = createWrapper(container); + expect(wrapper.findButton()!.getElement()).toHaveTextContent('Hover me'); + }); + + it('renders tooltip content when hovered', () => { + const { container } = render( + + + + ); + + const wrapper = createWrapper(container); + expect(wrapper.getElement()).toHaveTextContent('Tooltip text'); + }); + + it('handles React node content', () => { + const { container } = render( + Bold tooltip}> + + + ); + + const wrapper = createWrapper(container); + expect(wrapper.getElement()).toHaveTextContent('Bold tooltip'); + }); + + it('supports all positions', () => { + const positions: Array<'top' | 'right' | 'bottom' | 'left'> = ['top', 'right', 'bottom', 'left']; + + positions.forEach(position => { + const { container } = render( + + + + ); + + const wrapper = createWrapper(container); + expect(wrapper.getElement()).toHaveTextContent('Tooltip text'); + }); + }); +}); diff --git a/src/tooltip/__tests__/tooltip.test.tsx b/src/tooltip/__tests__/tooltip.test.tsx new file mode 100644 index 0000000000..f628b0d8b4 --- /dev/null +++ b/src/tooltip/__tests__/tooltip.test.tsx @@ -0,0 +1,103 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { act, render } from '@testing-library/react'; + +import InternalTooltip from '../../internal/components/tooltip'; +import { TooltipProps } from '../../internal/components/tooltip'; +import StatusIndicator from '../../status-indicator/internal'; +import createWrapper, { ElementWrapper, PopoverWrapper } from '../../test-utils/dom'; + +import tooltipStyles from '../../internal/components/tooltip/styles.css.js'; +import styles from '../../popover/styles.css.js'; + +class TooltipInternalWrapper extends PopoverWrapper { + findTooltip(): ElementWrapper | null { + return createWrapper().findByClassName(tooltipStyles.root); + } + findContent(): ElementWrapper | null { + return createWrapper().findByClassName(styles.content); + } + findArrow(): ElementWrapper | null { + return createWrapper().findByClassName(styles.arrow); + } + findHeader(): ElementWrapper | null { + return createWrapper().findByClassName(styles.header); + } +} + +const dummyRef = { current: null }; +function renderTooltip(props: Partial) { + const { container } = render( + + ); + return new TooltipInternalWrapper(container); +} + +describe('Tooltip', () => { + it('renders text correctly', () => { + const wrapper = renderTooltip({ value: 'Value' }); + + expect(wrapper.findContent()!.getElement()).toHaveTextContent('Value'); + }); + + it('renders node correctly', () => { + const wrapper = renderTooltip({ value: Success }); + const statusIndicatorWrapper = createWrapper(wrapper.findContent()!.getElement()).findStatusIndicator()!; + + expect(statusIndicatorWrapper.getElement()).toHaveTextContent('Success'); + }); + + it('renders arrow', () => { + const wrapper = renderTooltip({ value: 'Value' }); + + expect(wrapper.findArrow()).not.toBeNull(); + }); + + it('does not render a header', () => { + const wrapper = renderTooltip({ value: 'Value' }); + + expect(wrapper.findHeader()).toBeNull(); + }); + + it('contentAttributes work as expected', () => { + const wrapper = renderTooltip({ value: 'Value', contentAttributes: { title: 'test' } }); + + expect(wrapper.findTooltip()?.getElement()).toHaveAttribute('title', 'test'); + }); + + it('trackKey is set correctly for strings', () => { + const wrapper = renderTooltip({ value: 'Value' }); + + expect(wrapper.findTooltip()?.getElement()).toHaveAttribute('data-testid', 'Value'); + }); + + it('trackKey is set correctly for explicit value', () => { + const trackKey = 'test-track-key'; + const wrapper = renderTooltip({ value: 'Value', trackKey }); + + expect(wrapper.findTooltip()?.getElement()).toHaveAttribute('data-testid', trackKey); + }); + + it('calls onDismiss when an Escape keypress is detected anywhere', () => { + const onDismiss = jest.fn(); + const keydownEvent = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }); + jest.spyOn(keydownEvent, 'stopPropagation'); + + renderTooltip({ value: 'Value', onDismiss }); + expect(onDismiss).not.toHaveBeenCalled(); + + act(() => { + // Dispatch the exect event instance so that we can spy stopPropagation on it. + document.body.dispatchEvent(keydownEvent); + }); + expect(keydownEvent.stopPropagation).toHaveBeenCalled(); + expect(onDismiss).toHaveBeenCalled(); + }); +}); diff --git a/src/tooltip/index.tsx b/src/tooltip/index.tsx new file mode 100644 index 0000000000..5f5781b034 --- /dev/null +++ b/src/tooltip/index.tsx @@ -0,0 +1,55 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import { getBaseProps } from '../internal/base-component'; +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { TooltipProps } from './interfaces'; +import InternalTooltip from './internal'; + +export { TooltipProps }; + +const Tooltip = React.forwardRef( + ( + { + value, + trackRef, + trackKey, + position, + className, + contentAttributes, + size, + hideOnOverscroll, + onDismiss, + ...props + }: TooltipProps, + ref: React.Ref + ) => { + const baseComponentProps = useBaseComponent('Tooltip', { + props: { position, size }, + }); + const baseProps = getBaseProps(props); + + return ( + + ); + } +); + +applyDisplayName(Tooltip, 'Tooltip'); +export default Tooltip; diff --git a/src/tooltip/interfaces.ts b/src/tooltip/interfaces.ts new file mode 100644 index 0000000000..1f3de0e5f4 --- /dev/null +++ b/src/tooltip/interfaces.ts @@ -0,0 +1,63 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { NonCancelableEventHandler } from '../internal/events'; +import { PopoverProps } from '../popover/interfaces'; + +export interface TooltipProps { + /** + * The content to display in the tooltip. + */ + value: React.ReactNode; + + /** + * Reference to the element the tooltip is positioned against. + */ + trackRef: React.RefObject; + + /** + * A unique key to identify the tooltip. If not provided and value is a string or number, + * the value will be used as the key. + */ + trackKey?: string | number; + + /** + * The position of the tooltip relative to the tracked element. + * @default 'top' + */ + position?: 'top' | 'right' | 'bottom' | 'left'; + + /** + * Additional CSS class name to apply to the tooltip container. + */ + className?: string; + + /** + * Additional HTML attributes to apply to the tooltip content container. + */ + contentAttributes?: React.HTMLAttributes; + + /** + * The size of the tooltip. + * @default 'small' + */ + size?: PopoverProps['size']; + + /** + * If true, the tooltip will be hidden when the page is scrolled. + */ + hideOnOverscroll?: boolean; + + /** + * Callback function called when the tooltip should be dismissed. + * @internal + */ + onDismiss?: NonCancelableEventHandler; +} + +export namespace TooltipProps { + export interface Ref { + focus(): void; + } +} diff --git a/src/tooltip/internal.tsx b/src/tooltip/internal.tsx new file mode 100644 index 0000000000..8e566da359 --- /dev/null +++ b/src/tooltip/internal.tsx @@ -0,0 +1,105 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useEffect } from 'react'; + +import { Portal } from '@cloudscape-design/component-toolkit/internal'; + +import { Transition } from '../internal/components/transition'; +import { fireNonCancelableEvent } from '../internal/events'; +import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import PopoverArrow from '../popover/arrow'; +import PopoverBody from '../popover/body'; +import PopoverContainer from '../popover/container'; +import { TooltipProps } from './interfaces'; + +import styles from './styles.css.js'; + +export type InternalTooltipProps = TooltipProps & InternalBaseComponentProps; + +const InternalTooltip = React.forwardRef( + ( + { + value, + trackRef, + trackKey, + className, + contentAttributes = {}, + position = 'top', + size = 'small', + hideOnOverscroll, + onDismiss, + __internalRootRef, + ...props + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _ref + ) => { + let finalTrackKey = trackKey; + if (!finalTrackKey && (typeof value === 'string' || typeof value === 'number')) { + finalTrackKey = value; + } + + useEffect(() => { + const controller = new AbortController(); + window.addEventListener( + 'keydown', + (event: KeyboardEvent) => { + if (event.key === 'Escape') { + // Prevent any surrounding modals or dialogs from acting on this Esc. + event.stopPropagation(); + fireNonCancelableEvent(onDismiss); + } + }, + { + // The tooltip is often activated on mouseover, which means the focus can + // be anywhere else on the page. Capture also means that this gets called + // before any wrapper modals or dialogs can detect it and act on it. + capture: true, + signal: controller.signal, + } + ); + return () => { + controller.abort(); + }; + }, [onDismiss]); + + return ( + +
+ + {() => ( + } + hideOnOverscroll={hideOnOverscroll} + className={className} + > + + {value} + + + )} + +
+
+ ); + } +); + +export default InternalTooltip; diff --git a/src/tooltip/simple-tooltip.tsx b/src/tooltip/simple-tooltip.tsx new file mode 100644 index 0000000000..c2908feade --- /dev/null +++ b/src/tooltip/simple-tooltip.tsx @@ -0,0 +1,63 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useRef, useState } from 'react'; + +import InternalTooltip from './internal'; + +export interface SimpleTooltipProps { + /** + * The tooltip content to display. + */ + content: React.ReactNode; + + /** + * The element that triggers the tooltip on hover. + */ + children: React.ReactNode; + + /** + * The position of the tooltip relative to the trigger element. + * @default 'top' + */ + position?: 'top' | 'right' | 'bottom' | 'left'; +} + +/** + * A simplified, accessible tooltip component that requires minimal props. + * + * Features: + * - Automatic state and ref management + * - Keyboard accessible (Escape to dismiss) + * - ARIA compliant + * - Hover activation + * + * Example: + * ```tsx + * + * + * + * ``` + */ +export function SimpleTooltip({ content, children, position = 'top' }: SimpleTooltipProps) { + const triggerRef = useRef(null); + const [show, setShow] = useState(false); + + return ( + <> + setShow(true)} + onMouseLeave={() => setShow(false)} + onFocus={() => setShow(true)} + onBlur={() => setShow(false)} + // Make the trigger keyboard accessible if it contains interactive elements + style={{ display: 'inline-block' }} + > + {children} + + {show && ( + setShow(false)} /> + )} + + ); +} diff --git a/src/tooltip/styles.scss b/src/tooltip/styles.scss new file mode 100644 index 0000000000..2be8e137cc --- /dev/null +++ b/src/tooltip/styles.scss @@ -0,0 +1,8 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +.root { + /* used in tests */ +} diff --git a/src/tooltip/tooltip-coordinator.tsx b/src/tooltip/tooltip-coordinator.tsx new file mode 100644 index 0000000000..3b4dc80d35 --- /dev/null +++ b/src/tooltip/tooltip-coordinator.tsx @@ -0,0 +1,53 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { createContext, ReactNode, useCallback, useContext, useMemo, useState } from 'react'; + +interface TooltipCoordinatorContextValue { + activeTooltipId: string | null; + registerTooltip: (id: string) => void; + unregisterTooltip: (id: string) => void; +} + +const TooltipCoordinatorContext = createContext(null); + +/** + * TooltipCoordinator ensures only one tooltip is visible at a time within its scope. + * Wrap multiple elements with tooltips in this component to enable coordination. + * + * @example + * ```tsx + * + * + * {tooltip1 && } + * + * + * {tooltip2 && } + * + * ``` + */ +export function TooltipCoordinator({ children }: { children: ReactNode }) { + const [activeTooltipId, setActiveTooltipId] = useState(null); + + const registerTooltip = useCallback((id: string) => { + setActiveTooltipId(id); + }, []); + + const unregisterTooltip = useCallback((id: string) => { + setActiveTooltipId(current => (current === id ? null : current)); + }, []); + + const contextValue = useMemo( + () => ({ activeTooltipId, registerTooltip, unregisterTooltip }), + [activeTooltipId, registerTooltip, unregisterTooltip] + ); + + return {children}; +} + +/** + * Hook to access the tooltip coordinator context. + * Returns null if not within a TooltipCoordinator. + */ +export function useTooltipCoordinator() { + return useContext(TooltipCoordinatorContext); +} diff --git a/src/tooltip/use-tooltip-advanced.ts b/src/tooltip/use-tooltip-advanced.ts new file mode 100644 index 0000000000..d85ac67d14 --- /dev/null +++ b/src/tooltip/use-tooltip-advanced.ts @@ -0,0 +1,141 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { TooltipProps } from './interfaces'; +import { useTooltipCoordinator } from './tooltip-coordinator'; + +/** + * @internal + */ +export interface UseTooltipAdvancedOptions { + position?: TooltipProps['position']; + size?: TooltipProps['size']; + content: React.ReactNode; + onDismiss?: () => void; + id?: string; + disableCoordination?: boolean; +} + +/** + * @internal + */ +export interface TooltipApi { + show: () => void; + hide: () => void; + toggle: () => void; + isVisible: boolean; +} + +/** + * @internal + */ +export function useTooltipAdvanced(options: UseTooltipAdvancedOptions): [ + targetProps: { + ref: React.RefObject; + onMouseEnter: () => void; + onMouseLeave: () => void; + onFocus: () => void; + onBlur: () => void; + }, + tooltipProps: TooltipProps | null, + api: TooltipApi, +] { + const ref = useRef(null); + + // Generate stable ID for this tooltip instance + const tooltipId = useRef(options.id || `tooltip-${Math.random().toString(36).substr(2, 9)}`).current; + + // Get coordinator context (null if not wrapped in TooltipCoordinator) + const coordinator = useTooltipCoordinator(); + + // Destructure to get stable references + const activeTooltipId = coordinator?.activeTooltipId ?? null; + const registerTooltip = coordinator?.registerTooltip; + const unregisterTooltip = coordinator?.unregisterTooltip; + + // Track hover and focus states separately + const [isHovered, setIsHovered] = useState(false); + const [isFocused, setIsFocused] = useState(false); + + // Determine if THIS tooltip should be visible + const wantsToShow = isHovered || isFocused; + const isCoordinated = coordinator && !options.disableCoordination; + const isActiveTooltip = !isCoordinated || activeTooltipId === tooltipId; + const isVisible = wantsToShow && isActiveTooltip; + + // Register/unregister with coordinator when wantsToShow changes + useEffect(() => { + if (!registerTooltip || !unregisterTooltip || options.disableCoordination) { + return; + } + + if (wantsToShow) { + registerTooltip(tooltipId); + } else { + unregisterTooltip(tooltipId); + } + }, [wantsToShow, registerTooltip, unregisterTooltip, options.disableCoordination, tooltipId]); + + const handleMouseEnter = useCallback(() => { + setIsHovered(true); + }, []); + + const handleMouseLeave = useCallback(() => { + setIsHovered(false); + }, []); + + const handleFocus = useCallback(() => { + setIsFocused(true); + }, []); + + const handleBlur = useCallback(() => { + setIsFocused(false); + }, []); + + const show = useCallback(() => { + setIsHovered(true); + setIsFocused(true); + }, []); + + const hide = useCallback(() => { + setIsHovered(false); + setIsFocused(false); + options.onDismiss?.(); + }, [options]); + + const toggle = useCallback(() => { + if (isVisible) { + hide(); + } else { + show(); + } + }, [isVisible, show, hide]); + + const targetProps = { + ref, + onMouseEnter: handleMouseEnter, + onMouseLeave: handleMouseLeave, + onFocus: handleFocus, + onBlur: handleBlur, + }; + + const tooltipProps: TooltipProps | null = isVisible + ? { + trackRef: ref, + value: options.content, + position: options.position, + size: options.size, + onDismiss: options.onDismiss ? hide : undefined, + } + : null; + + const api: TooltipApi = { + show, + hide, + toggle, + isVisible, + }; + + return [targetProps, tooltipProps, api]; +} diff --git a/src/tooltip/use-tooltip.ts b/src/tooltip/use-tooltip.ts new file mode 100644 index 0000000000..30865a9be4 --- /dev/null +++ b/src/tooltip/use-tooltip.ts @@ -0,0 +1,43 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { useRef, useState } from 'react'; + +import type { TooltipProps } from './interfaces'; + +export interface UseTooltipOptions { + position?: TooltipProps['position']; + size?: TooltipProps['size']; + onDismiss?: () => void; +} + +export function useTooltip(content: React.ReactNode, options?: UseTooltipOptions) { + const ref = useRef(null); + const [show, setShow] = useState(false); + + const handleDismiss = () => { + setShow(false); + options?.onDismiss?.(); + }; + + return { + // Spread these props on your trigger element + triggerProps: { + ref, + onMouseEnter: () => setShow(true), + onMouseLeave: () => setShow(false), + }, + // Render this after your trigger element + tooltip: show + ? { + trackRef: ref, + value: content, + position: options?.position, + size: options?.size, + onDismiss: options?.onDismiss ? handleDismiss : undefined, + } + : null, + // Manual control if needed + show, + setShow, + }; +}