diff --git a/docs/app/javascript/controllers/ruby_ui/tooltip_controller.js b/docs/app/javascript/controllers/ruby_ui/tooltip_controller.js index 20ba35ce..504242a5 100644 --- a/docs/app/javascript/controllers/ruby_ui/tooltip_controller.js +++ b/docs/app/javascript/controllers/ruby_ui/tooltip_controller.js @@ -3,36 +3,72 @@ import { computePosition, autoUpdate, offset, shift } from "@floating-ui/dom"; export default class extends Controller { static targets = ["trigger", "content"]; - static values = { placement: String } - constructor(...args) { - super(...args); - this.cleanup; + static values = { placement: "top" }; + + mount() { + if (this.mounted) return; + + const element = this.cloneTemplate(); + element.setAttribute("data-placement", this.placementValue); + document.body.appendChild(element); + + this.triggerTarget.setAttribute("aria-describedby", element.id); + element.addEventListener("animationend", (event) => this.animationEnd(event)); + + const onBeforeCache = () => this.unmount(); + document.addEventListener("turbo:before-cache", onBeforeCache); + + this.mounted = { element, onBeforeCache }; + this.mounted.stopAutoUpdate = autoUpdate(this.triggerTarget, element, () => this.reposition()); } - connect() { - this.setFloatingElement(); + unmount() { + if (!this.mounted) return; - const tooltipId = this.contentTarget.getAttribute("id"); - this.triggerTarget.setAttribute("aria-describedby", tooltipId); + document.removeEventListener("turbo:before-cache", this.mounted.onBeforeCache); + this.mounted.stopAutoUpdate?.(); + this.mounted.element.remove(); + this.triggerTarget.removeAttribute("aria-describedby"); + + this.mounted = null; } disconnect() { - this.cleanup(); - } - - setFloatingElement() { - this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => { - computePosition(this.triggerTarget, this.contentTarget, { - placement: this.placementValue, - middleware: [offset(4), shift()] - }).then(({ x, y }) => { - Object.assign(this.contentTarget.style, { - left: `${x}px`, - top: `${y}px`, - }); - }); + this.unmount(); + } + + show() { + if (!this.hasContentTarget) return; + + this.mount(); + this.mounted.element.setAttribute("data-state", "open"); + } + + hide() { + this.mounted?.element.setAttribute("data-state", "closed"); + } + + animationEnd(event) { + if (event.animationName !== "exit") return; + if (this.mounted?.element.getAttribute("data-state") !== "closed") return; + + this.unmount(); + } + + cloneTemplate() { + return this.contentTarget.content.firstElementChild.cloneNode(true); + } + + reposition() { + if (!this.mounted) return; + + const position = { placement: this.placementValue, middleware: [offset(4), shift()] }; + + computePosition(this.triggerTarget, this.mounted.element, position).then(({ x, y }) => { + this.mounted?.element.style.setProperty("left", `${x}px`); + this.mounted?.element.style.setProperty("top", `${y}px`); }); } } diff --git a/gem/lib/ruby_ui/tooltip/tooltip_content.rb b/gem/lib/ruby_ui/tooltip/tooltip_content.rb index ec0e3139..e09a2317 100644 --- a/gem/lib/ruby_ui/tooltip/tooltip_content.rb +++ b/gem/lib/ruby_ui/tooltip/tooltip_content.rb @@ -8,7 +8,9 @@ def initialize(**attrs) end def view_template(&) - div(**attrs, &) + template(data: {ruby_ui__tooltip_target: "content"}) do + div(**attrs, &) + end end private @@ -16,10 +18,15 @@ def view_template(&) def default_attrs { id: @id, - data: { - ruby_ui__tooltip_target: "content" - }, - class: "invisible peer-hover:visible peer-focus:visible 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 peer-focus:zoom-in-95 animate-out fade-out-0 zoom-out-95 peer-hover:animate-in peer-focus:animate-in peer-hover:fade-in-0 peer-focus:fade-in-0 peer-hover:zoom-in-95 group-data-[ruby-ui--tooltip-placement-value=bottom]:slide-in-from-top-2 group-data-[ruby-ui--tooltip-placement-value=left]:slide-in-from-right-2 group-data-[ruby-ui--tooltip-placement-value=right]:slide-in-from-left-2 group-data-[ruby-ui--tooltip-placement-value=top]:slide-in-from-bottom-2 delay-500" + class: [ + "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", + "data-[placement=bottom]:slide-in-from-top-2", + "data-[placement=left]:slide-in-from-right-2", + "data-[placement=right]:slide-in-from-left-2", + "data-[placement=top]:slide-in-from-bottom-2", + "data-[state=open]:visible data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", + "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" + ] } end end diff --git a/gem/lib/ruby_ui/tooltip/tooltip_controller.js b/gem/lib/ruby_ui/tooltip/tooltip_controller.js index 20ba35ce..504242a5 100644 --- a/gem/lib/ruby_ui/tooltip/tooltip_controller.js +++ b/gem/lib/ruby_ui/tooltip/tooltip_controller.js @@ -3,36 +3,72 @@ import { computePosition, autoUpdate, offset, shift } from "@floating-ui/dom"; export default class extends Controller { static targets = ["trigger", "content"]; - static values = { placement: String } - constructor(...args) { - super(...args); - this.cleanup; + static values = { placement: "top" }; + + mount() { + if (this.mounted) return; + + const element = this.cloneTemplate(); + element.setAttribute("data-placement", this.placementValue); + document.body.appendChild(element); + + this.triggerTarget.setAttribute("aria-describedby", element.id); + element.addEventListener("animationend", (event) => this.animationEnd(event)); + + const onBeforeCache = () => this.unmount(); + document.addEventListener("turbo:before-cache", onBeforeCache); + + this.mounted = { element, onBeforeCache }; + this.mounted.stopAutoUpdate = autoUpdate(this.triggerTarget, element, () => this.reposition()); } - connect() { - this.setFloatingElement(); + unmount() { + if (!this.mounted) return; - const tooltipId = this.contentTarget.getAttribute("id"); - this.triggerTarget.setAttribute("aria-describedby", tooltipId); + document.removeEventListener("turbo:before-cache", this.mounted.onBeforeCache); + this.mounted.stopAutoUpdate?.(); + this.mounted.element.remove(); + this.triggerTarget.removeAttribute("aria-describedby"); + + this.mounted = null; } disconnect() { - this.cleanup(); - } - - setFloatingElement() { - this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => { - computePosition(this.triggerTarget, this.contentTarget, { - placement: this.placementValue, - middleware: [offset(4), shift()] - }).then(({ x, y }) => { - Object.assign(this.contentTarget.style, { - left: `${x}px`, - top: `${y}px`, - }); - }); + this.unmount(); + } + + show() { + if (!this.hasContentTarget) return; + + this.mount(); + this.mounted.element.setAttribute("data-state", "open"); + } + + hide() { + this.mounted?.element.setAttribute("data-state", "closed"); + } + + animationEnd(event) { + if (event.animationName !== "exit") return; + if (this.mounted?.element.getAttribute("data-state") !== "closed") return; + + this.unmount(); + } + + cloneTemplate() { + return this.contentTarget.content.firstElementChild.cloneNode(true); + } + + reposition() { + if (!this.mounted) return; + + const position = { placement: this.placementValue, middleware: [offset(4), shift()] }; + + computePosition(this.triggerTarget, this.mounted.element, position).then(({ x, y }) => { + this.mounted?.element.style.setProperty("left", `${x}px`); + this.mounted?.element.style.setProperty("top", `${y}px`); }); } } diff --git a/gem/lib/ruby_ui/tooltip/tooltip_trigger.rb b/gem/lib/ruby_ui/tooltip/tooltip_trigger.rb index a535e942..473ba027 100644 --- a/gem/lib/ruby_ui/tooltip/tooltip_trigger.rb +++ b/gem/lib/ruby_ui/tooltip/tooltip_trigger.rb @@ -10,9 +10,16 @@ def view_template(&) def default_attrs { - data: {ruby_ui__tooltip_target: "trigger"}, - variant: :outline, - class: "peer" + data: { + ruby_ui__tooltip_target: "trigger", + action: [ + "mouseenter->ruby-ui--tooltip#show", + "mouseleave->ruby-ui--tooltip#hide", + "focus->ruby-ui--tooltip#show", + "blur->ruby-ui--tooltip#hide" + ] + }, + variant: :outline } end end