{"name":"tooltip","type":"registry:component","title":"Tooltip","description":"A tooltip component that displays information when an element receives keyboard focus or the mouse hovers over it. Built with Lit and uses Floating UI for smart positioning.","categories":["ui","tooltip","web-component"],"author":"Lloyd Richards <lloyd.d.richards@gmail.com>","registryDependencies":["@lit/popover","@lit/button"],"dependencies":["@floating-ui/dom","lucide-static"],"files":[{"path":"registry/ui/tooltip/tooltip.ts","type":"registry:ui","content":"import type { Placement } from \"@floating-ui/dom\";\nimport { cva } from \"class-variance-authority\";\nimport { css, html, LitElement, type PropertyValues } from \"lit\";\nimport { customElement, property, query, state } from \"lit/decorators.js\";\nimport { TW } from \"@/lib/tailwindMixin\";\nimport { cn } from \"@/lib/utils\";\nimport \"../popover/popover\";\nimport type { Popover as PopupElement } from \"../popover/popover\";\n\nconst tooltipVariants = cva(\n  \"max-w-80 z-50 overflow-hidden rounded-md border border-border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md\",\n  {\n    variants: {\n      placement: {\n        top: \"animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2\",\n        bottom: \"animate-in fade-in-0 zoom-in-95 slide-in-from-top-2\",\n        left: \"animate-in fade-in-0 zoom-in-95 slide-in-from-right-2\",\n        right: \"animate-in fade-in-0 zoom-in-95 slide-in-from-left-2\",\n      },\n    },\n  },\n);\n\nexport interface TooltipProperties {\n  content?: string;\n  placement?: Placement;\n  disabled?: boolean;\n  distance?: number;\n  open?: boolean;\n  skidding?: number;\n  trigger?: string;\n  hoist?: boolean;\n}\n\n@customElement(\"ui-tooltip\")\nexport class Tooltip extends TW(LitElement) implements TooltipProperties {\n  @property() content = \"\";\n  @property() placement: Placement = \"top\";\n  @property({ type: Boolean, reflect: true }) disabled = false;\n  @property({ type: Number }) distance = 8;\n  @property({ type: Boolean, reflect: true }) open = false;\n  @property({ type: Number }) skidding = 0;\n  @property() trigger = \"hover focus\";\n  @property({ type: Boolean }) hoist = false;\n\n  @query(\"ui-popover\") private popup!: PopupElement;\n\n  @state() private currentPlacement: Placement = \"top\";\n\n  private hoverTimeout?: number;\n\n  static styles = css`\n    :host {\n      display: inline-block;\n    }\n  `;\n\n  constructor() {\n    super();\n    this.addEventListener(\"blur\", this.handleBlur, true);\n    this.addEventListener(\"focus\", this.handleFocus, true);\n    this.addEventListener(\"click\", this.handleClick);\n    this.addEventListener(\"mouseenter\", this.handleMouseEnter);\n    this.addEventListener(\"mouseleave\", this.handleMouseLeave);\n    this.addEventListener(\"keydown\", this.handleKeyDown);\n  }\n\n  override disconnectedCallback() {\n    super.disconnectedCallback();\n    this.clearHoverTimeout();\n  }\n\n  override firstUpdated() {\n    if (this.open && !this.disabled) {\n      this.popup.active = true;\n      this.popup.reposition();\n    }\n  }\n\n  override updated(changedProperties: PropertyValues) {\n    super.updated(changedProperties);\n\n    if (changedProperties.has(\"open\")) {\n      if (this.open && !this.disabled) {\n        this.handleShow();\n      } else {\n        this.handleHide();\n      }\n    }\n\n    if (changedProperties.has(\"disabled\") && this.disabled && this.open) {\n      this.hide();\n    }\n\n    if (\n      this.hasUpdated &&\n      (changedProperties.has(\"content\") ||\n        changedProperties.has(\"distance\") ||\n        changedProperties.has(\"hoist\") ||\n        changedProperties.has(\"placement\") ||\n        changedProperties.has(\"skidding\"))\n    ) {\n      this.updateComplete.then(() => {\n        this.popup?.reposition();\n      });\n    }\n  }\n\n  private clearHoverTimeout() {\n    if (this.hoverTimeout) {\n      clearTimeout(this.hoverTimeout);\n      this.hoverTimeout = undefined;\n    }\n  }\n\n  private handleBlur = () => {\n    if (this.hasTrigger(\"focus\")) {\n      this.hide();\n    }\n  };\n\n  private handleClick = () => {\n    if (this.hasTrigger(\"click\")) {\n      if (this.open) {\n        this.hide();\n      } else {\n        this.show();\n      }\n    }\n  };\n\n  private handleFocus = () => {\n    if (this.hasTrigger(\"focus\")) {\n      this.show();\n    }\n  };\n\n  private handleKeyDown = (event: KeyboardEvent) => {\n    if (this.open && event.key === \"Escape\") {\n      event.stopPropagation();\n      this.hide();\n    }\n  };\n\n  private handleMouseEnter = () => {\n    if (this.hasTrigger(\"hover\")) {\n      this.clearHoverTimeout();\n      this.hoverTimeout = window.setTimeout(() => this.show(), 700);\n    }\n  };\n\n  private handleMouseLeave = () => {\n    if (this.hasTrigger(\"hover\")) {\n      this.clearHoverTimeout();\n      this.hoverTimeout = window.setTimeout(() => this.hide(), 100);\n    }\n  };\n\n  private handlePopupReposition = (\n    e: CustomEvent<{ placement: Placement }>,\n  ) => {\n    this.currentPlacement = e.detail.placement;\n  };\n\n  private handleShow() {\n    if (this.disabled) return;\n\n    this.dispatchEvent(\n      new CustomEvent(\"tooltip-show\", {\n        bubbles: true,\n        composed: true,\n      }),\n    );\n\n    this.popup.active = true;\n    this.popup.reposition();\n\n    this.dispatchEvent(\n      new CustomEvent(\"tooltip-after-show\", {\n        bubbles: true,\n        composed: true,\n      }),\n    );\n  }\n\n  private handleHide() {\n    this.dispatchEvent(\n      new CustomEvent(\"tooltip-hide\", {\n        bubbles: true,\n        composed: true,\n      }),\n    );\n\n    this.popup.active = false;\n\n    this.dispatchEvent(\n      new CustomEvent(\"tooltip-after-hide\", {\n        bubbles: true,\n        composed: true,\n      }),\n    );\n  }\n\n  private hasTrigger(triggerType: string) {\n    const triggers = this.trigger.split(\" \");\n    return triggers.includes(triggerType);\n  }\n\n  show() {\n    if (this.open) return;\n    this.open = true;\n  }\n\n  hide() {\n    if (!this.open) return;\n    this.open = false;\n  }\n\n  override render() {\n    const placementVariant = this.currentPlacement.split(\"-\")[0] as\n      | \"top\"\n      | \"bottom\"\n      | \"left\"\n      | \"right\";\n\n    return html`\n      <ui-popover\n        placement=${this.placement}\n        .distance=${this.distance}\n        .skidding=${this.skidding}\n        strategy=${this.hoist ? \"fixed\" : \"absolute\"}\n        ?active=${this.open}\n        .flip=${true}\n        .shift=${true}\n        @popover-reposition=${this.handlePopupReposition}\n      >\n        <slot slot=\"anchor\" aria-describedby=\"tooltip\"></slot>\n\n        <div\n          part=\"body\"\n          id=\"tooltip\"\n          class=${cn(\n            tooltipVariants({\n              placement: this.open ? placementVariant : undefined,\n            }),\n          )}\n          role=\"tooltip\"\n          aria-live=${this.open ? \"polite\" : \"off\"}\n        >\n          <slot name=\"content\">${this.content}</slot>\n        </div>\n      </ui-popover>\n    `;\n  }\n}\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    \"ui-tooltip\": Tooltip;\n  }\n\n  interface HTMLElementEventMap {\n    \"tooltip-show\": CustomEvent;\n    \"tooltip-after-show\": CustomEvent;\n    \"tooltip-hide\": CustomEvent;\n    \"tooltip-after-hide\": CustomEvent;\n  }\n}\n"},{"path":"registry/ui/tooltip/tooltip.stories.ts","type":"registry:ui","content":"import \"./tooltip\";\nimport \"../button/button\";\nimport type { Meta, StoryObj } from \"@storybook/web-components-vite\";\nimport { html } from \"lit\";\nimport { unsafeSVG } from \"lit/directives/unsafe-svg.js\";\nimport { Plus } from \"lucide-static\";\nimport type { TooltipProperties } from \"./tooltip\";\n\ntype TooltipArgs = TooltipProperties & {\n  buttonText?: string;\n};\n\nconst meta: Meta<TooltipArgs> = {\n  title: \"ui/Tooltip\",\n  component: \"ui-tooltip\",\n  tags: [\"autodocs\"],\n  parameters: {\n    layout: \"centered\",\n  },\n  argTypes: {\n    content: { control: \"text\" },\n    placement: {\n      control: \"select\",\n      options: [\"top\", \"right\", \"bottom\", \"left\"],\n    },\n    buttonText: { control: \"text\" },\n  },\n  args: {\n    content: \"Add to library\",\n    placement: \"top\",\n    buttonText: \"Add\",\n  },\n  render: (args) => html`\n    <div class=\"flex min-h-64 items-center justify-center\">\n      <ui-tooltip\n        .content=${args.content || \"\"}\n        .placement=${args.placement || \"top\"}\n      >\n        <ui-button variant=\"ghost\" size=\"icon\">\n          ${unsafeSVG(Plus)}\n          <span class=\"sr-only\">${args.buttonText}</span>\n        </ui-button>\n      </ui-tooltip>\n    </div>\n  `,\n};\n\nexport default meta;\n\ntype Story = StoryObj<TooltipArgs>;\n\n/**\n * The default form of the tooltip.\n */\nexport const Default: Story = {};\n\n/**\n * Use the `bottom` side to display the tooltip below the element.\n */\nexport const Bottom: Story = {\n  args: {\n    placement: \"bottom\",\n  },\n};\n\n/**\n * Use the `left` side to display the tooltip to the left of the element.\n */\nexport const Left: Story = {\n  args: {\n    placement: \"left\",\n  },\n};\n\n/**\n * Use the `right` side to display the tooltip to the right of the element.\n */\nexport const Right: Story = {\n  args: {\n    placement: \"right\",\n  },\n};\n\n/**\n * Tooltip with custom content using slot.\n */\nexport const CustomContent: Story = {\n  render: () => html`\n    <div class=\"flex min-h-64 items-center justify-center\">\n      <ui-tooltip placement=\"top\">\n        <ui-button variant=\"ghost\" size=\"icon\">\n          <div class=\"[&>svg]:size-5\">${unsafeSVG(Plus)}</div>\n          <span class=\"sr-only\">Add</span>\n        </ui-button>\n        <div slot=\"content\">\n          <strong>Add to library</strong>\n          <p class=\"text-xs\">Press Ctrl+A</p>\n        </div>\n      </ui-tooltip>\n    </div>\n  `,\n};\n"}]}