diff --git a/docs/app/components/component_setup/manual_steps.rb b/docs/app/components/component_setup/manual_steps.rb index 0019100de..80835718f 100644 --- a/docs/app/components/component_setup/manual_steps.rb +++ b/docs/app/components/component_setup/manual_steps.rb @@ -174,7 +174,7 @@ def component_dependencies_steps(steps) end # Temporary solution while we don't remove - # motion adn tippy.js dependencies + # the motion dependency def pin_importmap_instructions(js_package) case js_package when "motion" @@ -182,12 +182,6 @@ def pin_importmap_instructions(js_package) // Add to your config/importmap.rb pin "motion", to: "https://cdn.jsdelivr.net/npm/motion@10.18.0/+esm" CODE - when "tippy.js" - <<~CODE - // Add to your config/importmap.rb - pin "tippy.js", to: "https://cdn.jsdelivr.net/npm/tippy.js@6.3.7/+esm" - pin "@popperjs/core", to: "https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/+esm"\n - CODE else "bin/importmap pin #{js_package}" end diff --git a/docs/app/javascript/controllers/ruby_ui/context_menu_controller.js b/docs/app/javascript/controllers/ruby_ui/context_menu_controller.js index 235105709..ebc1f20a4 100644 --- a/docs/app/javascript/controllers/ruby_ui/context_menu_controller.js +++ b/docs/app/javascript/controllers/ruby_ui/context_menu_controller.js @@ -1,106 +1,125 @@ import { Controller } from "@hotwired/stimulus"; -import tippy from "tippy.js"; +import { + computePosition, + flip, + shift, + offset, + autoUpdate, +} from "@floating-ui/dom"; export default class extends Controller { static targets = ["trigger", "content", "menuItem"]; static values = { - options: { - type: Object, - default: {}, - }, - // make content width of the trigger element (true/false) - matchWidth: { - type: Boolean, - default: false, - } - } + open: { type: Boolean, default: false }, + options: { type: Object, default: {} }, + // make content width match the trigger element (true/false) + matchWidth: { type: Boolean, default: false }, + }; connect() { - this.boundHandleKeydown = this.handleKeydown.bind(this); // Bind the function so we can remove it later - this.initializeTippy(); + this.cleanup = null; this.selectedIndex = -1; + this.boundHandleKeydown = this.handleKeydown.bind(this); } disconnect() { - this.destroyTippy(); + this.hide(); } - initializeTippy() { - const defaultOptions = { - content: this.contentTarget.innerHTML, - allowHTML: true, - interactive: true, - onShow: (instance) => { - this.matchWidthValue && this.setContentWidth(instance); // ensure content width matches trigger width - this.addEventListeners(); - }, - onHide: () => { - this.removeEventListeners(); - this.deselectAll(); - }, - popperOptions: { - modifiers: [ - { - name: "offset", - options: { - offset: [0, 4] - }, - }, - ], - } - }; - - const mergedOptions = { ...this.optionsValue, ...defaultOptions }; - this.tippy = tippy(this.triggerTarget, mergedOptions); + handleContextMenu(event) { + event.preventDefault(); + this.open(); } - destroyTippy() { - if (this.tippy) { - this.tippy.destroy(); + open() { + this.openValue = true; + this.contentTarget.classList.remove("hidden"); + this.contentTarget.dataset.state = "open"; + if (this.matchWidthValue) { + this.contentTarget.style.width = `${this.triggerTarget.offsetWidth}px`; } + this.addEventListeners(); + this.updatePosition(); } - setContentWidth(instance) { - // box-sizing: border-box - const content = instance.popper.querySelector('.tippy-content'); - if (content) { - content.style.width = `${instance.reference.offsetWidth}px`; + close() { + this.hide(); + } + + hide() { + if (!this.openValue) return; + this.openValue = false; + this.contentTarget.classList.add("hidden"); + this.contentTarget.dataset.state = "closed"; + this.removeEventListeners(); + this.deselectAll(); + if (this.cleanup) { + this.cleanup(); + this.cleanup = null; } } - handleContextMenu(event) { - event.preventDefault(); - this.open(); + updatePosition() { + if (this.cleanup) this.cleanup(); + + this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => { + computePosition(this.triggerTarget, this.contentTarget, { + placement: this.optionsValue.placement || "bottom-start", + middleware: [offset(4), flip(), shift({ padding: 8 })], + }).then(({ x, y, placement }) => { + Object.assign(this.contentTarget.style, { + left: `${x}px`, + top: `${y}px`, + }); + this.contentTarget.dataset.side = placement.split("-")[0]; + }); + }); } - open() { - this.tippy.show(); + addEventListeners() { + document.addEventListener("keydown", this.boundHandleKeydown); + document.addEventListener("click", this.handleOutsidePointer); + // A right-click outside should dismiss this menu and let the native + // context menu (or another trigger's menu) take over. + document.addEventListener("contextmenu", this.handleOutsidePointer); } - close() { - this.tippy.hide(); + removeEventListeners() { + document.removeEventListener("keydown", this.boundHandleKeydown); + document.removeEventListener("click", this.handleOutsidePointer); + document.removeEventListener("contextmenu", this.handleOutsidePointer); } + handleOutsidePointer = (event) => { + if (!this.element.contains(event.target)) { + this.hide(); + } + }; + handleKeydown(e) { - // return if no menu items (one line fix for) - if (this.menuItemTargets.length === 0) { return; } + if (e.key === "Escape") { + e.preventDefault(); + this.hide(); + return; + } - if (e.key === 'ArrowDown') { + if (this.menuItemTargets.length === 0) return; + + if (e.key === "ArrowDown") { e.preventDefault(); this.updateSelectedItem(1); - } else if (e.key === 'ArrowUp') { + } else if (e.key === "ArrowUp") { e.preventDefault(); this.updateSelectedItem(-1); - } else if (e.key === 'Enter' && this.selectedIndex !== -1) { + } else if (e.key === "Enter" && this.selectedIndex !== -1) { e.preventDefault(); this.menuItemTargets[this.selectedIndex].click(); } } updateSelectedItem(direction) { - // Check if any of the menuItemTargets have aria-selected="true" and set the selectedIndex to that index this.menuItemTargets.forEach((item, index) => { - if (item.getAttribute('aria-selected') === 'true') { + if (item.getAttribute("aria-selected") === "true") { this.selectedIndex = index; } }); @@ -121,24 +140,17 @@ export default class extends Controller { } toggleAriaSelected(element, isSelected) { - // Add or remove attribute if (isSelected) { - element.setAttribute('aria-selected', 'true'); + element.setAttribute("aria-selected", "true"); } else { - element.removeAttribute('aria-selected'); + element.removeAttribute("aria-selected"); } } deselectAll() { - this.menuItemTargets.forEach(item => this.toggleAriaSelected(item, false)); + this.menuItemTargets.forEach((item) => + this.toggleAriaSelected(item, false) + ); this.selectedIndex = -1; } - - addEventListeners() { - document.addEventListener('keydown', this.boundHandleKeydown); - } - - removeEventListeners() { - document.removeEventListener('keydown', this.boundHandleKeydown); - } } diff --git a/docs/app/javascript/controllers/ruby_ui/hover_card_controller.js b/docs/app/javascript/controllers/ruby_ui/hover_card_controller.js index 235105709..955117f08 100644 --- a/docs/app/javascript/controllers/ruby_ui/hover_card_controller.js +++ b/docs/app/javascript/controllers/ruby_ui/hover_card_controller.js @@ -1,106 +1,150 @@ import { Controller } from "@hotwired/stimulus"; -import tippy from "tippy.js"; +import { + computePosition, + flip, + shift, + offset, + autoUpdate, +} from "@floating-ui/dom"; export default class extends Controller { static targets = ["trigger", "content", "menuItem"]; static values = { - options: { - type: Object, - default: {}, - }, - // make content width of the trigger element (true/false) - matchWidth: { - type: Boolean, - default: false, - } - } + open: { type: Boolean, default: false }, + options: { type: Object, default: {} }, + // make content width match the trigger element (true/false) + matchWidth: { type: Boolean, default: false }, + }; connect() { - this.boundHandleKeydown = this.handleKeydown.bind(this); // Bind the function so we can remove it later - this.initializeTippy(); + this.openDelay = this.delayAt(0, 500); + this.closeDelay = this.delayAt(1, 250); + this.openTimeout = null; + this.closeTimeout = null; + this.cleanup = null; this.selectedIndex = -1; + this.boundHandleKeydown = this.handleKeydown.bind(this); + this.addEventListeners(); } disconnect() { - this.destroyTippy(); + this.removeEventListeners(); + this.clearTimers(); + document.removeEventListener("keydown", this.boundHandleKeydown); + if (this.cleanup) { + this.cleanup(); + this.cleanup = null; + } } - initializeTippy() { - const defaultOptions = { - content: this.contentTarget.innerHTML, - allowHTML: true, - interactive: true, - onShow: (instance) => { - this.matchWidthValue && this.setContentWidth(instance); // ensure content width matches trigger width - this.addEventListeners(); - }, - onHide: () => { - this.removeEventListeners(); - this.deselectAll(); - }, - popperOptions: { - modifiers: [ - { - name: "offset", - options: { - offset: [0, 4] - }, - }, - ], - } - }; + // Supports the tippy-style `delay` option: a number or a [open, close] tuple. + delayAt(index, fallback) { + const delay = this.optionsValue.delay; + if (Array.isArray(delay)) return delay[index] ?? fallback; + if (typeof delay === "number") return delay; + return fallback; + } - const mergedOptions = { ...this.optionsValue, ...defaultOptions }; - this.tippy = tippy(this.triggerTarget, mergedOptions); + addEventListeners() { + this.triggerTarget.addEventListener("mouseenter", this.handleMouseEnter); + this.triggerTarget.addEventListener("mouseleave", this.handleMouseLeave); + this.triggerTarget.addEventListener("focusin", this.handleMouseEnter); + this.triggerTarget.addEventListener("focusout", this.handleMouseLeave); + this.contentTarget.addEventListener("mouseenter", this.handleMouseEnter); + this.contentTarget.addEventListener("mouseleave", this.handleMouseLeave); + this.contentTarget.addEventListener("focusin", this.handleMouseEnter); + this.contentTarget.addEventListener("focusout", this.handleMouseLeave); } - destroyTippy() { - if (this.tippy) { - this.tippy.destroy(); - } + removeEventListeners() { + this.triggerTarget.removeEventListener("mouseenter", this.handleMouseEnter); + this.triggerTarget.removeEventListener("mouseleave", this.handleMouseLeave); + this.triggerTarget.removeEventListener("focusin", this.handleMouseEnter); + this.triggerTarget.removeEventListener("focusout", this.handleMouseLeave); + this.contentTarget.removeEventListener("mouseenter", this.handleMouseEnter); + this.contentTarget.removeEventListener("mouseleave", this.handleMouseLeave); + this.contentTarget.removeEventListener("focusin", this.handleMouseEnter); + this.contentTarget.removeEventListener("focusout", this.handleMouseLeave); } - setContentWidth(instance) { - // box-sizing: border-box - const content = instance.popper.querySelector('.tippy-content'); - if (content) { - content.style.width = `${instance.reference.offsetWidth}px`; - } + handleMouseEnter = () => { + this.clearTimers(); + this.openTimeout = setTimeout(() => this.show(), this.openDelay); + }; + + handleMouseLeave = () => { + this.clearTimers(); + this.closeTimeout = setTimeout(() => this.hide(), this.closeDelay); + }; + + clearTimers() { + clearTimeout(this.openTimeout); + clearTimeout(this.closeTimeout); } - handleContextMenu(event) { - event.preventDefault(); - this.open(); + show() { + this.openValue = true; + this.contentTarget.classList.remove("hidden"); + this.contentTarget.dataset.state = "open"; + if (this.matchWidthValue) { + this.contentTarget.style.width = `${this.triggerTarget.offsetWidth}px`; + } + document.addEventListener("keydown", this.boundHandleKeydown); + this.updatePosition(); } - open() { - this.tippy.show(); + hide() { + this.openValue = false; + this.contentTarget.classList.add("hidden"); + this.contentTarget.dataset.state = "closed"; + document.removeEventListener("keydown", this.boundHandleKeydown); + this.deselectAll(); + if (this.cleanup) { + this.cleanup(); + this.cleanup = null; + } } - close() { - this.tippy.hide(); + updatePosition() { + if (this.cleanup) this.cleanup(); + + this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => { + computePosition(this.triggerTarget, this.contentTarget, { + placement: this.optionsValue.placement || "bottom", + middleware: [offset(4), flip(), shift({ padding: 8 })], + }).then(({ x, y, placement }) => { + Object.assign(this.contentTarget.style, { + left: `${x}px`, + top: `${y}px`, + }); + this.contentTarget.dataset.side = placement.split("-")[0]; + }); + }); } handleKeydown(e) { - // return if no menu items (one line fix for) - if (this.menuItemTargets.length === 0) { return; } + if (e.key === "Escape") { + this.hide(); + return; + } + + if (this.menuItemTargets.length === 0) return; - if (e.key === 'ArrowDown') { + if (e.key === "ArrowDown") { e.preventDefault(); this.updateSelectedItem(1); - } else if (e.key === 'ArrowUp') { + } else if (e.key === "ArrowUp") { e.preventDefault(); this.updateSelectedItem(-1); - } else if (e.key === 'Enter' && this.selectedIndex !== -1) { + } else if (e.key === "Enter" && this.selectedIndex !== -1) { e.preventDefault(); this.menuItemTargets[this.selectedIndex].click(); } } updateSelectedItem(direction) { - // Check if any of the menuItemTargets have aria-selected="true" and set the selectedIndex to that index this.menuItemTargets.forEach((item, index) => { - if (item.getAttribute('aria-selected') === 'true') { + if (item.getAttribute("aria-selected") === "true") { this.selectedIndex = index; } }); @@ -121,24 +165,17 @@ export default class extends Controller { } toggleAriaSelected(element, isSelected) { - // Add or remove attribute if (isSelected) { - element.setAttribute('aria-selected', 'true'); + element.setAttribute("aria-selected", "true"); } else { - element.removeAttribute('aria-selected'); + element.removeAttribute("aria-selected"); } } deselectAll() { - this.menuItemTargets.forEach(item => this.toggleAriaSelected(item, false)); + this.menuItemTargets.forEach((item) => + this.toggleAriaSelected(item, false) + ); this.selectedIndex = -1; } - - addEventListeners() { - document.addEventListener('keydown', this.boundHandleKeydown); - } - - removeEventListeners() { - document.removeEventListener('keydown', this.boundHandleKeydown); - } } diff --git a/docs/package.json b/docs/package.json index 94f9cb969..572c8b565 100644 --- a/docs/package.json +++ b/docs/package.json @@ -18,7 +18,6 @@ "motion": "12.40.0", "mustache": "4.2.0", "tailwindcss": "4.3.1", - "tippy.js": "6.3.7", "tw-animate-css": "1.4.0" }, "scripts": { diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index aece364e1..401cadad5 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -56,9 +56,6 @@ importers: tailwindcss: specifier: 4.3.1 version: 4.3.1 - tippy.js: - specifier: 6.3.7 - version: 6.3.7 tw-animate-css: specifier: 1.4.0 version: 1.4.0 @@ -243,9 +240,6 @@ packages: '@kurkle/color@0.3.4': resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} - '@popperjs/core@2.11.8': - resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} - '@rails/actioncable@8.1.200': resolution: {integrity: sha512-on0DSb7AFUkq1ocxivDNQhhGW/RQpY91zvRVyyaEWP4gOOZWy33P/UyxjQk74IENWNrTqs8+zOGHwTjiiFruRw==} @@ -391,9 +385,6 @@ packages: tailwindcss@4.3.1: resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==} - tippy.js@6.3.7: - resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -511,8 +502,6 @@ snapshots: '@kurkle/color@0.3.4': {} - '@popperjs/core@2.11.8': {} - '@rails/actioncable@8.1.200': {} '@tailwindcss/forms@0.5.11(tailwindcss@4.3.1)': @@ -643,10 +632,6 @@ snapshots: tailwindcss@4.3.1: {} - tippy.js@6.3.7: - dependencies: - '@popperjs/core': 2.11.8 - tslib@2.8.1: {} tw-animate-css@1.4.0: {} diff --git a/gem/lib/generators/ruby_ui/dependencies.yml b/gem/lib/generators/ruby_ui/dependencies.yml index 710b34b2b..3d774b009 100644 --- a/gem/lib/generators/ruby_ui/dependencies.yml +++ b/gem/lib/generators/ruby_ui/dependencies.yml @@ -40,7 +40,7 @@ command: context_menu: js_packages: - - "tippy.js" + - "@floating-ui/dom" date_picker: components: @@ -64,7 +64,7 @@ dropdown_menu: hover_card: js_packages: - - "tippy.js" + - "@floating-ui/dom" masked_input: components: diff --git a/gem/lib/generators/ruby_ui/javascript_utils.rb b/gem/lib/generators/ruby_ui/javascript_utils.rb index 62a08b5d6..0e1e6b998 100644 --- a/gem/lib/generators/ruby_ui/javascript_utils.rb +++ b/gem/lib/generators/ruby_ui/javascript_utils.rb @@ -25,8 +25,6 @@ def pin_with_importmap(package) pin_motion when "tw-animate-css" pin_tw_animate_css - when "tippy.js" - pin_tippy_js else run "bin/importmap pin #{package}" end @@ -65,17 +63,6 @@ def pin_motion RUBY end - def pin_tippy_js - say <<~TEXT - WARNING: Installing tippy.js from CDN because `bin/importmap pin tippy.js` doesn't download the correct file. - TEXT - - inject_into_file rails_root.join("config/importmap.rb"), <<~RUBY - pin "tippy.js", to: "https://cdn.jsdelivr.net/npm/tippy.js@6.3.7/+esm" - pin "@popperjs/core", to: "https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/+esm"\n - RUBY - end - def rails_root = Rails.root end end diff --git a/gem/lib/ruby_ui/context_menu/context_menu_content.rb b/gem/lib/ruby_ui/context_menu/context_menu_content.rb index 4e5a948f6..596d0f387 100644 --- a/gem/lib/ruby_ui/context_menu/context_menu_content.rb +++ b/gem/lib/ruby_ui/context_menu/context_menu_content.rb @@ -2,10 +2,8 @@ module RubyUI class ContextMenuContent < Base - def view_template(&block) - template(data: {ruby_ui__context_menu_target: "content"}) do - div(**attrs, &block) - end + def view_template(&) + div(**attrs, &) end private @@ -14,9 +12,10 @@ def default_attrs { role: "menu", aria_orientation: "vertical", - data_state: "open", + data_state: "closed", + data: {ruby_ui__context_menu_target: "content"}, class: - "z-50 min-w-[8rem] outline-none pointer-events-auto overflow-hidden rounded-md border bg-background p-1 text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "hidden absolute z-50 min-w-[8rem] outline-none pointer-events-auto overflow-hidden rounded-md border bg-background p-1 text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", tabindex: "-1", data_orientation: "vertical" } diff --git a/gem/lib/ruby_ui/context_menu/context_menu_controller.js b/gem/lib/ruby_ui/context_menu/context_menu_controller.js index 235105709..ebc1f20a4 100644 --- a/gem/lib/ruby_ui/context_menu/context_menu_controller.js +++ b/gem/lib/ruby_ui/context_menu/context_menu_controller.js @@ -1,106 +1,125 @@ import { Controller } from "@hotwired/stimulus"; -import tippy from "tippy.js"; +import { + computePosition, + flip, + shift, + offset, + autoUpdate, +} from "@floating-ui/dom"; export default class extends Controller { static targets = ["trigger", "content", "menuItem"]; static values = { - options: { - type: Object, - default: {}, - }, - // make content width of the trigger element (true/false) - matchWidth: { - type: Boolean, - default: false, - } - } + open: { type: Boolean, default: false }, + options: { type: Object, default: {} }, + // make content width match the trigger element (true/false) + matchWidth: { type: Boolean, default: false }, + }; connect() { - this.boundHandleKeydown = this.handleKeydown.bind(this); // Bind the function so we can remove it later - this.initializeTippy(); + this.cleanup = null; this.selectedIndex = -1; + this.boundHandleKeydown = this.handleKeydown.bind(this); } disconnect() { - this.destroyTippy(); + this.hide(); } - initializeTippy() { - const defaultOptions = { - content: this.contentTarget.innerHTML, - allowHTML: true, - interactive: true, - onShow: (instance) => { - this.matchWidthValue && this.setContentWidth(instance); // ensure content width matches trigger width - this.addEventListeners(); - }, - onHide: () => { - this.removeEventListeners(); - this.deselectAll(); - }, - popperOptions: { - modifiers: [ - { - name: "offset", - options: { - offset: [0, 4] - }, - }, - ], - } - }; - - const mergedOptions = { ...this.optionsValue, ...defaultOptions }; - this.tippy = tippy(this.triggerTarget, mergedOptions); + handleContextMenu(event) { + event.preventDefault(); + this.open(); } - destroyTippy() { - if (this.tippy) { - this.tippy.destroy(); + open() { + this.openValue = true; + this.contentTarget.classList.remove("hidden"); + this.contentTarget.dataset.state = "open"; + if (this.matchWidthValue) { + this.contentTarget.style.width = `${this.triggerTarget.offsetWidth}px`; } + this.addEventListeners(); + this.updatePosition(); } - setContentWidth(instance) { - // box-sizing: border-box - const content = instance.popper.querySelector('.tippy-content'); - if (content) { - content.style.width = `${instance.reference.offsetWidth}px`; + close() { + this.hide(); + } + + hide() { + if (!this.openValue) return; + this.openValue = false; + this.contentTarget.classList.add("hidden"); + this.contentTarget.dataset.state = "closed"; + this.removeEventListeners(); + this.deselectAll(); + if (this.cleanup) { + this.cleanup(); + this.cleanup = null; } } - handleContextMenu(event) { - event.preventDefault(); - this.open(); + updatePosition() { + if (this.cleanup) this.cleanup(); + + this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => { + computePosition(this.triggerTarget, this.contentTarget, { + placement: this.optionsValue.placement || "bottom-start", + middleware: [offset(4), flip(), shift({ padding: 8 })], + }).then(({ x, y, placement }) => { + Object.assign(this.contentTarget.style, { + left: `${x}px`, + top: `${y}px`, + }); + this.contentTarget.dataset.side = placement.split("-")[0]; + }); + }); } - open() { - this.tippy.show(); + addEventListeners() { + document.addEventListener("keydown", this.boundHandleKeydown); + document.addEventListener("click", this.handleOutsidePointer); + // A right-click outside should dismiss this menu and let the native + // context menu (or another trigger's menu) take over. + document.addEventListener("contextmenu", this.handleOutsidePointer); } - close() { - this.tippy.hide(); + removeEventListeners() { + document.removeEventListener("keydown", this.boundHandleKeydown); + document.removeEventListener("click", this.handleOutsidePointer); + document.removeEventListener("contextmenu", this.handleOutsidePointer); } + handleOutsidePointer = (event) => { + if (!this.element.contains(event.target)) { + this.hide(); + } + }; + handleKeydown(e) { - // return if no menu items (one line fix for) - if (this.menuItemTargets.length === 0) { return; } + if (e.key === "Escape") { + e.preventDefault(); + this.hide(); + return; + } - if (e.key === 'ArrowDown') { + if (this.menuItemTargets.length === 0) return; + + if (e.key === "ArrowDown") { e.preventDefault(); this.updateSelectedItem(1); - } else if (e.key === 'ArrowUp') { + } else if (e.key === "ArrowUp") { e.preventDefault(); this.updateSelectedItem(-1); - } else if (e.key === 'Enter' && this.selectedIndex !== -1) { + } else if (e.key === "Enter" && this.selectedIndex !== -1) { e.preventDefault(); this.menuItemTargets[this.selectedIndex].click(); } } updateSelectedItem(direction) { - // Check if any of the menuItemTargets have aria-selected="true" and set the selectedIndex to that index this.menuItemTargets.forEach((item, index) => { - if (item.getAttribute('aria-selected') === 'true') { + if (item.getAttribute("aria-selected") === "true") { this.selectedIndex = index; } }); @@ -121,24 +140,17 @@ export default class extends Controller { } toggleAriaSelected(element, isSelected) { - // Add or remove attribute if (isSelected) { - element.setAttribute('aria-selected', 'true'); + element.setAttribute("aria-selected", "true"); } else { - element.removeAttribute('aria-selected'); + element.removeAttribute("aria-selected"); } } deselectAll() { - this.menuItemTargets.forEach(item => this.toggleAriaSelected(item, false)); + this.menuItemTargets.forEach((item) => + this.toggleAriaSelected(item, false) + ); this.selectedIndex = -1; } - - addEventListeners() { - document.addEventListener('keydown', this.boundHandleKeydown); - } - - removeEventListeners() { - document.removeEventListener('keydown', this.boundHandleKeydown); - } } diff --git a/gem/lib/ruby_ui/hover_card/hover_card_content.rb b/gem/lib/ruby_ui/hover_card/hover_card_content.rb index 5358d2b4c..1a5f2b3e0 100644 --- a/gem/lib/ruby_ui/hover_card/hover_card_content.rb +++ b/gem/lib/ruby_ui/hover_card/hover_card_content.rb @@ -2,10 +2,8 @@ module RubyUI class HoverCardContent < Base - def view_template(&block) - template(data: {ruby_ui__hover_card_target: "content"}) do - div(**attrs, &block) - end + def view_template(&) + div(**attrs, &) end private @@ -13,9 +11,10 @@ def view_template(&block) def default_attrs { data: { - state: :open + ruby_ui__hover_card_target: "content", + state: :closed }, - class: "z-50 rounded-md border bg-background p-4 text-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2" + class: "hidden absolute z-50 rounded-md border bg-background p-4 text-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2" } end end diff --git a/gem/lib/ruby_ui/hover_card/hover_card_controller.js b/gem/lib/ruby_ui/hover_card/hover_card_controller.js index 235105709..955117f08 100644 --- a/gem/lib/ruby_ui/hover_card/hover_card_controller.js +++ b/gem/lib/ruby_ui/hover_card/hover_card_controller.js @@ -1,106 +1,150 @@ import { Controller } from "@hotwired/stimulus"; -import tippy from "tippy.js"; +import { + computePosition, + flip, + shift, + offset, + autoUpdate, +} from "@floating-ui/dom"; export default class extends Controller { static targets = ["trigger", "content", "menuItem"]; static values = { - options: { - type: Object, - default: {}, - }, - // make content width of the trigger element (true/false) - matchWidth: { - type: Boolean, - default: false, - } - } + open: { type: Boolean, default: false }, + options: { type: Object, default: {} }, + // make content width match the trigger element (true/false) + matchWidth: { type: Boolean, default: false }, + }; connect() { - this.boundHandleKeydown = this.handleKeydown.bind(this); // Bind the function so we can remove it later - this.initializeTippy(); + this.openDelay = this.delayAt(0, 500); + this.closeDelay = this.delayAt(1, 250); + this.openTimeout = null; + this.closeTimeout = null; + this.cleanup = null; this.selectedIndex = -1; + this.boundHandleKeydown = this.handleKeydown.bind(this); + this.addEventListeners(); } disconnect() { - this.destroyTippy(); + this.removeEventListeners(); + this.clearTimers(); + document.removeEventListener("keydown", this.boundHandleKeydown); + if (this.cleanup) { + this.cleanup(); + this.cleanup = null; + } } - initializeTippy() { - const defaultOptions = { - content: this.contentTarget.innerHTML, - allowHTML: true, - interactive: true, - onShow: (instance) => { - this.matchWidthValue && this.setContentWidth(instance); // ensure content width matches trigger width - this.addEventListeners(); - }, - onHide: () => { - this.removeEventListeners(); - this.deselectAll(); - }, - popperOptions: { - modifiers: [ - { - name: "offset", - options: { - offset: [0, 4] - }, - }, - ], - } - }; + // Supports the tippy-style `delay` option: a number or a [open, close] tuple. + delayAt(index, fallback) { + const delay = this.optionsValue.delay; + if (Array.isArray(delay)) return delay[index] ?? fallback; + if (typeof delay === "number") return delay; + return fallback; + } - const mergedOptions = { ...this.optionsValue, ...defaultOptions }; - this.tippy = tippy(this.triggerTarget, mergedOptions); + addEventListeners() { + this.triggerTarget.addEventListener("mouseenter", this.handleMouseEnter); + this.triggerTarget.addEventListener("mouseleave", this.handleMouseLeave); + this.triggerTarget.addEventListener("focusin", this.handleMouseEnter); + this.triggerTarget.addEventListener("focusout", this.handleMouseLeave); + this.contentTarget.addEventListener("mouseenter", this.handleMouseEnter); + this.contentTarget.addEventListener("mouseleave", this.handleMouseLeave); + this.contentTarget.addEventListener("focusin", this.handleMouseEnter); + this.contentTarget.addEventListener("focusout", this.handleMouseLeave); } - destroyTippy() { - if (this.tippy) { - this.tippy.destroy(); - } + removeEventListeners() { + this.triggerTarget.removeEventListener("mouseenter", this.handleMouseEnter); + this.triggerTarget.removeEventListener("mouseleave", this.handleMouseLeave); + this.triggerTarget.removeEventListener("focusin", this.handleMouseEnter); + this.triggerTarget.removeEventListener("focusout", this.handleMouseLeave); + this.contentTarget.removeEventListener("mouseenter", this.handleMouseEnter); + this.contentTarget.removeEventListener("mouseleave", this.handleMouseLeave); + this.contentTarget.removeEventListener("focusin", this.handleMouseEnter); + this.contentTarget.removeEventListener("focusout", this.handleMouseLeave); } - setContentWidth(instance) { - // box-sizing: border-box - const content = instance.popper.querySelector('.tippy-content'); - if (content) { - content.style.width = `${instance.reference.offsetWidth}px`; - } + handleMouseEnter = () => { + this.clearTimers(); + this.openTimeout = setTimeout(() => this.show(), this.openDelay); + }; + + handleMouseLeave = () => { + this.clearTimers(); + this.closeTimeout = setTimeout(() => this.hide(), this.closeDelay); + }; + + clearTimers() { + clearTimeout(this.openTimeout); + clearTimeout(this.closeTimeout); } - handleContextMenu(event) { - event.preventDefault(); - this.open(); + show() { + this.openValue = true; + this.contentTarget.classList.remove("hidden"); + this.contentTarget.dataset.state = "open"; + if (this.matchWidthValue) { + this.contentTarget.style.width = `${this.triggerTarget.offsetWidth}px`; + } + document.addEventListener("keydown", this.boundHandleKeydown); + this.updatePosition(); } - open() { - this.tippy.show(); + hide() { + this.openValue = false; + this.contentTarget.classList.add("hidden"); + this.contentTarget.dataset.state = "closed"; + document.removeEventListener("keydown", this.boundHandleKeydown); + this.deselectAll(); + if (this.cleanup) { + this.cleanup(); + this.cleanup = null; + } } - close() { - this.tippy.hide(); + updatePosition() { + if (this.cleanup) this.cleanup(); + + this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => { + computePosition(this.triggerTarget, this.contentTarget, { + placement: this.optionsValue.placement || "bottom", + middleware: [offset(4), flip(), shift({ padding: 8 })], + }).then(({ x, y, placement }) => { + Object.assign(this.contentTarget.style, { + left: `${x}px`, + top: `${y}px`, + }); + this.contentTarget.dataset.side = placement.split("-")[0]; + }); + }); } handleKeydown(e) { - // return if no menu items (one line fix for) - if (this.menuItemTargets.length === 0) { return; } + if (e.key === "Escape") { + this.hide(); + return; + } + + if (this.menuItemTargets.length === 0) return; - if (e.key === 'ArrowDown') { + if (e.key === "ArrowDown") { e.preventDefault(); this.updateSelectedItem(1); - } else if (e.key === 'ArrowUp') { + } else if (e.key === "ArrowUp") { e.preventDefault(); this.updateSelectedItem(-1); - } else if (e.key === 'Enter' && this.selectedIndex !== -1) { + } else if (e.key === "Enter" && this.selectedIndex !== -1) { e.preventDefault(); this.menuItemTargets[this.selectedIndex].click(); } } updateSelectedItem(direction) { - // Check if any of the menuItemTargets have aria-selected="true" and set the selectedIndex to that index this.menuItemTargets.forEach((item, index) => { - if (item.getAttribute('aria-selected') === 'true') { + if (item.getAttribute("aria-selected") === "true") { this.selectedIndex = index; } }); @@ -121,24 +165,17 @@ export default class extends Controller { } toggleAriaSelected(element, isSelected) { - // Add or remove attribute if (isSelected) { - element.setAttribute('aria-selected', 'true'); + element.setAttribute("aria-selected", "true"); } else { - element.removeAttribute('aria-selected'); + element.removeAttribute("aria-selected"); } } deselectAll() { - this.menuItemTargets.forEach(item => this.toggleAriaSelected(item, false)); + this.menuItemTargets.forEach((item) => + this.toggleAriaSelected(item, false) + ); this.selectedIndex = -1; } - - addEventListeners() { - document.addEventListener('keydown', this.boundHandleKeydown); - } - - removeEventListeners() { - document.removeEventListener('keydown', this.boundHandleKeydown); - } } diff --git a/gem/package.json b/gem/package.json index 1421464fa..b3a99e9cd 100644 --- a/gem/package.json +++ b/gem/package.json @@ -31,8 +31,7 @@ "fuse.js": "^7.0.0", "maska": "^3.0.3", "motion": "^10.16.4", - "mustache": "^4.2.0", - "tippy.js": "^6.3.7" + "mustache": "^4.2.0" }, "devDependencies": { "globals": "^15.8.0" diff --git a/gem/test/ruby_ui/context_menu_test.rb b/gem/test/ruby_ui/context_menu_test.rb index 1dc50dabc..6cdefa3aa 100644 --- a/gem/test/ruby_ui/context_menu_test.rb +++ b/gem/test/ruby_ui/context_menu_test.rb @@ -26,4 +26,19 @@ def test_render_with_all_items assert_match(/Right click here/, output) end + + # Floating UI positions a real element in the DOM (tippy used to clone a + #