, outside the sidebar, so it hides itself\n # with a body-level selector: only shown while a desktop sidebar is\n # collapsed (shadcn: hidden={state !== \"collapsed\" || isMobile}).\n TOOLTIP_CLASSES = \"[body:has([data-slot=sidebar][data-state=expanded])_&]:hidden max-md:hidden\"\n\n # tooltip: label shown on the right when the sidebar is collapsed to icons.\n def initialize(as: :button, variant: :default, size: :default, active: false, tooltip: nil, **attrs)\n raise ArgumentError, \"Invalid variant: #{variant}\" unless VARIANT_CLASSES.key?(variant)\n raise ArgumentError, \"Invalid size: #{size}\" unless SIZE_CLASSES.key?(size)\n\n @as = as\n @variant = variant\n @size = size\n @active = active\n @tooltip = tooltip\n super(**attrs)\n end\n\n def view_template(&block)\n return tag(@as, **attrs, &block) unless @tooltip\n\n tag(@as, **mix(attrs, tooltip_attrs)) do\n # First child, so [&>span:last-child]:truncate still finds the label.\n TooltipContent(class: TOOLTIP_CLASSES) { @tooltip }\n yield if block\n end\n end\n\n private\n\n def tooltip_attrs\n {\n data: {\n controller: \"ruby-ui--tooltip\",\n ruby_ui__tooltip_placement_value: \"right\",\n ruby_ui__tooltip_target: \"trigger\",\n action: [\n \"mouseenter->ruby-ui--tooltip#show\",\n \"mouseleave->ruby-ui--tooltip#hide\",\n \"focus->ruby-ui--tooltip#show\",\n \"blur->ruby-ui--tooltip#hide\"\n ]\n }\n }\n end\n\n def default_attrs\n {\n class: [\n \"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden\",\n \"rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring\",\n \"transition-[width,height,padding] focus-visible:ring-2\",\n \"active:bg-sidebar-accent active:text-sidebar-accent-foreground\",\n \"disabled:pointer-events-none disabled:opacity-50\",\n \"group-has-[[data-sidebar=menu-action]]/menu-item:pr-8\",\n \"aria-disabled:pointer-events-none aria-disabled:opacity-50\",\n \"data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium\",\n \"data-[active=true]:text-sidebar-accent-foreground\",\n \"data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground\",\n \"aria-expanded:hover:bg-sidebar-accent aria-expanded:hover:text-sidebar-accent-foreground\",\n \"group-data-[collapsible=icon]:!size-8\",\n \"group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate\",\n \"[&_svg]:size-4 [&_svg]:shrink-0\",\n VARIANT_CLASSES[@variant],\n SIZE_CLASSES[@size]\n ],\n data: {\n slot: \"sidebar-menu-button\",\n sidebar: \"menu-button\",\n size: @size,\n active: @active.to_s\n }\n }\n end\n end\nend\n"
+ "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SidebarMenuButton < Base\n VARIANT_CLASSES = {\n default: \"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground\",\n outline: [\n \"bg-background shadow-[0_0_0_1px_var(--sidebar-border)]\",\n \"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]\"\n ].join(\" \")\n }.freeze\n\n SIZE_CLASSES = {\n default: \"h-8 text-sm\",\n sm: \"h-7 text-xs\",\n lg: \"h-12 text-sm group-data-[collapsible=icon]:!p-0\"\n }.freeze\n\n # The tooltip controller places the content right after this button, inside\n # its own sidebar, so it hides from that sidebar's state alone: shown only\n # while the desktop sidebar around it is collapsed (shadcn:\n # hidden={state !== \"collapsed\" || isMobile}). A second, expanded sidebar\n # on the page no longer hides it.\n TOOLTIP_CLASSES = \"[[data-slot=sidebar]:not([data-state=collapsed])_&]:hidden max-md:hidden\"\n\n # tooltip: label shown on the right when the sidebar is collapsed to icons.\n def initialize(as: :button, variant: :default, size: :default, active: false, tooltip: nil, **attrs)\n raise ArgumentError, \"Invalid variant: #{variant}\" unless VARIANT_CLASSES.key?(variant)\n raise ArgumentError, \"Invalid size: #{size}\" unless SIZE_CLASSES.key?(size)\n\n @as = as\n @variant = variant\n @size = size\n @active = active\n @tooltip = tooltip\n super(**attrs)\n end\n\n def view_template(&block)\n return tag(@as, **attrs, &block) unless @tooltip\n\n tag(@as, **mix(attrs, tooltip_attrs)) do\n # First child, so [&>span:last-child]:truncate still finds the label.\n TooltipContent(side: :right, class: TOOLTIP_CLASSES) { @tooltip }\n yield if block\n end\n end\n\n private\n\n def tooltip_attrs\n {\n data: {\n controller: \"ruby-ui--tooltip\",\n ruby_ui__tooltip_target: \"trigger\"\n }\n }\n end\n\n def default_attrs\n {\n class: [\n \"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden\",\n \"rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring\",\n \"transition-[width,height,padding] focus-visible:ring-2\",\n \"active:bg-sidebar-accent active:text-sidebar-accent-foreground\",\n \"disabled:pointer-events-none disabled:opacity-50\",\n \"group-has-[[data-sidebar=menu-action]]/menu-item:pr-8\",\n \"aria-disabled:pointer-events-none aria-disabled:opacity-50\",\n \"data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium\",\n \"data-[active=true]:text-sidebar-accent-foreground\",\n \"data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground\",\n \"aria-expanded:hover:bg-sidebar-accent aria-expanded:hover:text-sidebar-accent-foreground\",\n \"group-data-[collapsible=icon]:!size-8\",\n \"group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate\",\n \"[&_svg]:size-4 [&_svg]:shrink-0\",\n VARIANT_CLASSES[@variant],\n SIZE_CLASSES[@size]\n ],\n data: {\n slot: \"sidebar-menu-button\",\n sidebar: \"menu-button\",\n size: @size,\n active: @active.to_s\n }\n }\n end\n end\nend\n"
},
{
"path": "sidebar_menu_item.rb",
diff --git a/mcp/data/registry/components/tooltip.json b/mcp/data/registry/components/tooltip.json
index 84422dc56..5afcca4f5 100644
--- a/mcp/data/registry/components/tooltip.json
+++ b/mcp/data/registry/components/tooltip.json
@@ -4,19 +4,19 @@
"files": [
{
"path": "tooltip.rb",
- "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Tooltip < Base\n def initialize(placement: \"top\", **attrs)\n @placement = placement\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n controller: \"ruby-ui--tooltip\",\n ruby_ui__tooltip_placement_value: @placement\n },\n class: \"group\"\n }\n end\n end\nend\n"
+ "content": "# frozen_string_literal: true\n\nmodule RubyUI\n # Root of the tooltip: scopes the trigger and the content for the Stimulus\n # controller. open: renders it open on load (Base UI's defaultOpen) and\n # disabled: keeps it from opening (Base UI's Root disabled). Setting the\n # data-ruby-ui--tooltip-open-value or -disabled-value attribute later\n # opens, closes or disables it; each open or close dispatches\n # ruby-ui--tooltip:change with detail {open:}.\n #\n # placement: is the older API: TooltipContent's side: wins over it.\n class Tooltip < Base\n def initialize(placement: nil, open: false, disabled: false, **attrs)\n @placement = placement\n @open = open\n @disabled = disabled\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n slot: \"tooltip\",\n controller: \"ruby-ui--tooltip\",\n ruby_ui__tooltip_placement_value: @placement&.to_s,\n ruby_ui__tooltip_open_value: (!!@open).to_s,\n ruby_ui__tooltip_disabled_value: (!!@disabled).to_s\n },\n class: \"group\"\n }\n end\n end\nend\n"
},
{
"path": "tooltip_content.rb",
- "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TooltipContent < Base\n def initialize(**attrs)\n @id = \"tooltip#{SecureRandom.hex(4)}\"\n super\n end\n\n def view_template(&)\n template(data: {ruby_ui__tooltip_target: \"content\"}) do\n div(**attrs, &)\n end\n end\n\n private\n\n def default_attrs\n {\n id: @id,\n class: [\n \"invisible pointer-events-none w-fit max-w-[calc(100vw-2rem)] text-balance break-words absolute top-0 left-0 z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md\",\n \"data-[placement=bottom]:slide-in-from-top-2\",\n \"data-[placement=left]:slide-in-from-right-2\",\n \"data-[placement=right]:slide-in-from-left-2\",\n \"data-[placement=top]:slide-in-from-bottom-2\",\n \"data-[state=open]:visible data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95\",\n \"data-[state=closed]:visible data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:fill-mode-forwards\"\n ]\n }\n end\n end\nend\n"
+ "content": "# frozen_string_literal: true\n\nmodule RubyUI\n # The popup, kept in a until the controller connects. The\n # controller places the copy right after the trigger, so it stays inside\n # the same container (a Sidebar hides its own tooltips from its state), and\n # opens it as a native popover=\"manual\" in the top layer, where no ancestor\n # with overflow: hidden clips it. role=\"tooltip\"; the trigger's focusable\n # element points at it with aria-describedby.\n #\n # side:/align: pick the placement (side also takes the logical\n # :inline_start / :inline_end), side_offset:/align_offset: the gap, with the\n # defaults of shadcn's TooltipContent. Without side:, Tooltip's placement:\n # applies, then top. It flips to the other side when there is no room.\n class TooltipContent < Base\n SIDES = %i[top right bottom left inline_start inline_end].freeze\n ALIGNS = %i[start center end].freeze\n\n def initialize(side: nil, side_offset: 4, align: nil, align_offset: 0, **attrs)\n @id = \"tooltip-#{SecureRandom.hex(4)}\"\n @side = SIDES.include?(side&.to_sym) ? side.to_s.tr(\"_\", \"-\") : nil\n @align = ALIGNS.include?(align&.to_sym) ? align.to_s : nil\n @side_offset = side_offset\n @align_offset = align_offset\n super(**attrs)\n end\n\n def view_template(&block)\n template(data: {ruby_ui__tooltip_target: \"content\"}) do\n div(**attrs) do\n yield if block\n span(**arrow_attrs)\n end\n end\n end\n\n private\n\n def arrow_attrs\n {\n aria: {hidden: \"true\"},\n data: {slot: \"tooltip-arrow\"},\n class: [\n \"absolute z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground\",\n \"data-[side=bottom]:top-1 data-[side=top]:-bottom-2.5\",\n \"data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2\",\n # The controller writes the physical side, so logical sides point right in RTL too.\n \"data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2\"\n ]\n }\n end\n\n def default_attrs\n {\n id: @id,\n popover: \"manual\",\n role: \"tooltip\",\n data: {\n slot: \"tooltip-content\",\n # Kept in sync with data-open/data-closed for CSS that targets data-[state=...].\n state: \"closed\",\n closed: \"\",\n preferred_side: @side,\n preferred_align: @align,\n side_offset: @side_offset,\n align_offset: @align_offset\n },\n class: [\n # Resets the UA popover box (overflow too, or it clips the arrow);\n # inline-flex must not beat the UA's hidden closed popover.\n \"fixed inset-auto m-0 border-0 overflow-visible [&:not(:popover-open)]:hidden\",\n \"z-50 w-fit max-w-xs origin-(--transform-origin) bg-foreground text-background\",\n \"inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs\",\n \"has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm\",\n \"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95\",\n \"data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-closed:fill-mode-forwards\",\n \"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2\",\n \"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2\",\n \"data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2\"\n ]\n }\n end\n end\nend\n"
},
{
"path": "tooltip_controller.js",
- "content": "import { Controller } from \"@hotwired/stimulus\";\nimport { computePosition, autoUpdate, offset, shift } from \"@floating-ui/dom\";\n\nexport default class extends Controller {\n static targets = [\"trigger\", \"content\"];\n\n static values = { placement: \"top\" };\n\n mount() {\n if (this.mounted) return;\n\n const element = this.cloneTemplate();\n element.setAttribute(\"data-placement\", this.placementValue);\n document.body.appendChild(element);\n\n this.triggerTarget.setAttribute(\"aria-describedby\", element.id);\n // animationcancel covers an exit run cut short (backgrounded tab, interrupting\n // style change) — without it the cloned node is never removed from .\n element.addEventListener(\"animationend\", this.handleExitAnimationEnd);\n element.addEventListener(\"animationcancel\", this.handleExitAnimationEnd);\n\n const onBeforeCache = () => this.unmount();\n document.addEventListener(\"turbo:before-cache\", onBeforeCache);\n\n this.mounted = { element, onBeforeCache };\n this.mounted.stopAutoUpdate = autoUpdate(this.triggerTarget, element, () => this.reposition());\n }\n\n unmount() {\n if (!this.mounted) return;\n\n document.removeEventListener(\"turbo:before-cache\", this.mounted.onBeforeCache);\n\n this.mounted.stopAutoUpdate?.();\n this.mounted.element.remove();\n this.triggerTarget.removeAttribute(\"aria-describedby\");\n\n this.mounted = null;\n }\n\n disconnect() {\n this.unmount();\n }\n\n show() {\n if (!this.hasContentTarget) return;\n\n this.mount();\n this.mounted.element.setAttribute(\"data-state\", \"open\");\n }\n\n hide() {\n this.mounted?.element.setAttribute(\"data-state\", \"closed\");\n }\n\n handleExitAnimationEnd = (event) => {\n if (event.animationName !== \"exit\") return;\n if (this.mounted?.element.getAttribute(\"data-state\") !== \"closed\") return;\n\n this.unmount();\n };\n\n cloneTemplate() {\n return this.contentTarget.content.firstElementChild.cloneNode(true);\n }\n\n reposition() {\n if (!this.mounted) return;\n\n const position = { placement: this.placementValue, middleware: [offset(4), shift()] };\n\n computePosition(this.triggerTarget, this.mounted.element, position).then(({ x, y }) => {\n this.mounted?.element.style.setProperty(\"left\", `${x}px`);\n this.mounted?.element.style.setProperty(\"top\", `${y}px`);\n });\n }\n}\n"
+ "content": "import { Controller } from \"@hotwired/stimulus\";\nimport { computePosition, autoUpdate, offset, flip, shift, arrow } from \"@floating-ui/dom\";\n\n// Connects to data-controller=\"ruby-ui--tooltip\"\n//\n// Behaves like Base UI's Tooltip: it opens while the pointer rests on the\n// trigger (after delay) or when the trigger gets keyboard focus, and closes\n// when the pointer leaves both the trigger and the content, on blur, on\n// Escape, or when the trigger is pressed (close_on_click). The pointer can\n// move onto the content without closing it (WCAG 1.4.13).\n//\n// TooltipContent ships in a . On connect the controller places a\n// copy right after the trigger, so it stays inside the same container (a\n// Sidebar hides its tooltips from its own data-state), and opens it as a\n// native popover=\"manual\" in the top layer; Floating UI places it next to\n// the trigger. The content has role=\"tooltip\" and the trigger's focusable\n// element points at it with aria-describedby, as the APG tooltip pattern\n// asks (Base UI leaves both out).\nconst SIDES = [\"top\", \"right\", \"bottom\", \"left\", \"inline-start\", \"inline-end\"];\nconst ALIGNS = [\"start\", \"center\", \"end\"];\nconst FOCUSABLE = 'button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])';\n// The pointer needs a moment to cross the gap between trigger and content.\nconst HOVER_GRACE = 100;\n\nexport default class extends Controller {\n static targets = [\"trigger\", \"content\"];\n static values = {\n placement: { type: String, default: \"top\" },\n open: { type: Boolean, default: false },\n disabled: { type: Boolean, default: false },\n };\n\n // The copy of the content template that lives in the page.\n popup = null;\n isOpen = false;\n\n connect() {\n document.addEventListener(\"turbo:before-cache\", this.handleBeforeCache);\n this.mount();\n if (this.openValue && !this.disabledValue) this.open();\n this.ready = true;\n }\n\n disconnect() {\n this.ready = false;\n document.removeEventListener(\"turbo:before-cache\", this.handleBeforeCache);\n this.unmount();\n // Stimulus reuses this instance if the element comes back.\n this.pointerInside = false;\n this.pressedClosed = false;\n }\n\n // Target callbacks run before connect() and again whenever Turbo or other\n // code swaps the trigger or the content inside a connected tooltip.\n triggerTargetConnected(trigger) {\n trigger.addEventListener(\"mouseenter\", this.handleEnter);\n trigger.addEventListener(\"mouseleave\", this.handleLeave);\n trigger.addEventListener(\"focusin\", this.handleFocusIn);\n trigger.addEventListener(\"focusout\", this.handleFocusOut);\n trigger.addEventListener(\"click\", this.handleClick);\n if (this.ready) this.remount();\n }\n\n triggerTargetDisconnected(trigger) {\n trigger.removeEventListener(\"mouseenter\", this.handleEnter);\n trigger.removeEventListener(\"mouseleave\", this.handleLeave);\n trigger.removeEventListener(\"focusin\", this.handleFocusIn);\n trigger.removeEventListener(\"focusout\", this.handleFocusOut);\n trigger.removeEventListener(\"click\", this.handleClick);\n // The copy sits next to (and is placed against) this trigger.\n if (this.popup && this.mountedTrigger === trigger) this.unmount();\n }\n\n contentTargetConnected() {\n if (this.ready) this.remount();\n }\n\n contentTargetDisconnected(template) {\n if (this.popup && this.mountedTemplate === template) this.unmount();\n }\n\n openValueChanged(open) {\n if (!this.ready) return;\n open ? this.open() : this.close();\n }\n\n disabledValueChanged(disabled) {\n if (!this.ready) return;\n // An open value set while disabled takes effect once enabled again.\n if (disabled) {\n this.close();\n } else if (this.openValue) {\n this.open();\n }\n }\n\n // Kept for markup that still wires data-action to show/hide.\n show() {\n this.open();\n }\n\n hide() {\n this.close();\n }\n\n // --- Mounting -----------------------------------------------------------------\n\n mount() {\n if (this.popup || !this.hasContentTarget || !this.hasTriggerTarget) return;\n const template = this.contentTarget;\n const trigger = this.triggerTarget;\n const popup = template.content?.firstElementChild?.cloneNode(true);\n if (!popup) return;\n\n popup.addEventListener(\"mouseenter\", this.handleEnter);\n popup.addEventListener(\"mouseleave\", this.handleLeave);\n trigger.after(popup);\n this.popup = popup;\n this.mountedTrigger = trigger;\n this.mountedTemplate = template;\n this.describe(trigger, popup);\n }\n\n unmount() {\n const popup = this.popup;\n if (!popup) return;\n this.close({ animate: false });\n popup.removeEventListener(\"mouseenter\", this.handleEnter);\n popup.removeEventListener(\"mouseleave\", this.handleLeave);\n this.undescribe();\n popup.remove();\n this.popup = null;\n this.mountedTrigger = null;\n this.mountedTemplate = null;\n }\n\n // A swapped trigger or content while open stays open, without telling\n // listeners about a close and reopen that never happened.\n remount() {\n const wasOpen = this.isOpen;\n this.remounting = wasOpen;\n try {\n this.unmount();\n this.mount();\n if (wasOpen) this.open();\n } finally {\n this.remounting = false;\n }\n }\n\n // --- aria-describedby ---------------------------------------------------------\n\n // The element inside the trigger that receives focus (the Button), else the wrapper.\n controlFor(trigger) {\n return trigger.matches(FOCUSABLE) ? trigger : trigger.querySelector(FOCUSABLE) || trigger;\n }\n\n // Adds the content's id to the control's aria-describedby, keeping a\n // caller's own ids. Skipped when the tooltip only repeats the control's\n // text (SidebarMenuButton's label): the description would say it twice.\n describe(trigger, popup) {\n const control = this.controlFor(trigger);\n const text = (element) => element.textContent.replace(/\\s+/g, \" \").trim().toLowerCase();\n if (text(popup) !== \"\" && text(popup) === text(control)) return;\n\n const ids = (control.getAttribute(\"aria-describedby\") || \"\").split(/\\s+/).filter(Boolean);\n if (!ids.includes(popup.id)) control.setAttribute(\"aria-describedby\", [...ids, popup.id].join(\" \"));\n this.describedControl = control;\n }\n\n undescribe() {\n const control = this.describedControl;\n this.describedControl = null;\n if (!control || !this.popup) return;\n const ids = (control.getAttribute(\"aria-describedby\") || \"\").split(/\\s+/).filter((id) => id && id !== this.popup.id);\n ids.length ? control.setAttribute(\"aria-describedby\", ids.join(\" \")) : control.removeAttribute(\"aria-describedby\");\n }\n\n // --- Pointer, focus, click, Escape --------------------------------------------\n\n triggerSetting(key, fallback) {\n const own = this.mountedTrigger?.dataset[key];\n return own !== undefined && own !== \"\" ? own : fallback;\n }\n\n get openDelay() {\n return Number(this.triggerSetting(\"delay\", 0)) || 0;\n }\n\n get closeDelay() {\n return Number(this.triggerSetting(\"closeDelay\", 0)) || 0;\n }\n\n get closeOnClick() {\n return this.triggerSetting(\"closeOnClick\", \"true\") !== \"false\";\n }\n\n handleEnter = (event) => {\n this.pointerInside = true;\n this.clearTimers();\n // After close_on_click, the pointer has to leave the trigger before it opens again.\n if (this.isOpen || this.pressedClosed || this.disabledValue) return;\n if (event.currentTarget === this.popup) return;\n const delay = this.openDelay;\n if (delay > 0) {\n this.openTimeout = setTimeout(() => this.open(), delay);\n } else {\n this.open();\n }\n };\n\n handleLeave = (event) => {\n this.pointerInside = false;\n if (event.currentTarget === this.mountedTrigger) this.pressedClosed = false;\n this.clearTimers();\n if (!this.isOpen || this.keyboardFocusInside()) return;\n this.closeTimeout = setTimeout(() => this.close(), this.closeDelay + HOVER_GRACE);\n };\n\n // Opens on keyboard focus only, as Base UI does: a click focuses the button too.\n handleFocusIn = (event) => {\n if (this.disabledValue || this.isOpen || !this.isFocusVisible(event.target)) return;\n this.clearTimers();\n this.open();\n };\n\n handleFocusOut = (event) => {\n if (this.mountedTrigger?.contains(event.relatedTarget)) return;\n if (this.pointerInside) return;\n this.close();\n };\n\n // Enter and Space on the trigger's button arrive here as a click too. A\n // press during the open delay cancels the pending open as well.\n handleClick = () => {\n if (!this.closeOnClick) return;\n this.clearTimers();\n this.pressedClosed = this.pointerInside;\n this.close();\n };\n\n handleKeydown = (event) => {\n if (event.key !== \"Escape\" || !this.isOpen) return;\n // A tooltip hidden by CSS (an expanded or mobile sidebar) closes without\n // taking the key, so an enclosing Sheet or dialog still gets it.\n // A visible one keeps an enclosing