import { html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { USWDSBaseComponent } from '../../utils/base-component.js';
import { initializeTooltip } from './usa-tooltip-behavior.js';
// Import official USWDS compiled CSS
import '../../styles/styles.css';
/**
* USA Tooltip Web Component
*
* Minimal wrapper around USWDS tooltip functionality.
* All tooltip behavior and positioning is managed by USWDS JavaScript.
*
* @element usa-tooltip
* @fires tooltip-show - Dispatched when tooltip is shown
* @fires tooltip-hide - Dispatched when tooltip is hidden
*
* @see README.mdx - Complete API documentation, usage examples, and implementation notes
* @see CHANGELOG.mdx - Component version history and breaking changes
* @see TESTING.mdx - Testing documentation and coverage reports
*
* @uswds-js-reference https://github.com/uswds/uswds/tree/develop/packages/usa-tooltip/src/index.js
* @uswds-css-reference https://github.com/uswds/uswds/tree/develop/packages/usa-tooltip/src/styles/_usa-tooltip.scss
* @uswds-docs https://designsystem.digital.gov/components/tooltip/
* @uswds-guidance https://designsystem.digital.gov/components/tooltip/#guidance
* @uswds-accessibility https://designsystem.digital.gov/components/tooltip/#accessibility
*/
@customElement('usa-tooltip')
export class USATooltip extends USWDSBaseComponent {
static override styles = css`
:host {
display: inline-block;
}
:host([hidden]) {
display: none;
}
`;
@property({ type: String })
text = '';
@property({ type: String })
position: 'top' | 'bottom' | 'left' | 'right' = 'top';
@property({ type: String })
override title = '';
@property({ type: String })
label = '';
@property({ type: Boolean, reflect: true })
visible = false;
@property({ type: String })
classes = '';
// Slot content handling to prevent duplication
private slottedContent: string = '';
// Store cleanup function from behavior
private cleanup?: () => void;
// CRITICAL: Light DOM implementation for USWDS compatibility
protected override createRenderRoot() {
return this;
}
override connectedCallback() {
super.connectedCallback();
// Set web component managed flag to prevent USWDS auto-initialization conflicts
this.setAttribute('data-web-component-managed', 'true');
// Capture any initial light DOM content before render to prevent duplication
if (this.childNodes.length > 0) {
this.slottedContent = this.innerHTML;
this.innerHTML = '';
}
}
override async firstUpdated(changedProperties: Map) {
// ARCHITECTURE: Script Tag Pattern
// USWDS is loaded globally via script tag in .storybook/preview-head.html
// Components just render HTML - USWDS enhances automatically via window.USWDS
// ARCHITECTURE: USWDS-Mirrored Behavior Pattern
// Uses dedicated behavior file (usa-tooltip-behavior.ts) that replicates USWDS source exactly
super.firstUpdated(changedProperties);
// Wait for DOM to be fully rendered
await this.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => resolve(undefined)));
// Initialize using mirrored USWDS behavior
this.cleanup = initializeTooltip(this);
}
override disconnectedCallback() {
super.disconnectedCallback();
this.cleanup?.();
}
private applySlottedContent() {
if (this.slottedContent) {
const slotElement = this.querySelector('slot');
if (slotElement) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = this.slottedContent;
slotElement.replaceWith(...Array.from(tempDiv.childNodes));
}
}
}
/**
* Show the tooltip (API method)
*/
show() {
this.visible = true;
const trigger = this.querySelector('.usa-tooltip__trigger');
if (trigger) {
trigger.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
}
this.dispatchEvent(
new CustomEvent('tooltip-show', {
bubbles: true,
composed: true,
detail: {
tooltip: this,
text: this.text || this.title || this.label,
position: this.position,
},
})
);
}
/**
* Hide the tooltip (API method)
*/
hide() {
this.visible = false;
const wrapper = this.closest('.usa-tooltip') || this.parentElement?.querySelector('.usa-tooltip');
if (wrapper) {
wrapper.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
}
this.dispatchEvent(
new CustomEvent('tooltip-hide', {
bubbles: true,
composed: true,
detail: {
tooltip: this,
text: this.text || this.title || this.label,
position: this.position,
},
})
);
}
/**
* Toggle the tooltip visibility
*/
toggle() {
if (this.visible) {
this.hide();
} else {
this.show();
}
}
override render() {
// Generate USWDS tooltip structure from properties
const tooltipText = this.text || this.title || this.label;
// Check if we have slotted content or text
const hasSlottedContent = this.children.length > 0 || this.slottedContent.length > 0;
const hasExplicitText = this.text || this.title || this.label;
// If we have slotted content (with or without text), render slot
// The slotted element will be marked with .usa-tooltip class in updated()
if (hasSlottedContent) {
return html``;
} else if (hasExplicitText) {
// No slotted content - create a span trigger with the label
const displayLabel = this.label || 'Tooltip trigger';
// USWDS expects element with .usa-tooltip class and title attribute
return html`
${displayLabel}
`;
} else {
// No content at all - render empty slot
return html``;
}
}
override updated(changedProperties: Map) {
super.updated(changedProperties);
// Apply captured slot content using DOM manipulation
this.applySlottedContent();
const tooltipText = this.text || this.title || this.label;
const hasSlottedContent = this.children.length > 0;
// If we have slotted content AND text, add .usa-tooltip class and title to slotted element
if (hasSlottedContent && tooltipText && (changedProperties.has('text') || changedProperties.has('title'))) {
// Find the first child element (the trigger)
const triggerElement = this.children[0] as HTMLElement;
if (triggerElement && !triggerElement.classList.contains('usa-tooltip')) {
// Add USWDS tooltip class and attributes so USWDS will transform it
triggerElement.classList.add('usa-tooltip');
triggerElement.setAttribute('title', tooltipText);
triggerElement.setAttribute('data-position', this.position);
if (this.classes) {
triggerElement.setAttribute('data-classes', this.classes);
}
// Re-initialize USWDS to transform this element
initializeTooltip(this);
}
}
// Update tooltip text if text property changed
if (changedProperties.has('text') || changedProperties.has('title')) {
// After USWDS initialization, the structure is:
//
// (wrapper)
// (trigger with removed title)
// (tooltip content)
// Check if USWDS has already transformed the tooltip
const tooltipBody = this.querySelector('.usa-tooltip__body');
if (tooltipBody) {
// USWDS has transformed - update the body content directly
// DO NOT set title attribute as USWDS removes it during transformation
tooltipBody.textContent = tooltipText;
} else if (!hasSlottedContent) {
// No slotted content - we rendered a span, ensure title is set
const wrapper = this.querySelector('.usa-tooltip');
if (wrapper && !wrapper.hasAttribute('title')) {
wrapper.setAttribute('title', tooltipText);
}
}
}
// Update position if changed
if (changedProperties.has('position')) {
const wrapper = this.querySelector('.usa-tooltip');
const trigger = this.querySelector('.usa-tooltip__trigger');
if (trigger) {
(trigger as HTMLElement).setAttribute('data-position', this.position);
} else if (wrapper) {
wrapper.setAttribute('data-position', this.position);
}
}
// Update classes if changed
if (changedProperties.has('classes') && this.classes) {
// After USWDS transformation with slotted content, the structure is:
//
// (wrapper created by USWDS from slotted element)
// (original slotted element)
// Find the wrapper - must be a direct child with .usa-tooltip class
const wrapper = Array.from(this.children).find(
child => child.classList.contains('usa-tooltip')
) as HTMLElement;
if (wrapper) {
// Remove old custom classes (not usa-tooltip)
const classesToRemove = Array.from(wrapper.classList).filter(
cls => cls !== 'usa-tooltip' && cls !== 'usa-tooltip--active'
);
classesToRemove.forEach(cls => wrapper.classList.remove(cls));
// Add new classes
const newClasses = this.classes.split(' ').filter(cls => cls.trim());
newClasses.forEach(cls => wrapper.classList.add(cls));
} else if (hasSlottedContent) {
// Not yet transformed - add data-classes to trigger element for USWDS to pick up
const triggerElement = this.children[0] as HTMLElement;
if (triggerElement) {
triggerElement.setAttribute('data-classes', this.classes);
}
}
}
}
}