diff --git a/gem/lib/ruby_ui/clipboard/clipboard_controller.js b/gem/lib/ruby_ui/clipboard/clipboard_controller.js index 00c6f5b3b..10088d7f3 100644 --- a/gem/lib/ruby_ui/clipboard/clipboard_controller.js +++ b/gem/lib/ruby_ui/clipboard/clipboard_controller.js @@ -3,7 +3,7 @@ import { computePosition, flip, shift } from "@floating-ui/dom"; // Connects to data-controller="accordion" export default class extends Controller { - static targets = ['trigger', 'source', 'successPopover', 'errorPopover'] + static targets = ['trigger', 'source', 'successPopover', 'successPanel', 'errorPopover', 'errorPanel'] static values = { options: { type: Object, @@ -11,10 +11,16 @@ export default class extends Controller { }, } + disconnect() { + // Nothing is left to wait for the exit animation, so apply the pending hide now. + if (this.hasSuccessPanelTarget) this.settleExit(this.successPanelTarget); + if (this.hasErrorPanelTarget) this.settleExit(this.errorPanelTarget); + } + copy() { let sourceElement = this.sourceTarget.children[0]; if (!sourceElement) { - this.showErrorPopover(); + this.#showErrorPopover(); return; } let textToCopy = sourceElement.tagName === 'INPUT' ? sourceElement.value : sourceElement.innerText; @@ -26,8 +32,8 @@ export default class extends Controller { } onClickOutside() { - if (!this.successPopoverTarget.classList.contains("hidden")) this.successPopoverTarget.classList.add("hidden"); - if (!this.errorPopoverTarget.classList.contains("hidden")) this.errorPopoverTarget.classList.add("hidden"); + this.#hidePopover(this.successPopoverTarget, this.successPanelTarget); + this.#hidePopover(this.errorPopoverTarget, this.errorPanelTarget); } #computeTooltip(popoverElement) { @@ -43,12 +49,65 @@ export default class extends Controller { } #showSuccessPopover() { - this.#computeTooltip(this.successPopoverTarget); - this.successPopoverTarget.classList.remove("hidden"); + this.#showPopover(this.successPopoverTarget, this.successPanelTarget); } #showErrorPopover() { - this.#computeTooltip(this.errorPopoverTarget); - this.errorPopoverTarget.classList.remove("hidden"); + this.#showPopover(this.errorPopoverTarget, this.errorPanelTarget); + } + + #showPopover(popover, panel) { + this.#computeTooltip(popover); + popover.classList.remove("hidden"); + panel.dataset.state = "open"; + } + + #hidePopover(popover, panel) { + if (popover.classList.contains("hidden")) return; + + panel.dataset.state = "closed"; + this.hideAfterExitAnimation(panel); + } + + afterExit(panel) { + const popover = panel === this.successPanelTarget ? this.successPopoverTarget : this.errorPopoverTarget; + popover.classList.add("hidden"); + } + + // Overlay exit — the same block in every overlay controller, so keep them in sync. + exitAnimationNames = new WeakMap(); + + hideAfterExitAnimation(animated) { + const exitAnimations = animated + .getAnimations() + .filter((animation) => animation instanceof CSSAnimation); + + // No exit animation, or no box to run it in: animationend would never fire. + if (exitAnimations.length === 0) { + this.settleExit(animated); + return; + } + + this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName)); + animated.addEventListener("animationend", this.handleExitAnimationEnd); + animated.addEventListener("animationcancel", this.handleExitAnimationEnd); + } + + handleExitAnimationEnd = (event) => { + // animationend bubbles — an animated child must not hide its container. + if (event.target !== event.currentTarget) return; + // Closing mid-open cancels the enter animation; only the exit run settles this. + if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return; + + this.settleExit(event.currentTarget); + }; + + settleExit(animated) { + animated.removeEventListener("animationend", this.handleExitAnimationEnd); + animated.removeEventListener("animationcancel", this.handleExitAnimationEnd); + // Reopened mid-exit: it is on its way back in, leave it visible. + if (animated.dataset.state !== "closed") return; + + this.afterExit(animated); } } diff --git a/gem/lib/ruby_ui/clipboard/clipboard_popover.rb b/gem/lib/ruby_ui/clipboard/clipboard_popover.rb index f1ae18f02..e8801934a 100644 --- a/gem/lib/ruby_ui/clipboard/clipboard_popover.rb +++ b/gem/lib/ruby_ui/clipboard/clipboard_popover.rb @@ -28,12 +28,22 @@ def clipboard_target end end + def panel_target + case @type + when :success + "successPanel" + when :error + "errorPanel" + end + end + def default_attrs { data: { - state: :open + state: :open, + ruby_ui__clipboard_target: panel_target }, - class: "z-50 rounded-md text-sm border bg-background px-2 py-0.5 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: "z-50 rounded-md text-sm border bg-background px-2 py-0.5 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-[state=closed]:fill-mode-forwards 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/command/command_controller.js b/gem/lib/ruby_ui/command/command_controller.js index 91b80495d..27b7333d9 100644 --- a/gem/lib/ruby_ui/command/command_controller.js +++ b/gem/lib/ruby_ui/command/command_controller.js @@ -3,7 +3,7 @@ import Fuse from "fuse.js"; // Connects to data-controller="ruby-ui--command" export default class extends Controller { - static targets = ["input", "group", "item", "empty"]; + static targets = ["input", "group", "item", "empty", "backdrop", "panel"]; connect() { this.selectedIndex = -1; @@ -17,13 +17,67 @@ export default class extends Controller { this.toggleVisibility(this.emptyTargets, false); } + disconnect() { + // Nothing is left to wait for the exit animation, so apply the pending removal now. + if (this.hasPanelTarget) this.settleExit(this.panelTarget); + } + dismiss() { - // allow scroll on body + this.backdropTarget.dataset.state = "closed"; + this.panelTarget.dataset.state = "closed"; + this.hideAfterExitAnimation(this.panelTarget); + } + + // Opened again while dismissing: bring this instance back instead of stacking a new one. + show() { + this.backdropTarget.dataset.state = "open"; + this.panelTarget.dataset.state = "open"; + document.body.classList.add("overflow-hidden"); + this.focusInput(); + } + + afterExit() { document.body.classList.remove("overflow-hidden"); - // remove the element this.element.remove(); } + // Overlay exit — the same block in every overlay controller, so keep them in sync. + exitAnimationNames = new WeakMap(); + + hideAfterExitAnimation(animated) { + const exitAnimations = animated + .getAnimations() + .filter((animation) => animation instanceof CSSAnimation); + + // No exit animation, or no box to run it in: animationend would never fire. + if (exitAnimations.length === 0) { + this.settleExit(animated); + return; + } + + this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName)); + animated.addEventListener("animationend", this.handleExitAnimationEnd); + animated.addEventListener("animationcancel", this.handleExitAnimationEnd); + } + + handleExitAnimationEnd = (event) => { + // animationend bubbles — an animated child must not hide its container. + if (event.target !== event.currentTarget) return; + // Closing mid-open cancels the enter animation; only the exit run settles this. + if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return; + + this.settleExit(event.currentTarget); + }; + + settleExit(animated) { + animated.removeEventListener("animationend", this.handleExitAnimationEnd); + animated.removeEventListener("animationcancel", this.handleExitAnimationEnd); + // Reopened mid-exit: it is on its way back in, leave it visible. + if (animated.dataset.state !== "closed") return; + + this.afterExit(animated); + } + focusInput() { this.inputTarget?.focus(); } diff --git a/gem/lib/ruby_ui/command/command_dialog_content.rb b/gem/lib/ruby_ui/command/command_dialog_content.rb index 0781b6e41..3e8e3a885 100644 --- a/gem/lib/ruby_ui/command/command_dialog_content.rb +++ b/gem/lib/ruby_ui/command/command_dialog_content.rb @@ -30,8 +30,9 @@ def view_template(&block) def default_attrs { data_state: "open", + data_ruby_ui__command_target: "panel", class: [ - "fixed pointer-events-auto left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full", + "fixed pointer-events-auto left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background shadow-lg duration-200 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-[state=closed]:fill-mode-forwards data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full", SIZES[@size] ] } @@ -41,7 +42,8 @@ def backdrop div( data_state: "open", data_action: "click->ruby-ui--command#dismiss esc->ruby-ui--command#dismiss", - class: "fixed pointer-events-auto inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" + data_ruby_ui__command_target: "backdrop", + class: "fixed pointer-events-auto inset-0 z-50 bg-background/80 backdrop-blur-sm 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]:fill-mode-forwards" ) end end diff --git a/gem/lib/ruby_ui/command/command_dialog_controller.js b/gem/lib/ruby_ui/command/command_dialog_controller.js index 7763a79e1..d8d643a2b 100644 --- a/gem/lib/ruby_ui/command/command_dialog_controller.js +++ b/gem/lib/ruby_ui/command/command_dialog_controller.js @@ -23,7 +23,7 @@ export default class extends Controller { } if (this.openOutlet) { - this.openOutlet.focusInput(); + this.openOutlet.show(); return; } 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 596d0f387..d83af4d65 100644 --- a/gem/lib/ruby_ui/context_menu/context_menu_content.rb +++ b/gem/lib/ruby_ui/context_menu/context_menu_content.rb @@ -15,7 +15,7 @@ def default_attrs data_state: "closed", data: {ruby_ui__context_menu_target: "content"}, class: - "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", + "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-[state=closed]:fill-mode-forwards 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 ebc1f20a4..cecb8440b 100644 --- a/gem/lib/ruby_ui/context_menu/context_menu_controller.js +++ b/gem/lib/ruby_ui/context_menu/context_menu_controller.js @@ -24,6 +24,8 @@ export default class extends Controller { disconnect() { this.hide(); + // Nothing is left to wait for the exit animation, so apply the pending hide now. + if (this.hasContentTarget) this.settleExit(this.contentTarget); } handleContextMenu(event) { @@ -49,14 +51,58 @@ export default class extends Controller { 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; } + + if (!this.hasContentTarget) return; + + this.contentTarget.dataset.state = "closed"; + this.hideAfterExitAnimation(this.contentTarget); + } + + afterExit(content) { + content.classList.add("hidden"); + } + + // Overlay exit — the same block in every overlay controller, so keep them in sync. + exitAnimationNames = new WeakMap(); + + hideAfterExitAnimation(animated) { + const exitAnimations = animated + .getAnimations() + .filter((animation) => animation instanceof CSSAnimation); + + // No exit animation, or no box to run it in: animationend would never fire. + if (exitAnimations.length === 0) { + this.settleExit(animated); + return; + } + + this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName)); + animated.addEventListener("animationend", this.handleExitAnimationEnd); + animated.addEventListener("animationcancel", this.handleExitAnimationEnd); + } + + handleExitAnimationEnd = (event) => { + // animationend bubbles — an animated child must not hide its container. + if (event.target !== event.currentTarget) return; + // Closing mid-open cancels the enter animation; only the exit run settles this. + if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return; + + this.settleExit(event.currentTarget); + }; + + settleExit(animated) { + animated.removeEventListener("animationend", this.handleExitAnimationEnd); + animated.removeEventListener("animationcancel", this.handleExitAnimationEnd); + // Reopened mid-exit: it is on its way back in, leave it visible. + if (animated.dataset.state !== "closed") return; + + this.afterExit(animated); } updatePosition() { diff --git a/gem/lib/ruby_ui/dropdown_menu/dropdown_menu_content.rb b/gem/lib/ruby_ui/dropdown_menu/dropdown_menu_content.rb index 664f89e2d..741ecbe7f 100644 --- a/gem/lib/ruby_ui/dropdown_menu/dropdown_menu_content.rb +++ b/gem/lib/ruby_ui/dropdown_menu/dropdown_menu_content.rb @@ -13,9 +13,10 @@ def view_template(&block) def default_attrs { data: { - state: :open + state: :open, + ruby_ui__dropdown_menu_target: "panel" }, - class: "z-50 min-w-[8rem] 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 w-56" + class: "z-50 min-w-[8rem] 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-[state=closed]:fill-mode-forwards 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 w-56" } end diff --git a/gem/lib/ruby_ui/dropdown_menu/dropdown_menu_controller.js b/gem/lib/ruby_ui/dropdown_menu/dropdown_menu_controller.js index 6479c8442..9379d2e50 100644 --- a/gem/lib/ruby_ui/dropdown_menu/dropdown_menu_controller.js +++ b/gem/lib/ruby_ui/dropdown_menu/dropdown_menu_controller.js @@ -8,7 +8,7 @@ import { } from "@floating-ui/dom"; export default class extends Controller { - static targets = ["trigger", "content", "menuItem"]; + static targets = ["trigger", "content", "panel", "menuItem"]; static values = { open: { type: Boolean, @@ -31,6 +31,8 @@ export default class extends Controller { if (this.autoUpdateCleanup) { this.autoUpdateCleanup(); } + // Nothing is left to wait for the exit animation, so apply the pending hide now. + if (this.hasPanelTarget) this.settleExit(this.panelTarget); } #setupAutoUpdate() { @@ -63,9 +65,8 @@ export default class extends Controller { } toggle() { - this.contentTarget.classList.contains("hidden") - ? this.#open() - : this.close(); + // `hidden` now lands after the exit animation, so it no longer tells the states apart. + this.openValue ? this.close() : this.#open(); } #open() { @@ -77,13 +78,57 @@ export default class extends Controller { // static z-index, so closed siblings stack in normal flow and never cover it. this.element.style.zIndex = "50"; this.contentTarget.classList.remove("hidden"); + this.panelTarget.dataset.state = "open"; } close() { this.openValue = false; this.#removeEventListeners(); - this.element.style.zIndex = ""; + this.panelTarget.dataset.state = "closed"; + this.hideAfterExitAnimation(this.panelTarget); + } + + afterExit() { this.contentTarget.classList.add("hidden"); + // Held at 50 until now, so the menu fades above its siblings rather than behind them. + this.element.style.zIndex = ""; + } + + // Overlay exit — the same block in every overlay controller, so keep them in sync. + exitAnimationNames = new WeakMap(); + + hideAfterExitAnimation(animated) { + const exitAnimations = animated + .getAnimations() + .filter((animation) => animation instanceof CSSAnimation); + + // No exit animation, or no box to run it in: animationend would never fire. + if (exitAnimations.length === 0) { + this.settleExit(animated); + return; + } + + this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName)); + animated.addEventListener("animationend", this.handleExitAnimationEnd); + animated.addEventListener("animationcancel", this.handleExitAnimationEnd); + } + + handleExitAnimationEnd = (event) => { + // animationend bubbles — an animated child must not hide its container. + if (event.target !== event.currentTarget) return; + // Closing mid-open cancels the enter animation; only the exit run settles this. + if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return; + + this.settleExit(event.currentTarget); + }; + + settleExit(animated) { + animated.removeEventListener("animationend", this.handleExitAnimationEnd); + animated.removeEventListener("animationcancel", this.handleExitAnimationEnd); + // Reopened mid-exit: it is on its way back in, leave it visible. + if (animated.dataset.state !== "closed") return; + + this.afterExit(animated); } #handleKeydown(e) { 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 1a5f2b3e0..11576072b 100644 --- a/gem/lib/ruby_ui/hover_card/hover_card_content.rb +++ b/gem/lib/ruby_ui/hover_card/hover_card_content.rb @@ -14,7 +14,7 @@ def default_attrs ruby_ui__hover_card_target: "content", state: :closed }, - 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" + 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-[state=closed]:fill-mode-forwards 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 955117f08..3aa49a83a 100644 --- a/gem/lib/ruby_ui/hover_card/hover_card_controller.js +++ b/gem/lib/ruby_ui/hover_card/hover_card_controller.js @@ -27,14 +27,17 @@ export default class extends Controller { this.addEventListeners(); } + // Teardown that cannot fail comes first: a missing target throws, and Stimulus skips the rest. disconnect() { - this.removeEventListeners(); this.clearTimers(); document.removeEventListener("keydown", this.boundHandleKeydown); if (this.cleanup) { this.cleanup(); this.cleanup = null; } + this.removeEventListeners(); + // Nothing is left to wait for the exit animation, so apply the pending hide now. + if (this.hasContentTarget) this.settleExit(this.contentTarget); } // Supports the tippy-style `delay` option: a number or a [open, close] tuple. @@ -57,14 +60,19 @@ export default class extends Controller { } 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); + if (this.hasTriggerTarget) { + this.triggerTarget.removeEventListener("mouseenter", this.handleMouseEnter); + this.triggerTarget.removeEventListener("mouseleave", this.handleMouseLeave); + this.triggerTarget.removeEventListener("focusin", this.handleMouseEnter); + this.triggerTarget.removeEventListener("focusout", this.handleMouseLeave); + } + + if (this.hasContentTarget) { + this.contentTarget.removeEventListener("mouseenter", this.handleMouseEnter); + this.contentTarget.removeEventListener("mouseleave", this.handleMouseLeave); + this.contentTarget.removeEventListener("focusin", this.handleMouseEnter); + this.contentTarget.removeEventListener("focusout", this.handleMouseLeave); + } } handleMouseEnter = () => { @@ -95,14 +103,58 @@ export default class extends Controller { 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; } + + if (!this.hasContentTarget) return; + + this.contentTarget.dataset.state = "closed"; + this.hideAfterExitAnimation(this.contentTarget); + } + + afterExit(content) { + content.classList.add("hidden"); + } + + // Overlay exit — the same block in every overlay controller, so keep them in sync. + exitAnimationNames = new WeakMap(); + + hideAfterExitAnimation(animated) { + const exitAnimations = animated + .getAnimations() + .filter((animation) => animation instanceof CSSAnimation); + + // No exit animation, or no box to run it in: animationend would never fire. + if (exitAnimations.length === 0) { + this.settleExit(animated); + return; + } + + this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName)); + animated.addEventListener("animationend", this.handleExitAnimationEnd); + animated.addEventListener("animationcancel", this.handleExitAnimationEnd); + } + + handleExitAnimationEnd = (event) => { + // animationend bubbles — an animated child must not hide its container. + if (event.target !== event.currentTarget) return; + // Closing mid-open cancels the enter animation; only the exit run settles this. + if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return; + + this.settleExit(event.currentTarget); + }; + + settleExit(animated) { + animated.removeEventListener("animationend", this.handleExitAnimationEnd); + animated.removeEventListener("animationcancel", this.handleExitAnimationEnd); + // Reopened mid-exit: it is on its way back in, leave it visible. + if (animated.dataset.state !== "closed") return; + + this.afterExit(animated); } updatePosition() { diff --git a/gem/lib/ruby_ui/popover/popover_content.rb b/gem/lib/ruby_ui/popover/popover_content.rb index 1730dd9bb..440774049 100644 --- a/gem/lib/ruby_ui/popover/popover_content.rb +++ b/gem/lib/ruby_ui/popover/popover_content.rb @@ -18,6 +18,7 @@ def default_attrs "hidden z-50 rounded-md border bg-background p-1 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-[state=closed]:fill-mode-forwards", "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", "absolute" diff --git a/gem/lib/ruby_ui/popover/popover_controller.js b/gem/lib/ruby_ui/popover/popover_controller.js index 90e6f062f..8dbcbffaf 100644 --- a/gem/lib/ruby_ui/popover/popover_controller.js +++ b/gem/lib/ruby_ui/popover/popover_controller.js @@ -33,6 +33,8 @@ export default class extends Controller { document.removeEventListener("click", this.handleOutsideClick); this.stopAutoUpdate(); this.removeElementEventListeners(); + // Nothing is left to wait for the exit animation, so apply the pending hide now. + if (this.hasContentTarget) this.settleExit(this.contentTarget); } addEventListeners() { @@ -112,8 +114,49 @@ export default class extends Controller { if (!this.hasContentTarget) return; - this.contentTarget.classList.add("hidden"); this.contentTarget.dataset.state = "closed"; + this.hideAfterExitAnimation(this.contentTarget); + } + + afterExit(content) { + content.classList.add("hidden"); + } + + // Overlay exit — the same block in every overlay controller, so keep them in sync. + exitAnimationNames = new WeakMap(); + + hideAfterExitAnimation(animated) { + const exitAnimations = animated + .getAnimations() + .filter((animation) => animation instanceof CSSAnimation); + + // No exit animation, or no box to run it in: animationend would never fire. + if (exitAnimations.length === 0) { + this.settleExit(animated); + return; + } + + this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName)); + animated.addEventListener("animationend", this.handleExitAnimationEnd); + animated.addEventListener("animationcancel", this.handleExitAnimationEnd); + } + + handleExitAnimationEnd = (event) => { + // animationend bubbles — an animated child must not hide its container. + if (event.target !== event.currentTarget) return; + // Closing mid-open cancels the enter animation; only the exit run settles this. + if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return; + + this.settleExit(event.currentTarget); + }; + + settleExit(animated) { + animated.removeEventListener("animationend", this.handleExitAnimationEnd); + animated.removeEventListener("animationcancel", this.handleExitAnimationEnd); + // Reopened mid-exit: it is on its way back in, leave it visible. + if (animated.dataset.state !== "closed") return; + + this.afterExit(animated); } updatePosition() { diff --git a/gem/lib/ruby_ui/select/select_content.rb b/gem/lib/ruby_ui/select/select_content.rb index 2037b55fa..135d96c66 100644 --- a/gem/lib/ruby_ui/select/select_content.rb +++ b/gem/lib/ruby_ui/select/select_content.rb @@ -10,7 +10,9 @@ def initialize(**attrs) def view_template(&block) div(**attrs) do div( - class: "max-h-96 w-full text-wrap overflow-auto rounded-md border bg-background p-1 text-foreground shadow-md animate-out group-data-[ruby-ui--select-open-value=true]/select:animate-in fade-out-0 group-data-[ruby-ui--select-open-value=true]/select:fade-in-0 zoom-out-95 group-data-[ruby-ui--select-open-value=true]/select:zoom-in-95 slide-in-from-top-2", &block + class: "max-h-96 w-full text-wrap overflow-auto 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-[state=closed]:fill-mode-forwards slide-in-from-top-2", + data: {state: :closed, ruby_ui__select_target: "panel"}, + &block ) end end diff --git a/gem/lib/ruby_ui/select/select_controller.js b/gem/lib/ruby_ui/select/select_controller.js index 918b30670..260a76346 100644 --- a/gem/lib/ruby_ui/select/select_controller.js +++ b/gem/lib/ruby_ui/select/select_controller.js @@ -2,7 +2,7 @@ import { Controller } from "@hotwired/stimulus"; import { computePosition, autoUpdate, offset, flip } from "@floating-ui/dom"; export default class extends Controller { - static targets = ["trigger", "content", "input", "value", "item"]; + static targets = ["trigger", "content", "panel", "input", "value", "item"]; static values = { open: Boolean }; static outlets = ["ruby-ui--select-item"]; @@ -17,6 +17,8 @@ export default class extends Controller { } disconnect() { + // Nothing is left to wait for the exit animation, so apply the pending hide now. + if (this.hasPanelTarget) this.settleExit(this.panelTarget); this.cleanup(); } @@ -114,8 +116,57 @@ export default class extends Controller { toogleContent() { this.openValue = !this.openValue; - this.contentTarget.classList.toggle("hidden"); this.triggerTarget.setAttribute("aria-expanded", this.openValue); + + if (this.openValue) { + this.contentTarget.classList.remove("hidden"); + this.panelTarget.dataset.state = "open"; + return; + } + + this.panelTarget.dataset.state = "closed"; + this.hideAfterExitAnimation(this.panelTarget); + } + + afterExit() { + this.contentTarget.classList.add("hidden"); + } + + // Overlay exit — the same block in every overlay controller, so keep them in sync. + exitAnimationNames = new WeakMap(); + + hideAfterExitAnimation(animated) { + const exitAnimations = animated + .getAnimations() + .filter((animation) => animation instanceof CSSAnimation); + + // No exit animation, or no box to run it in: animationend would never fire. + if (exitAnimations.length === 0) { + this.settleExit(animated); + return; + } + + this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName)); + animated.addEventListener("animationend", this.handleExitAnimationEnd); + animated.addEventListener("animationcancel", this.handleExitAnimationEnd); + } + + handleExitAnimationEnd = (event) => { + // animationend bubbles — an animated child must not hide its container. + if (event.target !== event.currentTarget) return; + // Closing mid-open cancels the enter animation; only the exit run settles this. + if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return; + + this.settleExit(event.currentTarget); + }; + + settleExit(animated) { + animated.removeEventListener("animationend", this.handleExitAnimationEnd); + animated.removeEventListener("animationcancel", this.handleExitAnimationEnd); + // Reopened mid-exit: it is on its way back in, leave it visible. + if (animated.dataset.state !== "closed") return; + + this.afterExit(animated); } setFloatingElement() { diff --git a/gem/lib/ruby_ui/sheet/sheet_content.rb b/gem/lib/ruby_ui/sheet/sheet_content.rb index a097f1e13..596d29386 100644 --- a/gem/lib/ruby_ui/sheet/sheet_content.rb +++ b/gem/lib/ruby_ui/sheet/sheet_content.rb @@ -32,8 +32,9 @@ def view_template(&block) def default_attrs { data_state: "open", # For animate in + data_ruby_ui__sheet_content_target: "panel", class: [ - "fixed pointer-events-auto z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500 overflow-scroll", + "fixed pointer-events-auto z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fill-mode-forwards data-[state=closed]:duration-300 data-[state=open]:duration-500 overflow-scroll", @side_classes ] } @@ -69,8 +70,9 @@ def backdrop div( data_state: "open", data_action: "click->ruby-ui--sheet-content#close", + data_ruby_ui__sheet_content_target: "backdrop", class: - "fixed pointer-events-auto inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" + "fixed pointer-events-auto inset-0 z-50 bg-background/80 backdrop-blur-sm 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]:fill-mode-forwards" ) end end diff --git a/gem/lib/ruby_ui/sheet/sheet_content_controller.js b/gem/lib/ruby_ui/sheet/sheet_content_controller.js index 8df0712be..caef6910b 100644 --- a/gem/lib/ruby_ui/sheet/sheet_content_controller.js +++ b/gem/lib/ruby_ui/sheet/sheet_content_controller.js @@ -1,7 +1,58 @@ -import { Controller } from "@hotwired/stimulus" +import { Controller } from "@hotwired/stimulus"; export default class extends Controller { + static targets = ["backdrop", "panel"]; + + disconnect() { + // Nothing is left to wait for the exit animation, so apply the pending removal now. + if (this.hasPanelTarget) this.settleExit(this.panelTarget); + } + close() { - this.element.remove() + this.backdropTarget.dataset.state = "closed"; + this.panelTarget.dataset.state = "closed"; + // The panel carries the longer exit, so the backdrop has finished by the time it settles. + this.hideAfterExitAnimation(this.panelTarget); + } + + afterExit() { + this.element.remove(); + } + + // Overlay exit — the same block in every overlay controller, so keep them in sync. + exitAnimationNames = new WeakMap(); + + hideAfterExitAnimation(animated) { + const exitAnimations = animated + .getAnimations() + .filter((animation) => animation instanceof CSSAnimation); + + // No exit animation, or no box to run it in: animationend would never fire. + if (exitAnimations.length === 0) { + this.settleExit(animated); + return; + } + + this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName)); + animated.addEventListener("animationend", this.handleExitAnimationEnd); + animated.addEventListener("animationcancel", this.handleExitAnimationEnd); + } + + handleExitAnimationEnd = (event) => { + // animationend bubbles — an animated child must not hide its container. + if (event.target !== event.currentTarget) return; + // Closing mid-open cancels the enter animation; only the exit run settles this. + if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return; + + this.settleExit(event.currentTarget); + }; + + settleExit(animated) { + animated.removeEventListener("animationend", this.handleExitAnimationEnd); + animated.removeEventListener("animationcancel", this.handleExitAnimationEnd); + // Reopened mid-exit: it is on its way back in, leave it visible. + if (animated.dataset.state !== "closed") return; + + this.afterExit(animated); } } diff --git a/gem/test/ruby_ui/clipboard_test.rb b/gem/test/ruby_ui/clipboard_test.rb index dd0d6c842..1dc1b0c8f 100644 --- a/gem/test/ruby_ui/clipboard_test.rb +++ b/gem/test/ruby_ui/clipboard_test.rb @@ -10,4 +10,13 @@ def test_render_with_all_items assert_match(/Copied/, output) end + + # `hidden` lands a frame after the animation ends; without a forwards fill mode that frame flashes. + def test_content_holds_the_last_frame_of_the_exit_animation + output = phlex do + RubyUI.ClipboardPopover(type: :success) { "Copied!" } + end + + assert_match(/data-\[state=closed\]:fill-mode-forwards/, output) + end end diff --git a/gem/test/ruby_ui/command_test.rb b/gem/test/ruby_ui/command_test.rb index 63323082e..dbdd19d02 100644 --- a/gem/test/ruby_ui/command_test.rb +++ b/gem/test/ruby_ui/command_test.rb @@ -62,4 +62,13 @@ def test_render_with_all_items assert_match(/Search/, output) assert_match(/data-controller="ruby-ui--command"/, output) end + + # Removal lands a frame after the animation ends; without a forwards fill mode that frame flashes. + def test_content_and_backdrop_hold_the_last_frame_of_the_exit_animation + output = phlex do + RubyUI.CommandDialogContent { "command body" } + end + + assert_equal 2, output.scan("data-[state=closed]:fill-mode-forwards").size + end end diff --git a/gem/test/ruby_ui/context_menu_test.rb b/gem/test/ruby_ui/context_menu_test.rb index 6cdefa3aa..9dcb30705 100644 --- a/gem/test/ruby_ui/context_menu_test.rb +++ b/gem/test/ruby_ui/context_menu_test.rb @@ -41,4 +41,15 @@ def test_content_renders_hidden_positioned_div_not_template assert_match(/absolute/, output) assert_match(/Back/, output) end + + # `hidden` lands a frame after the animation ends; without a forwards fill mode that frame flashes. + def test_content_holds_the_last_frame_of_the_exit_animation + output = phlex do + RubyUI.ContextMenuContent do + RubyUI.ContextMenuItem(href: "#") { "Back" } + end + end + + assert_match(/data-\[state=closed\]:fill-mode-forwards/, output) + end end diff --git a/gem/test/ruby_ui/dropdown_menu_test.rb b/gem/test/ruby_ui/dropdown_menu_test.rb index 261d44c6e..69d8d0270 100644 --- a/gem/test/ruby_ui/dropdown_menu_test.rb +++ b/gem/test/ruby_ui/dropdown_menu_test.rb @@ -62,4 +62,13 @@ def test_render_with_strategy_fixed assert_match(/is-fixed/, output) end + + # `hidden` lands a frame after the animation ends; without a forwards fill mode that frame flashes. + def test_content_holds_the_last_frame_of_the_exit_animation + output = phlex do + RubyUI.DropdownMenuContent { "menu body" } + end + + assert_match(/data-\[state=closed\]:fill-mode-forwards/, output) + end end diff --git a/gem/test/ruby_ui/hover_card_test.rb b/gem/test/ruby_ui/hover_card_test.rb index 0d0ebd2c3..5f45cfe6f 100644 --- a/gem/test/ruby_ui/hover_card_test.rb +++ b/gem/test/ruby_ui/hover_card_test.rb @@ -35,4 +35,13 @@ def test_content_renders_hidden_positioned_div_not_template assert_match(/absolute/, output) assert_match(/card body/, output) end + + # `hidden` lands a frame after the animation ends; without a forwards fill mode that frame flashes. + def test_content_holds_the_last_frame_of_the_exit_animation + output = phlex do + RubyUI.HoverCardContent { "card body" } + end + + assert_match(/data-\[state=closed\]:fill-mode-forwards/, output) + end end diff --git a/gem/test/ruby_ui/popover_test.rb b/gem/test/ruby_ui/popover_test.rb index a4039c96c..6f935cef4 100644 --- a/gem/test/ruby_ui/popover_test.rb +++ b/gem/test/ruby_ui/popover_test.rb @@ -38,4 +38,13 @@ def test_content_renders_closed_state_by_default assert_match(/absolute/, output) assert_match(/popover body/, output) end + + # `hidden` lands a frame after the animation ends; without a forwards fill mode that frame flashes. + def test_content_holds_the_last_frame_of_the_exit_animation + output = phlex do + RubyUI.PopoverContent { "popover body" } + end + + assert_match(/data-\[state=closed\]:fill-mode-forwards/, output) + end end diff --git a/gem/test/ruby_ui/select_test.rb b/gem/test/ruby_ui/select_test.rb index b51469043..2bdc88519 100644 --- a/gem/test/ruby_ui/select_test.rb +++ b/gem/test/ruby_ui/select_test.rb @@ -39,4 +39,13 @@ def test_select_value_renders_placeholder_when_block_returns_nil assert_match(/Placeholder/, output) end + + # `hidden` lands a frame after the animation ends; without a forwards fill mode that frame flashes. + def test_content_holds_the_last_frame_of_the_exit_animation + output = phlex do + RubyUI.SelectContent { "options" } + end + + assert_match(/data-\[state=closed\]:fill-mode-forwards/, output) + end end diff --git a/gem/test/ruby_ui/sheet_test.rb b/gem/test/ruby_ui/sheet_test.rb index 2b7074574..57e206b59 100644 --- a/gem/test/ruby_ui/sheet_test.rb +++ b/gem/test/ruby_ui/sheet_test.rb @@ -42,4 +42,13 @@ def test_render_open_when_open_is_true assert_match(/data-ruby-ui--sheet-open-value="true"/, output) end + + # Removal lands a frame after the animation ends; without a forwards fill mode that frame flashes. + def test_content_and_backdrop_hold_the_last_frame_of_the_exit_animation + output = phlex do + RubyUI.SheetContent { "sheet body" } + end + + assert_equal 2, output.scan("data-[state=closed]:fill-mode-forwards").size + end end diff --git a/mcp/data/registry.json b/mcp/data/registry.json index cc4cc3fc9..ed4cd153a 100644 --- a/mcp/data/registry.json +++ b/mcp/data/registry.json @@ -805,11 +805,11 @@ }, { "path": "clipboard_controller.js", - "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { computePosition, flip, shift } from \"@floating-ui/dom\";\n\n// Connects to data-controller=\"accordion\"\nexport default class extends Controller {\n static targets = ['trigger', 'source', 'successPopover', 'errorPopover']\n static values = {\n options: {\n type: Object,\n default: {},\n },\n }\n\n copy() {\n let sourceElement = this.sourceTarget.children[0];\n if (!sourceElement) {\n this.showErrorPopover();\n return;\n }\n let textToCopy = sourceElement.tagName === 'INPUT' ? sourceElement.value : sourceElement.innerText;\n navigator.clipboard.writeText(textToCopy).then(() => {\n this.#showSuccessPopover();\n }).catch(() => {\n this.#showErrorPopover();\n })\n }\n\n onClickOutside() {\n if (!this.successPopoverTarget.classList.contains(\"hidden\")) this.successPopoverTarget.classList.add(\"hidden\");\n if (!this.errorPopoverTarget.classList.contains(\"hidden\")) this.errorPopoverTarget.classList.add(\"hidden\");\n }\n\n #computeTooltip(popoverElement) {\n computePosition(this.triggerTarget, popoverElement, {\n placement: this.optionsValue.placement || \"top\",\n middleware: [flip(), shift()],\n }).then(({ x, y }) => {\n Object.assign(popoverElement.style, {\n left: `${x}px`,\n top: `${y}px`,\n });\n });\n }\n\n #showSuccessPopover() {\n this.#computeTooltip(this.successPopoverTarget);\n this.successPopoverTarget.classList.remove(\"hidden\");\n }\n\n #showErrorPopover() {\n this.#computeTooltip(this.errorPopoverTarget);\n this.errorPopoverTarget.classList.remove(\"hidden\");\n }\n}\n" + "content": "import { Controller } from \"@hotwired/stimulus\"\nimport { computePosition, flip, shift } from \"@floating-ui/dom\";\n\n// Connects to data-controller=\"accordion\"\nexport default class extends Controller {\n static targets = ['trigger', 'source', 'successPopover', 'successPanel', 'errorPopover', 'errorPanel']\n static values = {\n options: {\n type: Object,\n default: {},\n },\n }\n\n disconnect() {\n // Nothing is left to wait for the exit animation, so apply the pending hide now.\n if (this.hasSuccessPanelTarget) this.settleExit(this.successPanelTarget);\n if (this.hasErrorPanelTarget) this.settleExit(this.errorPanelTarget);\n }\n\n copy() {\n let sourceElement = this.sourceTarget.children[0];\n if (!sourceElement) {\n this.#showErrorPopover();\n return;\n }\n let textToCopy = sourceElement.tagName === 'INPUT' ? sourceElement.value : sourceElement.innerText;\n navigator.clipboard.writeText(textToCopy).then(() => {\n this.#showSuccessPopover();\n }).catch(() => {\n this.#showErrorPopover();\n })\n }\n\n onClickOutside() {\n this.#hidePopover(this.successPopoverTarget, this.successPanelTarget);\n this.#hidePopover(this.errorPopoverTarget, this.errorPanelTarget);\n }\n\n #computeTooltip(popoverElement) {\n computePosition(this.triggerTarget, popoverElement, {\n placement: this.optionsValue.placement || \"top\",\n middleware: [flip(), shift()],\n }).then(({ x, y }) => {\n Object.assign(popoverElement.style, {\n left: `${x}px`,\n top: `${y}px`,\n });\n });\n }\n\n #showSuccessPopover() {\n this.#showPopover(this.successPopoverTarget, this.successPanelTarget);\n }\n\n #showErrorPopover() {\n this.#showPopover(this.errorPopoverTarget, this.errorPanelTarget);\n }\n\n #showPopover(popover, panel) {\n this.#computeTooltip(popover);\n popover.classList.remove(\"hidden\");\n panel.dataset.state = \"open\";\n }\n\n #hidePopover(popover, panel) {\n if (popover.classList.contains(\"hidden\")) return;\n\n panel.dataset.state = \"closed\";\n this.hideAfterExitAnimation(panel);\n }\n\n afterExit(panel) {\n const popover = panel === this.successPanelTarget ? this.successPopoverTarget : this.errorPopoverTarget;\n popover.classList.add(\"hidden\");\n }\n\n // Overlay exit — the same block in every overlay controller, so keep them in sync.\n exitAnimationNames = new WeakMap();\n\n hideAfterExitAnimation(animated) {\n const exitAnimations = animated\n .getAnimations()\n .filter((animation) => animation instanceof CSSAnimation);\n\n // No exit animation, or no box to run it in: animationend would never fire.\n if (exitAnimations.length === 0) {\n this.settleExit(animated);\n return;\n }\n\n this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName));\n animated.addEventListener(\"animationend\", this.handleExitAnimationEnd);\n animated.addEventListener(\"animationcancel\", this.handleExitAnimationEnd);\n }\n\n handleExitAnimationEnd = (event) => {\n // animationend bubbles — an animated child must not hide its container.\n if (event.target !== event.currentTarget) return;\n // Closing mid-open cancels the enter animation; only the exit run settles this.\n if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return;\n\n this.settleExit(event.currentTarget);\n };\n\n settleExit(animated) {\n animated.removeEventListener(\"animationend\", this.handleExitAnimationEnd);\n animated.removeEventListener(\"animationcancel\", this.handleExitAnimationEnd);\n // Reopened mid-exit: it is on its way back in, leave it visible.\n if (animated.dataset.state !== \"closed\") return;\n\n this.afterExit(animated);\n }\n}\n" }, { "path": "clipboard_popover.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ClipboardPopover < Base\n def initialize(type:, **attrs)\n @type = type\n super(**attrs)\n end\n\n def view_template(&block)\n div(\n class: \"hidden\",\n style: \"width: max-content; position: absolute; top: 0; left: 0;\",\n data: {ruby_ui__clipboard_target: clipboard_target}\n ) do\n div(**attrs, &block)\n end\n end\n\n private\n\n def clipboard_target\n case @type\n when :success\n \"successPopover\"\n when :error\n \"errorPopover\"\n end\n end\n\n def default_attrs\n {\n data: {\n state: :open\n },\n class: \"z-50 rounded-md text-sm border bg-background px-2 py-0.5 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\"\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ClipboardPopover < Base\n def initialize(type:, **attrs)\n @type = type\n super(**attrs)\n end\n\n def view_template(&block)\n div(\n class: \"hidden\",\n style: \"width: max-content; position: absolute; top: 0; left: 0;\",\n data: {ruby_ui__clipboard_target: clipboard_target}\n ) do\n div(**attrs, &block)\n end\n end\n\n private\n\n def clipboard_target\n case @type\n when :success\n \"successPopover\"\n when :error\n \"errorPopover\"\n end\n end\n\n def panel_target\n case @type\n when :success\n \"successPanel\"\n when :error\n \"errorPanel\"\n end\n end\n\n def default_attrs\n {\n data: {\n state: :open,\n ruby_ui__clipboard_target: panel_target\n },\n class: \"z-50 rounded-md text-sm border bg-background px-2 py-0.5 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-[state=closed]:fill-mode-forwards 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\"\n }\n end\n end\nend\n" }, { "path": "clipboard_source.rb", @@ -1052,7 +1052,7 @@ }, { "path": "command_controller.js", - "content": "import { Controller } from \"@hotwired/stimulus\";\nimport Fuse from \"fuse.js\";\n\n// Connects to data-controller=\"ruby-ui--command\"\nexport default class extends Controller {\n static targets = [\"input\", \"group\", \"item\", \"empty\"];\n\n connect() {\n this.selectedIndex = -1;\n\n if (!this.hasInputTarget) {\n return;\n }\n\n this.inputTarget.focus();\n this.searchIndex = this.buildSearchIndex();\n this.toggleVisibility(this.emptyTargets, false);\n }\n\n dismiss() {\n // allow scroll on body\n document.body.classList.remove(\"overflow-hidden\");\n // remove the element\n this.element.remove();\n }\n\n focusInput() {\n this.inputTarget?.focus();\n }\n\n filter(e) {\n // Deselect any previously selected item\n this.deselectAll();\n\n const query = e.target.value.toLowerCase();\n if (query.length === 0) {\n this.resetVisibility();\n return;\n }\n\n this.toggleVisibility(this.itemTargets, false);\n\n const results = this.searchIndex.search(query);\n results.forEach((result) =>\n this.toggleVisibility([result.item.element], true),\n );\n\n this.toggleVisibility(this.emptyTargets, results.length === 0);\n this.updateGroupVisibility();\n }\n\n toggleVisibility(elements, isVisible) {\n elements.forEach((el) => el.classList.toggle(\"hidden\", !isVisible));\n }\n\n updateGroupVisibility() {\n this.groupTargets.forEach((group) => {\n const hasVisibleItems =\n group.querySelectorAll(\n \"[data-ruby-ui--command-target='item']:not(.hidden)\",\n ).length > 0;\n this.toggleVisibility([group], hasVisibleItems);\n });\n }\n\n resetVisibility() {\n this.toggleVisibility(this.itemTargets, true);\n this.toggleVisibility(this.groupTargets, true);\n this.toggleVisibility(this.emptyTargets, false);\n }\n\n buildSearchIndex() {\n const options = {\n keys: [\"value\"],\n threshold: 0.2,\n includeMatches: true,\n };\n const items = this.itemTargets.map((el) => ({\n value: el.dataset.value,\n element: el,\n }));\n return new Fuse(items, options);\n }\n\n handleKeydown(e) {\n const visibleItems = this.itemTargets.filter(\n (item) => !item.classList.contains(\"hidden\"),\n );\n if (e.key === \"ArrowDown\") {\n e.preventDefault();\n this.updateSelectedItem(visibleItems, 1);\n } else if (e.key === \"ArrowUp\") {\n e.preventDefault();\n this.updateSelectedItem(visibleItems, -1);\n } else if (e.key === \"Enter\" && this.selectedIndex !== -1) {\n e.preventDefault();\n visibleItems[this.selectedIndex].click();\n }\n }\n\n updateSelectedItem(visibleItems, direction) {\n if (this.selectedIndex >= 0) {\n this.toggleAriaSelected(visibleItems[this.selectedIndex], false);\n }\n\n this.selectedIndex += direction;\n\n // Ensure the selected index is within the bounds of the visible items\n if (this.selectedIndex < 0) {\n this.selectedIndex = visibleItems.length - 1;\n } else if (this.selectedIndex >= visibleItems.length) {\n this.selectedIndex = 0;\n }\n\n this.toggleAriaSelected(visibleItems[this.selectedIndex], true);\n }\n\n toggleAriaSelected(element, isSelected) {\n element.setAttribute(\"aria-selected\", isSelected.toString());\n }\n\n deselectAll() {\n this.itemTargets.forEach((item) => this.toggleAriaSelected(item, false));\n this.selectedIndex = -1;\n }\n}\n" + "content": "import { Controller } from \"@hotwired/stimulus\";\nimport Fuse from \"fuse.js\";\n\n// Connects to data-controller=\"ruby-ui--command\"\nexport default class extends Controller {\n static targets = [\"input\", \"group\", \"item\", \"empty\", \"backdrop\", \"panel\"];\n\n connect() {\n this.selectedIndex = -1;\n\n if (!this.hasInputTarget) {\n return;\n }\n\n this.inputTarget.focus();\n this.searchIndex = this.buildSearchIndex();\n this.toggleVisibility(this.emptyTargets, false);\n }\n\n disconnect() {\n // Nothing is left to wait for the exit animation, so apply the pending removal now.\n if (this.hasPanelTarget) this.settleExit(this.panelTarget);\n }\n\n dismiss() {\n this.backdropTarget.dataset.state = \"closed\";\n this.panelTarget.dataset.state = \"closed\";\n this.hideAfterExitAnimation(this.panelTarget);\n }\n\n // Opened again while dismissing: bring this instance back instead of stacking a new one.\n show() {\n this.backdropTarget.dataset.state = \"open\";\n this.panelTarget.dataset.state = \"open\";\n document.body.classList.add(\"overflow-hidden\");\n this.focusInput();\n }\n\n afterExit() {\n document.body.classList.remove(\"overflow-hidden\");\n this.element.remove();\n }\n\n // Overlay exit — the same block in every overlay controller, so keep them in sync.\n exitAnimationNames = new WeakMap();\n\n hideAfterExitAnimation(animated) {\n const exitAnimations = animated\n .getAnimations()\n .filter((animation) => animation instanceof CSSAnimation);\n\n // No exit animation, or no box to run it in: animationend would never fire.\n if (exitAnimations.length === 0) {\n this.settleExit(animated);\n return;\n }\n\n this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName));\n animated.addEventListener(\"animationend\", this.handleExitAnimationEnd);\n animated.addEventListener(\"animationcancel\", this.handleExitAnimationEnd);\n }\n\n handleExitAnimationEnd = (event) => {\n // animationend bubbles — an animated child must not hide its container.\n if (event.target !== event.currentTarget) return;\n // Closing mid-open cancels the enter animation; only the exit run settles this.\n if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return;\n\n this.settleExit(event.currentTarget);\n };\n\n settleExit(animated) {\n animated.removeEventListener(\"animationend\", this.handleExitAnimationEnd);\n animated.removeEventListener(\"animationcancel\", this.handleExitAnimationEnd);\n // Reopened mid-exit: it is on its way back in, leave it visible.\n if (animated.dataset.state !== \"closed\") return;\n\n this.afterExit(animated);\n }\n\n focusInput() {\n this.inputTarget?.focus();\n }\n\n filter(e) {\n // Deselect any previously selected item\n this.deselectAll();\n\n const query = e.target.value.toLowerCase();\n if (query.length === 0) {\n this.resetVisibility();\n return;\n }\n\n this.toggleVisibility(this.itemTargets, false);\n\n const results = this.searchIndex.search(query);\n results.forEach((result) =>\n this.toggleVisibility([result.item.element], true),\n );\n\n this.toggleVisibility(this.emptyTargets, results.length === 0);\n this.updateGroupVisibility();\n }\n\n toggleVisibility(elements, isVisible) {\n elements.forEach((el) => el.classList.toggle(\"hidden\", !isVisible));\n }\n\n updateGroupVisibility() {\n this.groupTargets.forEach((group) => {\n const hasVisibleItems =\n group.querySelectorAll(\n \"[data-ruby-ui--command-target='item']:not(.hidden)\",\n ).length > 0;\n this.toggleVisibility([group], hasVisibleItems);\n });\n }\n\n resetVisibility() {\n this.toggleVisibility(this.itemTargets, true);\n this.toggleVisibility(this.groupTargets, true);\n this.toggleVisibility(this.emptyTargets, false);\n }\n\n buildSearchIndex() {\n const options = {\n keys: [\"value\"],\n threshold: 0.2,\n includeMatches: true,\n };\n const items = this.itemTargets.map((el) => ({\n value: el.dataset.value,\n element: el,\n }));\n return new Fuse(items, options);\n }\n\n handleKeydown(e) {\n const visibleItems = this.itemTargets.filter(\n (item) => !item.classList.contains(\"hidden\"),\n );\n if (e.key === \"ArrowDown\") {\n e.preventDefault();\n this.updateSelectedItem(visibleItems, 1);\n } else if (e.key === \"ArrowUp\") {\n e.preventDefault();\n this.updateSelectedItem(visibleItems, -1);\n } else if (e.key === \"Enter\" && this.selectedIndex !== -1) {\n e.preventDefault();\n visibleItems[this.selectedIndex].click();\n }\n }\n\n updateSelectedItem(visibleItems, direction) {\n if (this.selectedIndex >= 0) {\n this.toggleAriaSelected(visibleItems[this.selectedIndex], false);\n }\n\n this.selectedIndex += direction;\n\n // Ensure the selected index is within the bounds of the visible items\n if (this.selectedIndex < 0) {\n this.selectedIndex = visibleItems.length - 1;\n } else if (this.selectedIndex >= visibleItems.length) {\n this.selectedIndex = 0;\n }\n\n this.toggleAriaSelected(visibleItems[this.selectedIndex], true);\n }\n\n toggleAriaSelected(element, isSelected) {\n element.setAttribute(\"aria-selected\", isSelected.toString());\n }\n\n deselectAll() {\n this.itemTargets.forEach((item) => this.toggleAriaSelected(item, false));\n this.selectedIndex = -1;\n }\n}\n" }, { "path": "command_dialog.rb", @@ -1060,11 +1060,11 @@ }, { "path": "command_dialog_content.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CommandDialogContent < Base\n SIZES = {\n xs: \"max-w-sm\",\n sm: \"max-w-md\",\n md: \"max-w-lg\",\n lg: \"max-w-2xl\",\n xl: \"max-w-4xl\",\n full: \"max-w-full\"\n }\n\n def initialize(size: :md, **attrs)\n @size = size\n super(**attrs)\n end\n\n def view_template(&block)\n template(data: {ruby_ui__command_dialog_target: \"content\"}) do\n div(data: {controller: \"ruby-ui--command\", ruby_ui__command_dialog_instance: true}) do\n backdrop\n div(**attrs, &block)\n end\n end\n end\n\n private\n\n def default_attrs\n {\n data_state: \"open\",\n class: [\n \"fixed pointer-events-auto left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full\",\n SIZES[@size]\n ]\n }\n end\n\n def backdrop\n div(\n data_state: \"open\",\n data_action: \"click->ruby-ui--command#dismiss esc->ruby-ui--command#dismiss\",\n class: \"fixed pointer-events-auto inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\"\n )\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class CommandDialogContent < Base\n SIZES = {\n xs: \"max-w-sm\",\n sm: \"max-w-md\",\n md: \"max-w-lg\",\n lg: \"max-w-2xl\",\n xl: \"max-w-4xl\",\n full: \"max-w-full\"\n }\n\n def initialize(size: :md, **attrs)\n @size = size\n super(**attrs)\n end\n\n def view_template(&block)\n template(data: {ruby_ui__command_dialog_target: \"content\"}) do\n div(data: {controller: \"ruby-ui--command\", ruby_ui__command_dialog_instance: true}) do\n backdrop\n div(**attrs, &block)\n end\n end\n end\n\n private\n\n def default_attrs\n {\n data_state: \"open\",\n data_ruby_ui__command_target: \"panel\",\n class: [\n \"fixed pointer-events-auto left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background shadow-lg duration-200 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-[state=closed]:fill-mode-forwards data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full\",\n SIZES[@size]\n ]\n }\n end\n\n def backdrop\n div(\n data_state: \"open\",\n data_action: \"click->ruby-ui--command#dismiss esc->ruby-ui--command#dismiss\",\n data_ruby_ui__command_target: \"backdrop\",\n class: \"fixed pointer-events-auto inset-0 z-50 bg-background/80 backdrop-blur-sm 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]:fill-mode-forwards\"\n )\n end\n end\nend\n" }, { "path": "command_dialog_controller.js", - "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"ruby-ui--command-dialog\"\nexport default class extends Controller {\n static targets = [\"content\"];\n static outlets = [\"ruby-ui--command\"];\n\n rubyUiCommandOutletConnected(controller) {\n this.openOutlet = controller;\n }\n\n rubyUiCommandOutletDisconnected() {\n this.openOutlet = null;\n }\n\n open(e) {\n if (e) {\n e.preventDefault();\n }\n\n if (!this.hasContentTarget) {\n return;\n }\n\n if (this.openOutlet) {\n this.openOutlet.focusInput();\n return;\n }\n\n document.body.insertAdjacentHTML(\"beforeend\", this.contentTarget.innerHTML);\n // prevent scroll on body\n document.body.classList.add(\"overflow-hidden\");\n }\n}\n" + "content": "import { Controller } from \"@hotwired/stimulus\";\n\n// Connects to data-controller=\"ruby-ui--command-dialog\"\nexport default class extends Controller {\n static targets = [\"content\"];\n static outlets = [\"ruby-ui--command\"];\n\n rubyUiCommandOutletConnected(controller) {\n this.openOutlet = controller;\n }\n\n rubyUiCommandOutletDisconnected() {\n this.openOutlet = null;\n }\n\n open(e) {\n if (e) {\n e.preventDefault();\n }\n\n if (!this.hasContentTarget) {\n return;\n }\n\n if (this.openOutlet) {\n this.openOutlet.show();\n return;\n }\n\n document.body.insertAdjacentHTML(\"beforeend\", this.contentTarget.innerHTML);\n // prevent scroll on body\n document.body.classList.add(\"overflow-hidden\");\n }\n}\n" }, { "path": "command_dialog_trigger.rb", @@ -1123,11 +1123,11 @@ }, { "path": "context_menu_content.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ContextMenuContent < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n role: \"menu\",\n aria_orientation: \"vertical\",\n data_state: \"closed\",\n data: {ruby_ui__context_menu_target: \"content\"},\n class:\n \"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\",\n tabindex: \"-1\",\n data_orientation: \"vertical\"\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ContextMenuContent < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n role: \"menu\",\n aria_orientation: \"vertical\",\n data_state: \"closed\",\n data: {ruby_ui__context_menu_target: \"content\"},\n class:\n \"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-[state=closed]:fill-mode-forwards 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\",\n tabindex: \"-1\",\n data_orientation: \"vertical\"\n }\n end\n end\nend\n" }, { "path": "context_menu_controller.js", - "content": "import { Controller } from \"@hotwired/stimulus\";\nimport {\n computePosition,\n flip,\n shift,\n offset,\n autoUpdate,\n} from \"@floating-ui/dom\";\n\nexport default class extends Controller {\n static targets = [\"trigger\", \"content\", \"menuItem\"];\n static values = {\n open: { type: Boolean, default: false },\n options: { type: Object, default: {} },\n // make content width match the trigger element (true/false)\n matchWidth: { type: Boolean, default: false },\n };\n\n connect() {\n this.cleanup = null;\n this.selectedIndex = -1;\n this.boundHandleKeydown = this.handleKeydown.bind(this);\n }\n\n disconnect() {\n this.hide();\n }\n\n handleContextMenu(event) {\n event.preventDefault();\n this.open();\n }\n\n open() {\n this.openValue = true;\n this.contentTarget.classList.remove(\"hidden\");\n this.contentTarget.dataset.state = \"open\";\n if (this.matchWidthValue) {\n this.contentTarget.style.width = `${this.triggerTarget.offsetWidth}px`;\n }\n this.addEventListeners();\n this.updatePosition();\n }\n\n close() {\n this.hide();\n }\n\n hide() {\n if (!this.openValue) return;\n this.openValue = false;\n this.contentTarget.classList.add(\"hidden\");\n this.contentTarget.dataset.state = \"closed\";\n this.removeEventListeners();\n this.deselectAll();\n if (this.cleanup) {\n this.cleanup();\n this.cleanup = null;\n }\n }\n\n updatePosition() {\n if (this.cleanup) this.cleanup();\n\n this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => {\n computePosition(this.triggerTarget, this.contentTarget, {\n placement: this.optionsValue.placement || \"bottom-start\",\n middleware: [offset(4), flip(), shift({ padding: 8 })],\n }).then(({ x, y, placement }) => {\n Object.assign(this.contentTarget.style, {\n left: `${x}px`,\n top: `${y}px`,\n });\n this.contentTarget.dataset.side = placement.split(\"-\")[0];\n });\n });\n }\n\n addEventListeners() {\n document.addEventListener(\"keydown\", this.boundHandleKeydown);\n document.addEventListener(\"click\", this.handleOutsidePointer);\n // A right-click outside should dismiss this menu and let the native\n // context menu (or another trigger's menu) take over.\n document.addEventListener(\"contextmenu\", this.handleOutsidePointer);\n }\n\n removeEventListeners() {\n document.removeEventListener(\"keydown\", this.boundHandleKeydown);\n document.removeEventListener(\"click\", this.handleOutsidePointer);\n document.removeEventListener(\"contextmenu\", this.handleOutsidePointer);\n }\n\n handleOutsidePointer = (event) => {\n if (!this.element.contains(event.target)) {\n this.hide();\n }\n };\n\n handleKeydown(e) {\n if (e.key === \"Escape\") {\n e.preventDefault();\n this.hide();\n return;\n }\n\n if (this.menuItemTargets.length === 0) return;\n\n if (e.key === \"ArrowDown\") {\n e.preventDefault();\n this.updateSelectedItem(1);\n } else if (e.key === \"ArrowUp\") {\n e.preventDefault();\n this.updateSelectedItem(-1);\n } else if (e.key === \"Enter\" && this.selectedIndex !== -1) {\n e.preventDefault();\n this.menuItemTargets[this.selectedIndex].click();\n }\n }\n\n updateSelectedItem(direction) {\n this.menuItemTargets.forEach((item, index) => {\n if (item.getAttribute(\"aria-selected\") === \"true\") {\n this.selectedIndex = index;\n }\n });\n\n if (this.selectedIndex >= 0) {\n this.toggleAriaSelected(this.menuItemTargets[this.selectedIndex], false);\n }\n\n this.selectedIndex += direction;\n\n if (this.selectedIndex < 0) {\n this.selectedIndex = this.menuItemTargets.length - 1;\n } else if (this.selectedIndex >= this.menuItemTargets.length) {\n this.selectedIndex = 0;\n }\n\n this.toggleAriaSelected(this.menuItemTargets[this.selectedIndex], true);\n }\n\n toggleAriaSelected(element, isSelected) {\n if (isSelected) {\n element.setAttribute(\"aria-selected\", \"true\");\n } else {\n element.removeAttribute(\"aria-selected\");\n }\n }\n\n deselectAll() {\n this.menuItemTargets.forEach((item) =>\n this.toggleAriaSelected(item, false)\n );\n this.selectedIndex = -1;\n }\n}\n" + "content": "import { Controller } from \"@hotwired/stimulus\";\nimport {\n computePosition,\n flip,\n shift,\n offset,\n autoUpdate,\n} from \"@floating-ui/dom\";\n\nexport default class extends Controller {\n static targets = [\"trigger\", \"content\", \"menuItem\"];\n static values = {\n open: { type: Boolean, default: false },\n options: { type: Object, default: {} },\n // make content width match the trigger element (true/false)\n matchWidth: { type: Boolean, default: false },\n };\n\n connect() {\n this.cleanup = null;\n this.selectedIndex = -1;\n this.boundHandleKeydown = this.handleKeydown.bind(this);\n }\n\n disconnect() {\n this.hide();\n // Nothing is left to wait for the exit animation, so apply the pending hide now.\n if (this.hasContentTarget) this.settleExit(this.contentTarget);\n }\n\n handleContextMenu(event) {\n event.preventDefault();\n this.open();\n }\n\n open() {\n this.openValue = true;\n this.contentTarget.classList.remove(\"hidden\");\n this.contentTarget.dataset.state = \"open\";\n if (this.matchWidthValue) {\n this.contentTarget.style.width = `${this.triggerTarget.offsetWidth}px`;\n }\n this.addEventListeners();\n this.updatePosition();\n }\n\n close() {\n this.hide();\n }\n\n hide() {\n if (!this.openValue) return;\n this.openValue = false;\n this.removeEventListeners();\n this.deselectAll();\n if (this.cleanup) {\n this.cleanup();\n this.cleanup = null;\n }\n\n if (!this.hasContentTarget) return;\n\n this.contentTarget.dataset.state = \"closed\";\n this.hideAfterExitAnimation(this.contentTarget);\n }\n\n afterExit(content) {\n content.classList.add(\"hidden\");\n }\n\n // Overlay exit — the same block in every overlay controller, so keep them in sync.\n exitAnimationNames = new WeakMap();\n\n hideAfterExitAnimation(animated) {\n const exitAnimations = animated\n .getAnimations()\n .filter((animation) => animation instanceof CSSAnimation);\n\n // No exit animation, or no box to run it in: animationend would never fire.\n if (exitAnimations.length === 0) {\n this.settleExit(animated);\n return;\n }\n\n this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName));\n animated.addEventListener(\"animationend\", this.handleExitAnimationEnd);\n animated.addEventListener(\"animationcancel\", this.handleExitAnimationEnd);\n }\n\n handleExitAnimationEnd = (event) => {\n // animationend bubbles — an animated child must not hide its container.\n if (event.target !== event.currentTarget) return;\n // Closing mid-open cancels the enter animation; only the exit run settles this.\n if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return;\n\n this.settleExit(event.currentTarget);\n };\n\n settleExit(animated) {\n animated.removeEventListener(\"animationend\", this.handleExitAnimationEnd);\n animated.removeEventListener(\"animationcancel\", this.handleExitAnimationEnd);\n // Reopened mid-exit: it is on its way back in, leave it visible.\n if (animated.dataset.state !== \"closed\") return;\n\n this.afterExit(animated);\n }\n\n updatePosition() {\n if (this.cleanup) this.cleanup();\n\n this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => {\n computePosition(this.triggerTarget, this.contentTarget, {\n placement: this.optionsValue.placement || \"bottom-start\",\n middleware: [offset(4), flip(), shift({ padding: 8 })],\n }).then(({ x, y, placement }) => {\n Object.assign(this.contentTarget.style, {\n left: `${x}px`,\n top: `${y}px`,\n });\n this.contentTarget.dataset.side = placement.split(\"-\")[0];\n });\n });\n }\n\n addEventListeners() {\n document.addEventListener(\"keydown\", this.boundHandleKeydown);\n document.addEventListener(\"click\", this.handleOutsidePointer);\n // A right-click outside should dismiss this menu and let the native\n // context menu (or another trigger's menu) take over.\n document.addEventListener(\"contextmenu\", this.handleOutsidePointer);\n }\n\n removeEventListeners() {\n document.removeEventListener(\"keydown\", this.boundHandleKeydown);\n document.removeEventListener(\"click\", this.handleOutsidePointer);\n document.removeEventListener(\"contextmenu\", this.handleOutsidePointer);\n }\n\n handleOutsidePointer = (event) => {\n if (!this.element.contains(event.target)) {\n this.hide();\n }\n };\n\n handleKeydown(e) {\n if (e.key === \"Escape\") {\n e.preventDefault();\n this.hide();\n return;\n }\n\n if (this.menuItemTargets.length === 0) return;\n\n if (e.key === \"ArrowDown\") {\n e.preventDefault();\n this.updateSelectedItem(1);\n } else if (e.key === \"ArrowUp\") {\n e.preventDefault();\n this.updateSelectedItem(-1);\n } else if (e.key === \"Enter\" && this.selectedIndex !== -1) {\n e.preventDefault();\n this.menuItemTargets[this.selectedIndex].click();\n }\n }\n\n updateSelectedItem(direction) {\n this.menuItemTargets.forEach((item, index) => {\n if (item.getAttribute(\"aria-selected\") === \"true\") {\n this.selectedIndex = index;\n }\n });\n\n if (this.selectedIndex >= 0) {\n this.toggleAriaSelected(this.menuItemTargets[this.selectedIndex], false);\n }\n\n this.selectedIndex += direction;\n\n if (this.selectedIndex < 0) {\n this.selectedIndex = this.menuItemTargets.length - 1;\n } else if (this.selectedIndex >= this.menuItemTargets.length) {\n this.selectedIndex = 0;\n }\n\n this.toggleAriaSelected(this.menuItemTargets[this.selectedIndex], true);\n }\n\n toggleAriaSelected(element, isSelected) {\n if (isSelected) {\n element.setAttribute(\"aria-selected\", \"true\");\n } else {\n element.removeAttribute(\"aria-selected\");\n }\n }\n\n deselectAll() {\n this.menuItemTargets.forEach((item) =>\n this.toggleAriaSelected(item, false)\n );\n this.selectedIndex = -1;\n }\n}\n" }, { "path": "context_menu_item.rb", @@ -1390,11 +1390,11 @@ }, { "path": "dropdown_menu_content.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DropdownMenuContent < Base\n def view_template(&block)\n div(**wrapper_attrs) do\n div(**attrs, &block)\n end\n end\n\n private\n\n def default_attrs\n {\n data: {\n state: :open\n },\n class: \"z-50 min-w-[8rem] 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 w-56\"\n }\n end\n\n def wrapper_attrs\n {\n class: [\n \"z-50 hidden group-[.is-absolute]/dropdown-menu:absolute\",\n \"group-[.is-fixed]/dropdown-menu:fixed\"\n ],\n data: {ruby_ui__dropdown_menu_target: \"content\"},\n style: {\n width: \"max-content\",\n top: \"0\",\n left: \"0\"\n }\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DropdownMenuContent < Base\n def view_template(&block)\n div(**wrapper_attrs) do\n div(**attrs, &block)\n end\n end\n\n private\n\n def default_attrs\n {\n data: {\n state: :open,\n ruby_ui__dropdown_menu_target: \"panel\"\n },\n class: \"z-50 min-w-[8rem] 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-[state=closed]:fill-mode-forwards 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 w-56\"\n }\n end\n\n def wrapper_attrs\n {\n class: [\n \"z-50 hidden group-[.is-absolute]/dropdown-menu:absolute\",\n \"group-[.is-fixed]/dropdown-menu:fixed\"\n ],\n data: {ruby_ui__dropdown_menu_target: \"content\"},\n style: {\n width: \"max-content\",\n top: \"0\",\n left: \"0\"\n }\n }\n end\n end\nend\n" }, { "path": "dropdown_menu_controller.js", - "content": "import { Controller } from \"@hotwired/stimulus\";\nimport {\n computePosition,\n flip,\n shift,\n offset,\n autoUpdate,\n} from \"@floating-ui/dom\";\n\nexport default class extends Controller {\n static targets = [\"trigger\", \"content\", \"menuItem\"];\n static values = {\n open: {\n type: Boolean,\n default: false,\n },\n options: {\n type: Object,\n default: {},\n },\n };\n\n connect() {\n this.boundHandleKeydown = this.#handleKeydown.bind(this); // Bind the function so we can remove it later\n this.selectedIndex = -1;\n\n this.#setupAutoUpdate();\n }\n\n disconnect() {\n if (this.autoUpdateCleanup) {\n this.autoUpdateCleanup();\n }\n }\n\n #setupAutoUpdate() {\n this.autoUpdateCleanup = autoUpdate(\n this.triggerTarget,\n this.contentTarget,\n this.#computeTooltip.bind(this),\n );\n }\n\n #computeTooltip() {\n computePosition(this.triggerTarget, this.contentTarget, {\n placement: this.optionsValue.placement || \"bottom\",\n middleware: [flip(), shift(), offset(8)],\n strategy: this.optionsValue.strategy || \"absolute\",\n }).then(({ x, y }) => {\n Object.assign(this.contentTarget.style, {\n left: `${x}px`,\n top: `${y}px`,\n });\n });\n }\n\n onClickOutside(event) {\n if (!this.openValue) return;\n if (this.element.contains(event.target)) return;\n\n event.preventDefault();\n this.close();\n }\n\n toggle() {\n this.contentTarget.classList.contains(\"hidden\")\n ? this.#open()\n : this.close();\n }\n\n #open() {\n this.openValue = true;\n this.#deselectAll();\n this.#addEventListeners();\n this.#computeTooltip();\n // Lift the open menu above sibling dropdowns/elements. The container has no\n // static z-index, so closed siblings stack in normal flow and never cover it.\n this.element.style.zIndex = \"50\";\n this.contentTarget.classList.remove(\"hidden\");\n }\n\n close() {\n this.openValue = false;\n this.#removeEventListeners();\n this.element.style.zIndex = \"\";\n this.contentTarget.classList.add(\"hidden\");\n }\n\n #handleKeydown(e) {\n // return if no menu items (one line fix for)\n if (this.menuItemTargets.length === 0) {\n return;\n }\n\n if (e.key === \"ArrowDown\") {\n e.preventDefault();\n this.#updateSelectedItem(1);\n } else if (e.key === \"ArrowUp\") {\n e.preventDefault();\n this.#updateSelectedItem(-1);\n } else if (e.key === \"Enter\" && this.selectedIndex !== -1) {\n e.preventDefault();\n this.menuItemTargets[this.selectedIndex].click();\n }\n }\n\n #updateSelectedItem(direction) {\n // Check if any of the menuItemTargets have aria-selected=\"true\" and set the selectedIndex to that index\n this.menuItemTargets.forEach((item, index) => {\n if (item.getAttribute(\"aria-selected\") === \"true\") {\n this.selectedIndex = index;\n }\n });\n\n if (this.selectedIndex >= 0) {\n this.#toggleAriaSelected(this.menuItemTargets[this.selectedIndex], false);\n }\n\n this.selectedIndex += direction;\n\n if (this.selectedIndex < 0) {\n this.selectedIndex = this.menuItemTargets.length - 1;\n } else if (this.selectedIndex >= this.menuItemTargets.length) {\n this.selectedIndex = 0;\n }\n\n this.#toggleAriaSelected(this.menuItemTargets[this.selectedIndex], true);\n }\n\n #toggleAriaSelected(element, isSelected) {\n // Add or remove attribute\n if (isSelected) {\n element.setAttribute(\"aria-selected\", \"true\");\n } else {\n element.removeAttribute(\"aria-selected\");\n }\n }\n\n #deselectAll() {\n this.menuItemTargets.forEach((item) =>\n this.#toggleAriaSelected(item, false),\n );\n this.selectedIndex = -1;\n }\n\n #addEventListeners() {\n document.addEventListener(\"keydown\", this.boundHandleKeydown);\n }\n\n #removeEventListeners() {\n document.removeEventListener(\"keydown\", this.boundHandleKeydown);\n }\n}\n" + "content": "import { Controller } from \"@hotwired/stimulus\";\nimport {\n computePosition,\n flip,\n shift,\n offset,\n autoUpdate,\n} from \"@floating-ui/dom\";\n\nexport default class extends Controller {\n static targets = [\"trigger\", \"content\", \"panel\", \"menuItem\"];\n static values = {\n open: {\n type: Boolean,\n default: false,\n },\n options: {\n type: Object,\n default: {},\n },\n };\n\n connect() {\n this.boundHandleKeydown = this.#handleKeydown.bind(this); // Bind the function so we can remove it later\n this.selectedIndex = -1;\n\n this.#setupAutoUpdate();\n }\n\n disconnect() {\n if (this.autoUpdateCleanup) {\n this.autoUpdateCleanup();\n }\n // Nothing is left to wait for the exit animation, so apply the pending hide now.\n if (this.hasPanelTarget) this.settleExit(this.panelTarget);\n }\n\n #setupAutoUpdate() {\n this.autoUpdateCleanup = autoUpdate(\n this.triggerTarget,\n this.contentTarget,\n this.#computeTooltip.bind(this),\n );\n }\n\n #computeTooltip() {\n computePosition(this.triggerTarget, this.contentTarget, {\n placement: this.optionsValue.placement || \"bottom\",\n middleware: [flip(), shift(), offset(8)],\n strategy: this.optionsValue.strategy || \"absolute\",\n }).then(({ x, y }) => {\n Object.assign(this.contentTarget.style, {\n left: `${x}px`,\n top: `${y}px`,\n });\n });\n }\n\n onClickOutside(event) {\n if (!this.openValue) return;\n if (this.element.contains(event.target)) return;\n\n event.preventDefault();\n this.close();\n }\n\n toggle() {\n // `hidden` now lands after the exit animation, so it no longer tells the states apart.\n this.openValue ? this.close() : this.#open();\n }\n\n #open() {\n this.openValue = true;\n this.#deselectAll();\n this.#addEventListeners();\n this.#computeTooltip();\n // Lift the open menu above sibling dropdowns/elements. The container has no\n // static z-index, so closed siblings stack in normal flow and never cover it.\n this.element.style.zIndex = \"50\";\n this.contentTarget.classList.remove(\"hidden\");\n this.panelTarget.dataset.state = \"open\";\n }\n\n close() {\n this.openValue = false;\n this.#removeEventListeners();\n this.panelTarget.dataset.state = \"closed\";\n this.hideAfterExitAnimation(this.panelTarget);\n }\n\n afterExit() {\n this.contentTarget.classList.add(\"hidden\");\n // Held at 50 until now, so the menu fades above its siblings rather than behind them.\n this.element.style.zIndex = \"\";\n }\n\n // Overlay exit — the same block in every overlay controller, so keep them in sync.\n exitAnimationNames = new WeakMap();\n\n hideAfterExitAnimation(animated) {\n const exitAnimations = animated\n .getAnimations()\n .filter((animation) => animation instanceof CSSAnimation);\n\n // No exit animation, or no box to run it in: animationend would never fire.\n if (exitAnimations.length === 0) {\n this.settleExit(animated);\n return;\n }\n\n this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName));\n animated.addEventListener(\"animationend\", this.handleExitAnimationEnd);\n animated.addEventListener(\"animationcancel\", this.handleExitAnimationEnd);\n }\n\n handleExitAnimationEnd = (event) => {\n // animationend bubbles — an animated child must not hide its container.\n if (event.target !== event.currentTarget) return;\n // Closing mid-open cancels the enter animation; only the exit run settles this.\n if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return;\n\n this.settleExit(event.currentTarget);\n };\n\n settleExit(animated) {\n animated.removeEventListener(\"animationend\", this.handleExitAnimationEnd);\n animated.removeEventListener(\"animationcancel\", this.handleExitAnimationEnd);\n // Reopened mid-exit: it is on its way back in, leave it visible.\n if (animated.dataset.state !== \"closed\") return;\n\n this.afterExit(animated);\n }\n\n #handleKeydown(e) {\n // return if no menu items (one line fix for)\n if (this.menuItemTargets.length === 0) {\n return;\n }\n\n if (e.key === \"ArrowDown\") {\n e.preventDefault();\n this.#updateSelectedItem(1);\n } else if (e.key === \"ArrowUp\") {\n e.preventDefault();\n this.#updateSelectedItem(-1);\n } else if (e.key === \"Enter\" && this.selectedIndex !== -1) {\n e.preventDefault();\n this.menuItemTargets[this.selectedIndex].click();\n }\n }\n\n #updateSelectedItem(direction) {\n // Check if any of the menuItemTargets have aria-selected=\"true\" and set the selectedIndex to that index\n this.menuItemTargets.forEach((item, index) => {\n if (item.getAttribute(\"aria-selected\") === \"true\") {\n this.selectedIndex = index;\n }\n });\n\n if (this.selectedIndex >= 0) {\n this.#toggleAriaSelected(this.menuItemTargets[this.selectedIndex], false);\n }\n\n this.selectedIndex += direction;\n\n if (this.selectedIndex < 0) {\n this.selectedIndex = this.menuItemTargets.length - 1;\n } else if (this.selectedIndex >= this.menuItemTargets.length) {\n this.selectedIndex = 0;\n }\n\n this.#toggleAriaSelected(this.menuItemTargets[this.selectedIndex], true);\n }\n\n #toggleAriaSelected(element, isSelected) {\n // Add or remove attribute\n if (isSelected) {\n element.setAttribute(\"aria-selected\", \"true\");\n } else {\n element.removeAttribute(\"aria-selected\");\n }\n }\n\n #deselectAll() {\n this.menuItemTargets.forEach((item) =>\n this.#toggleAriaSelected(item, false),\n );\n this.selectedIndex = -1;\n }\n\n #addEventListeners() {\n document.addEventListener(\"keydown\", this.boundHandleKeydown);\n }\n\n #removeEventListeners() {\n document.removeEventListener(\"keydown\", this.boundHandleKeydown);\n }\n}\n" }, { "path": "dropdown_menu_item.rb", @@ -1578,11 +1578,11 @@ }, { "path": "hover_card_content.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class HoverCardContent < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n ruby_ui__hover_card_target: \"content\",\n state: :closed\n },\n 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\"\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class HoverCardContent < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n ruby_ui__hover_card_target: \"content\",\n state: :closed\n },\n 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-[state=closed]:fill-mode-forwards 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\"\n }\n end\n end\nend\n" }, { "path": "hover_card_controller.js", - "content": "import { Controller } from \"@hotwired/stimulus\";\nimport {\n computePosition,\n flip,\n shift,\n offset,\n autoUpdate,\n} from \"@floating-ui/dom\";\n\nexport default class extends Controller {\n static targets = [\"trigger\", \"content\", \"menuItem\"];\n static values = {\n open: { type: Boolean, default: false },\n options: { type: Object, default: {} },\n // make content width match the trigger element (true/false)\n matchWidth: { type: Boolean, default: false },\n };\n\n connect() {\n this.openDelay = this.delayAt(0, 500);\n this.closeDelay = this.delayAt(1, 250);\n this.openTimeout = null;\n this.closeTimeout = null;\n this.cleanup = null;\n this.selectedIndex = -1;\n this.boundHandleKeydown = this.handleKeydown.bind(this);\n this.addEventListeners();\n }\n\n disconnect() {\n this.removeEventListeners();\n this.clearTimers();\n document.removeEventListener(\"keydown\", this.boundHandleKeydown);\n if (this.cleanup) {\n this.cleanup();\n this.cleanup = null;\n }\n }\n\n // Supports the tippy-style `delay` option: a number or a [open, close] tuple.\n delayAt(index, fallback) {\n const delay = this.optionsValue.delay;\n if (Array.isArray(delay)) return delay[index] ?? fallback;\n if (typeof delay === \"number\") return delay;\n return fallback;\n }\n\n addEventListeners() {\n this.triggerTarget.addEventListener(\"mouseenter\", this.handleMouseEnter);\n this.triggerTarget.addEventListener(\"mouseleave\", this.handleMouseLeave);\n this.triggerTarget.addEventListener(\"focusin\", this.handleMouseEnter);\n this.triggerTarget.addEventListener(\"focusout\", this.handleMouseLeave);\n this.contentTarget.addEventListener(\"mouseenter\", this.handleMouseEnter);\n this.contentTarget.addEventListener(\"mouseleave\", this.handleMouseLeave);\n this.contentTarget.addEventListener(\"focusin\", this.handleMouseEnter);\n this.contentTarget.addEventListener(\"focusout\", this.handleMouseLeave);\n }\n\n removeEventListeners() {\n this.triggerTarget.removeEventListener(\"mouseenter\", this.handleMouseEnter);\n this.triggerTarget.removeEventListener(\"mouseleave\", this.handleMouseLeave);\n this.triggerTarget.removeEventListener(\"focusin\", this.handleMouseEnter);\n this.triggerTarget.removeEventListener(\"focusout\", this.handleMouseLeave);\n this.contentTarget.removeEventListener(\"mouseenter\", this.handleMouseEnter);\n this.contentTarget.removeEventListener(\"mouseleave\", this.handleMouseLeave);\n this.contentTarget.removeEventListener(\"focusin\", this.handleMouseEnter);\n this.contentTarget.removeEventListener(\"focusout\", this.handleMouseLeave);\n }\n\n handleMouseEnter = () => {\n this.clearTimers();\n this.openTimeout = setTimeout(() => this.show(), this.openDelay);\n };\n\n handleMouseLeave = () => {\n this.clearTimers();\n this.closeTimeout = setTimeout(() => this.hide(), this.closeDelay);\n };\n\n clearTimers() {\n clearTimeout(this.openTimeout);\n clearTimeout(this.closeTimeout);\n }\n\n show() {\n this.openValue = true;\n this.contentTarget.classList.remove(\"hidden\");\n this.contentTarget.dataset.state = \"open\";\n if (this.matchWidthValue) {\n this.contentTarget.style.width = `${this.triggerTarget.offsetWidth}px`;\n }\n document.addEventListener(\"keydown\", this.boundHandleKeydown);\n this.updatePosition();\n }\n\n hide() {\n this.openValue = false;\n this.contentTarget.classList.add(\"hidden\");\n this.contentTarget.dataset.state = \"closed\";\n document.removeEventListener(\"keydown\", this.boundHandleKeydown);\n this.deselectAll();\n if (this.cleanup) {\n this.cleanup();\n this.cleanup = null;\n }\n }\n\n updatePosition() {\n if (this.cleanup) this.cleanup();\n\n this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => {\n computePosition(this.triggerTarget, this.contentTarget, {\n placement: this.optionsValue.placement || \"bottom\",\n middleware: [offset(4), flip(), shift({ padding: 8 })],\n }).then(({ x, y, placement }) => {\n Object.assign(this.contentTarget.style, {\n left: `${x}px`,\n top: `${y}px`,\n });\n this.contentTarget.dataset.side = placement.split(\"-\")[0];\n });\n });\n }\n\n handleKeydown(e) {\n if (e.key === \"Escape\") {\n this.hide();\n return;\n }\n\n if (this.menuItemTargets.length === 0) return;\n\n if (e.key === \"ArrowDown\") {\n e.preventDefault();\n this.updateSelectedItem(1);\n } else if (e.key === \"ArrowUp\") {\n e.preventDefault();\n this.updateSelectedItem(-1);\n } else if (e.key === \"Enter\" && this.selectedIndex !== -1) {\n e.preventDefault();\n this.menuItemTargets[this.selectedIndex].click();\n }\n }\n\n updateSelectedItem(direction) {\n this.menuItemTargets.forEach((item, index) => {\n if (item.getAttribute(\"aria-selected\") === \"true\") {\n this.selectedIndex = index;\n }\n });\n\n if (this.selectedIndex >= 0) {\n this.toggleAriaSelected(this.menuItemTargets[this.selectedIndex], false);\n }\n\n this.selectedIndex += direction;\n\n if (this.selectedIndex < 0) {\n this.selectedIndex = this.menuItemTargets.length - 1;\n } else if (this.selectedIndex >= this.menuItemTargets.length) {\n this.selectedIndex = 0;\n }\n\n this.toggleAriaSelected(this.menuItemTargets[this.selectedIndex], true);\n }\n\n toggleAriaSelected(element, isSelected) {\n if (isSelected) {\n element.setAttribute(\"aria-selected\", \"true\");\n } else {\n element.removeAttribute(\"aria-selected\");\n }\n }\n\n deselectAll() {\n this.menuItemTargets.forEach((item) =>\n this.toggleAriaSelected(item, false)\n );\n this.selectedIndex = -1;\n }\n}\n" + "content": "import { Controller } from \"@hotwired/stimulus\";\nimport {\n computePosition,\n flip,\n shift,\n offset,\n autoUpdate,\n} from \"@floating-ui/dom\";\n\nexport default class extends Controller {\n static targets = [\"trigger\", \"content\", \"menuItem\"];\n static values = {\n open: { type: Boolean, default: false },\n options: { type: Object, default: {} },\n // make content width match the trigger element (true/false)\n matchWidth: { type: Boolean, default: false },\n };\n\n connect() {\n this.openDelay = this.delayAt(0, 500);\n this.closeDelay = this.delayAt(1, 250);\n this.openTimeout = null;\n this.closeTimeout = null;\n this.cleanup = null;\n this.selectedIndex = -1;\n this.boundHandleKeydown = this.handleKeydown.bind(this);\n this.addEventListeners();\n }\n\n // Teardown that cannot fail comes first: a missing target throws, and Stimulus skips the rest.\n disconnect() {\n this.clearTimers();\n document.removeEventListener(\"keydown\", this.boundHandleKeydown);\n if (this.cleanup) {\n this.cleanup();\n this.cleanup = null;\n }\n this.removeEventListeners();\n // Nothing is left to wait for the exit animation, so apply the pending hide now.\n if (this.hasContentTarget) this.settleExit(this.contentTarget);\n }\n\n // Supports the tippy-style `delay` option: a number or a [open, close] tuple.\n delayAt(index, fallback) {\n const delay = this.optionsValue.delay;\n if (Array.isArray(delay)) return delay[index] ?? fallback;\n if (typeof delay === \"number\") return delay;\n return fallback;\n }\n\n addEventListeners() {\n this.triggerTarget.addEventListener(\"mouseenter\", this.handleMouseEnter);\n this.triggerTarget.addEventListener(\"mouseleave\", this.handleMouseLeave);\n this.triggerTarget.addEventListener(\"focusin\", this.handleMouseEnter);\n this.triggerTarget.addEventListener(\"focusout\", this.handleMouseLeave);\n this.contentTarget.addEventListener(\"mouseenter\", this.handleMouseEnter);\n this.contentTarget.addEventListener(\"mouseleave\", this.handleMouseLeave);\n this.contentTarget.addEventListener(\"focusin\", this.handleMouseEnter);\n this.contentTarget.addEventListener(\"focusout\", this.handleMouseLeave);\n }\n\n removeEventListeners() {\n if (this.hasTriggerTarget) {\n this.triggerTarget.removeEventListener(\"mouseenter\", this.handleMouseEnter);\n this.triggerTarget.removeEventListener(\"mouseleave\", this.handleMouseLeave);\n this.triggerTarget.removeEventListener(\"focusin\", this.handleMouseEnter);\n this.triggerTarget.removeEventListener(\"focusout\", this.handleMouseLeave);\n }\n\n if (this.hasContentTarget) {\n this.contentTarget.removeEventListener(\"mouseenter\", this.handleMouseEnter);\n this.contentTarget.removeEventListener(\"mouseleave\", this.handleMouseLeave);\n this.contentTarget.removeEventListener(\"focusin\", this.handleMouseEnter);\n this.contentTarget.removeEventListener(\"focusout\", this.handleMouseLeave);\n }\n }\n\n handleMouseEnter = () => {\n this.clearTimers();\n this.openTimeout = setTimeout(() => this.show(), this.openDelay);\n };\n\n handleMouseLeave = () => {\n this.clearTimers();\n this.closeTimeout = setTimeout(() => this.hide(), this.closeDelay);\n };\n\n clearTimers() {\n clearTimeout(this.openTimeout);\n clearTimeout(this.closeTimeout);\n }\n\n show() {\n this.openValue = true;\n this.contentTarget.classList.remove(\"hidden\");\n this.contentTarget.dataset.state = \"open\";\n if (this.matchWidthValue) {\n this.contentTarget.style.width = `${this.triggerTarget.offsetWidth}px`;\n }\n document.addEventListener(\"keydown\", this.boundHandleKeydown);\n this.updatePosition();\n }\n\n hide() {\n this.openValue = false;\n document.removeEventListener(\"keydown\", this.boundHandleKeydown);\n this.deselectAll();\n if (this.cleanup) {\n this.cleanup();\n this.cleanup = null;\n }\n\n if (!this.hasContentTarget) return;\n\n this.contentTarget.dataset.state = \"closed\";\n this.hideAfterExitAnimation(this.contentTarget);\n }\n\n afterExit(content) {\n content.classList.add(\"hidden\");\n }\n\n // Overlay exit — the same block in every overlay controller, so keep them in sync.\n exitAnimationNames = new WeakMap();\n\n hideAfterExitAnimation(animated) {\n const exitAnimations = animated\n .getAnimations()\n .filter((animation) => animation instanceof CSSAnimation);\n\n // No exit animation, or no box to run it in: animationend would never fire.\n if (exitAnimations.length === 0) {\n this.settleExit(animated);\n return;\n }\n\n this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName));\n animated.addEventListener(\"animationend\", this.handleExitAnimationEnd);\n animated.addEventListener(\"animationcancel\", this.handleExitAnimationEnd);\n }\n\n handleExitAnimationEnd = (event) => {\n // animationend bubbles — an animated child must not hide its container.\n if (event.target !== event.currentTarget) return;\n // Closing mid-open cancels the enter animation; only the exit run settles this.\n if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return;\n\n this.settleExit(event.currentTarget);\n };\n\n settleExit(animated) {\n animated.removeEventListener(\"animationend\", this.handleExitAnimationEnd);\n animated.removeEventListener(\"animationcancel\", this.handleExitAnimationEnd);\n // Reopened mid-exit: it is on its way back in, leave it visible.\n if (animated.dataset.state !== \"closed\") return;\n\n this.afterExit(animated);\n }\n\n updatePosition() {\n if (this.cleanup) this.cleanup();\n\n this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => {\n computePosition(this.triggerTarget, this.contentTarget, {\n placement: this.optionsValue.placement || \"bottom\",\n middleware: [offset(4), flip(), shift({ padding: 8 })],\n }).then(({ x, y, placement }) => {\n Object.assign(this.contentTarget.style, {\n left: `${x}px`,\n top: `${y}px`,\n });\n this.contentTarget.dataset.side = placement.split(\"-\")[0];\n });\n });\n }\n\n handleKeydown(e) {\n if (e.key === \"Escape\") {\n this.hide();\n return;\n }\n\n if (this.menuItemTargets.length === 0) return;\n\n if (e.key === \"ArrowDown\") {\n e.preventDefault();\n this.updateSelectedItem(1);\n } else if (e.key === \"ArrowUp\") {\n e.preventDefault();\n this.updateSelectedItem(-1);\n } else if (e.key === \"Enter\" && this.selectedIndex !== -1) {\n e.preventDefault();\n this.menuItemTargets[this.selectedIndex].click();\n }\n }\n\n updateSelectedItem(direction) {\n this.menuItemTargets.forEach((item, index) => {\n if (item.getAttribute(\"aria-selected\") === \"true\") {\n this.selectedIndex = index;\n }\n });\n\n if (this.selectedIndex >= 0) {\n this.toggleAriaSelected(this.menuItemTargets[this.selectedIndex], false);\n }\n\n this.selectedIndex += direction;\n\n if (this.selectedIndex < 0) {\n this.selectedIndex = this.menuItemTargets.length - 1;\n } else if (this.selectedIndex >= this.menuItemTargets.length) {\n this.selectedIndex = 0;\n }\n\n this.toggleAriaSelected(this.menuItemTargets[this.selectedIndex], true);\n }\n\n toggleAriaSelected(element, isSelected) {\n if (isSelected) {\n element.setAttribute(\"aria-selected\", \"true\");\n } else {\n element.removeAttribute(\"aria-selected\");\n }\n }\n\n deselectAll() {\n this.menuItemTargets.forEach((item) =>\n this.toggleAriaSelected(item, false)\n );\n this.selectedIndex = -1;\n }\n}\n" }, { "path": "hover_card_trigger.rb", @@ -2053,11 +2053,11 @@ }, { "path": "popover_content.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class PopoverContent < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n ruby_ui__popover_target: \"content\",\n state: :closed\n },\n class: [\n \"hidden z-50 rounded-md border bg-background p-1 text-foreground shadow-md outline-none\",\n \"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0\",\n \"data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95\",\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 \"absolute\"\n ]\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class PopoverContent < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {\n ruby_ui__popover_target: \"content\",\n state: :closed\n },\n class: [\n \"hidden z-50 rounded-md border bg-background p-1 text-foreground shadow-md outline-none\",\n \"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0\",\n \"data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95\",\n \"data-[state=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 \"absolute\"\n ]\n }\n end\n end\nend\n" }, { "path": "popover_controller.js", - "content": "import { Controller } from \"@hotwired/stimulus\";\nimport {\n computePosition,\n flip,\n shift,\n offset,\n autoUpdate,\n} from \"@floating-ui/dom\";\n\nexport default class extends Controller {\n static targets = [\"trigger\", \"content\"];\n static values = {\n open: { type: Boolean, default: false },\n options: { type: Object, default: {} },\n trigger: { type: String, default: \"hover\" },\n };\n\n connect() {\n this.closeTimeout = null;\n this.cleanup = null;\n this.addEventListeners();\n // openValue lives in the DOM, so a reconnect (frame swap, morph, moved element)\n // arrives already open. Re-arm the parts that live on the controller instead of\n // the markup — the keydown listener and the autoUpdate positioning.\n if (this.openValue) this.showPopover();\n }\n\n // Teardown that cannot fail comes first: resolving a target throws once the\n // element is gone, and Stimulus swallows that, skipping the rest of disconnect.\n disconnect() {\n clearTimeout(this.closeTimeout);\n document.removeEventListener(\"keydown\", this.handleKeydown);\n document.removeEventListener(\"click\", this.handleOutsideClick);\n this.stopAutoUpdate();\n this.removeElementEventListeners();\n }\n\n addEventListeners() {\n if (this.triggerValue === \"hover\") {\n this.triggerTarget.addEventListener(\"mouseenter\", this.handleMouseEnter);\n this.triggerTarget.addEventListener(\"mouseleave\", this.handleMouseLeave);\n this.contentTarget.addEventListener(\"mouseenter\", this.handleMouseEnter);\n this.contentTarget.addEventListener(\"mouseleave\", this.handleMouseLeave);\n } else if (this.triggerValue === \"click\") {\n this.triggerTarget.addEventListener(\"click\", this.handleClick);\n document.addEventListener(\"click\", this.handleOutsideClick);\n }\n }\n\n // Each target is guarded on its own: losing one of them must not strand the\n // listeners attached to the other.\n removeElementEventListeners() {\n if (this.hasTriggerTarget) {\n this.triggerTarget.removeEventListener(\"mouseenter\", this.handleMouseEnter);\n this.triggerTarget.removeEventListener(\"mouseleave\", this.handleMouseLeave);\n this.triggerTarget.removeEventListener(\"click\", this.handleClick);\n }\n\n if (this.hasContentTarget) {\n this.contentTarget.removeEventListener(\"mouseenter\", this.handleMouseEnter);\n this.contentTarget.removeEventListener(\"mouseleave\", this.handleMouseLeave);\n }\n }\n\n handleMouseEnter = () => {\n clearTimeout(this.closeTimeout);\n this.showPopover();\n };\n\n handleMouseLeave = () => {\n this.closeTimeout = setTimeout(() => this.hidePopover(), 100);\n };\n\n handleClick = (event) => {\n event.stopPropagation();\n this.openValue ? this.hidePopover() : this.showPopover();\n };\n\n handleOutsideClick = (event) => {\n if (this.element.contains(event.target)) return;\n if (!this.openValue) return;\n\n this.hidePopover();\n };\n\n handleKeydown = (event) => {\n if (event.key !== \"Escape\") return;\n if (!this.openValue) return;\n\n clearTimeout(this.closeTimeout);\n this.hidePopover();\n };\n\n // openValue is set here rather than by the callers, so a guarded early return can\n // never leave the DOM claiming the popover is open while nothing is wired up.\n showPopover() {\n if (!this.hasTriggerTarget || !this.hasContentTarget) return;\n\n this.openValue = true;\n this.contentTarget.classList.remove(\"hidden\");\n this.contentTarget.dataset.state = \"open\";\n document.addEventListener(\"keydown\", this.handleKeydown);\n this.updatePosition();\n }\n\n // Same rule as disconnect(): release what is held outside the element first, so a\n // missing content target cannot leave the keydown listener or autoUpdate running.\n hidePopover() {\n this.openValue = false;\n document.removeEventListener(\"keydown\", this.handleKeydown);\n this.stopAutoUpdate();\n\n if (!this.hasContentTarget) return;\n\n this.contentTarget.classList.add(\"hidden\");\n this.contentTarget.dataset.state = \"closed\";\n }\n\n updatePosition() {\n this.stopAutoUpdate();\n\n // Hold the exact pair this run positions. A target can be detached or swapped\n // while the controller stays connected, and the stale element must not be\n // written to by an observer callback or an in-flight computePosition.\n const trigger = this.triggerTarget;\n const content = this.contentTarget;\n\n // Deferred teardown is bound to this run's own handle, so a newer positioning\n // run installed before the microtask drains is never torn down by an older one.\n let stop = null;\n const releaseThisRun = () => {\n stop?.();\n if (this.cleanup === stop) this.cleanup = null;\n };\n\n stop = autoUpdate(trigger, content, () => {\n if (!trigger.isConnected || !content.isConnected) {\n // Release the observers instead of throwing on every scroll and resize.\n // Deferred because autoUpdate runs this once synchronously, before the\n // handle below has been assigned.\n queueMicrotask(releaseThisRun);\n return;\n }\n\n computePosition(trigger, content, {\n placement: this.optionsValue.placement || \"bottom\",\n middleware: [flip(), shift(), offset(8)],\n }).then(({ x, y, placement }) => {\n if (!content.isConnected) return;\n\n Object.assign(content.style, {\n left: `${x}px`,\n top: `${y}px`,\n });\n // flip() can resolve to the opposite side of the requested placement,\n // so the directional slide-in classes must follow the resolved value.\n content.dataset.side = placement.split(\"-\")[0];\n });\n });\n\n this.cleanup = stop;\n }\n\n stopAutoUpdate() {\n if (!this.cleanup) return;\n\n this.cleanup();\n this.cleanup = null;\n }\n}\n" + "content": "import { Controller } from \"@hotwired/stimulus\";\nimport {\n computePosition,\n flip,\n shift,\n offset,\n autoUpdate,\n} from \"@floating-ui/dom\";\n\nexport default class extends Controller {\n static targets = [\"trigger\", \"content\"];\n static values = {\n open: { type: Boolean, default: false },\n options: { type: Object, default: {} },\n trigger: { type: String, default: \"hover\" },\n };\n\n connect() {\n this.closeTimeout = null;\n this.cleanup = null;\n this.addEventListeners();\n // openValue lives in the DOM, so a reconnect (frame swap, morph, moved element)\n // arrives already open. Re-arm the parts that live on the controller instead of\n // the markup — the keydown listener and the autoUpdate positioning.\n if (this.openValue) this.showPopover();\n }\n\n // Teardown that cannot fail comes first: resolving a target throws once the\n // element is gone, and Stimulus swallows that, skipping the rest of disconnect.\n disconnect() {\n clearTimeout(this.closeTimeout);\n document.removeEventListener(\"keydown\", this.handleKeydown);\n document.removeEventListener(\"click\", this.handleOutsideClick);\n this.stopAutoUpdate();\n this.removeElementEventListeners();\n // Nothing is left to wait for the exit animation, so apply the pending hide now.\n if (this.hasContentTarget) this.settleExit(this.contentTarget);\n }\n\n addEventListeners() {\n if (this.triggerValue === \"hover\") {\n this.triggerTarget.addEventListener(\"mouseenter\", this.handleMouseEnter);\n this.triggerTarget.addEventListener(\"mouseleave\", this.handleMouseLeave);\n this.contentTarget.addEventListener(\"mouseenter\", this.handleMouseEnter);\n this.contentTarget.addEventListener(\"mouseleave\", this.handleMouseLeave);\n } else if (this.triggerValue === \"click\") {\n this.triggerTarget.addEventListener(\"click\", this.handleClick);\n document.addEventListener(\"click\", this.handleOutsideClick);\n }\n }\n\n // Each target is guarded on its own: losing one of them must not strand the\n // listeners attached to the other.\n removeElementEventListeners() {\n if (this.hasTriggerTarget) {\n this.triggerTarget.removeEventListener(\"mouseenter\", this.handleMouseEnter);\n this.triggerTarget.removeEventListener(\"mouseleave\", this.handleMouseLeave);\n this.triggerTarget.removeEventListener(\"click\", this.handleClick);\n }\n\n if (this.hasContentTarget) {\n this.contentTarget.removeEventListener(\"mouseenter\", this.handleMouseEnter);\n this.contentTarget.removeEventListener(\"mouseleave\", this.handleMouseLeave);\n }\n }\n\n handleMouseEnter = () => {\n clearTimeout(this.closeTimeout);\n this.showPopover();\n };\n\n handleMouseLeave = () => {\n this.closeTimeout = setTimeout(() => this.hidePopover(), 100);\n };\n\n handleClick = (event) => {\n event.stopPropagation();\n this.openValue ? this.hidePopover() : this.showPopover();\n };\n\n handleOutsideClick = (event) => {\n if (this.element.contains(event.target)) return;\n if (!this.openValue) return;\n\n this.hidePopover();\n };\n\n handleKeydown = (event) => {\n if (event.key !== \"Escape\") return;\n if (!this.openValue) return;\n\n clearTimeout(this.closeTimeout);\n this.hidePopover();\n };\n\n // openValue is set here rather than by the callers, so a guarded early return can\n // never leave the DOM claiming the popover is open while nothing is wired up.\n showPopover() {\n if (!this.hasTriggerTarget || !this.hasContentTarget) return;\n\n this.openValue = true;\n this.contentTarget.classList.remove(\"hidden\");\n this.contentTarget.dataset.state = \"open\";\n document.addEventListener(\"keydown\", this.handleKeydown);\n this.updatePosition();\n }\n\n // Same rule as disconnect(): release what is held outside the element first, so a\n // missing content target cannot leave the keydown listener or autoUpdate running.\n hidePopover() {\n this.openValue = false;\n document.removeEventListener(\"keydown\", this.handleKeydown);\n this.stopAutoUpdate();\n\n if (!this.hasContentTarget) return;\n\n this.contentTarget.dataset.state = \"closed\";\n this.hideAfterExitAnimation(this.contentTarget);\n }\n\n afterExit(content) {\n content.classList.add(\"hidden\");\n }\n\n // Overlay exit — the same block in every overlay controller, so keep them in sync.\n exitAnimationNames = new WeakMap();\n\n hideAfterExitAnimation(animated) {\n const exitAnimations = animated\n .getAnimations()\n .filter((animation) => animation instanceof CSSAnimation);\n\n // No exit animation, or no box to run it in: animationend would never fire.\n if (exitAnimations.length === 0) {\n this.settleExit(animated);\n return;\n }\n\n this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName));\n animated.addEventListener(\"animationend\", this.handleExitAnimationEnd);\n animated.addEventListener(\"animationcancel\", this.handleExitAnimationEnd);\n }\n\n handleExitAnimationEnd = (event) => {\n // animationend bubbles — an animated child must not hide its container.\n if (event.target !== event.currentTarget) return;\n // Closing mid-open cancels the enter animation; only the exit run settles this.\n if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return;\n\n this.settleExit(event.currentTarget);\n };\n\n settleExit(animated) {\n animated.removeEventListener(\"animationend\", this.handleExitAnimationEnd);\n animated.removeEventListener(\"animationcancel\", this.handleExitAnimationEnd);\n // Reopened mid-exit: it is on its way back in, leave it visible.\n if (animated.dataset.state !== \"closed\") return;\n\n this.afterExit(animated);\n }\n\n updatePosition() {\n this.stopAutoUpdate();\n\n // Hold the exact pair this run positions. A target can be detached or swapped\n // while the controller stays connected, and the stale element must not be\n // written to by an observer callback or an in-flight computePosition.\n const trigger = this.triggerTarget;\n const content = this.contentTarget;\n\n // Deferred teardown is bound to this run's own handle, so a newer positioning\n // run installed before the microtask drains is never torn down by an older one.\n let stop = null;\n const releaseThisRun = () => {\n stop?.();\n if (this.cleanup === stop) this.cleanup = null;\n };\n\n stop = autoUpdate(trigger, content, () => {\n if (!trigger.isConnected || !content.isConnected) {\n // Release the observers instead of throwing on every scroll and resize.\n // Deferred because autoUpdate runs this once synchronously, before the\n // handle below has been assigned.\n queueMicrotask(releaseThisRun);\n return;\n }\n\n computePosition(trigger, content, {\n placement: this.optionsValue.placement || \"bottom\",\n middleware: [flip(), shift(), offset(8)],\n }).then(({ x, y, placement }) => {\n if (!content.isConnected) return;\n\n Object.assign(content.style, {\n left: `${x}px`,\n top: `${y}px`,\n });\n // flip() can resolve to the opposite side of the requested placement,\n // so the directional slide-in classes must follow the resolved value.\n content.dataset.side = placement.split(\"-\")[0];\n });\n });\n\n this.cleanup = stop;\n }\n\n stopAutoUpdate() {\n if (!this.cleanup) return;\n\n this.cleanup();\n this.cleanup = null;\n }\n}\n" }, { "path": "popover_trigger.rb", @@ -2169,11 +2169,11 @@ }, { "path": "select_content.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SelectContent < Base\n def initialize(**attrs)\n @id = \"content#{SecureRandom.hex(4)}\"\n super\n end\n\n def view_template(&block)\n div(**attrs) do\n div(\n class: \"max-h-96 w-full text-wrap overflow-auto rounded-md border bg-background p-1 text-foreground shadow-md animate-out group-data-[ruby-ui--select-open-value=true]/select:animate-in fade-out-0 group-data-[ruby-ui--select-open-value=true]/select:fade-in-0 zoom-out-95 group-data-[ruby-ui--select-open-value=true]/select:zoom-in-95 slide-in-from-top-2\", &block\n )\n end\n end\n\n private\n\n def default_attrs\n {\n id: @id,\n role: \"listbox\",\n tabindex: \"-1\",\n data: {\n ruby_ui__select_target: \"content\"\n },\n class: \"hidden w-full absolute top-0 left-0 z-50\"\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SelectContent < Base\n def initialize(**attrs)\n @id = \"content#{SecureRandom.hex(4)}\"\n super\n end\n\n def view_template(&block)\n div(**attrs) do\n div(\n class: \"max-h-96 w-full text-wrap overflow-auto 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-[state=closed]:fill-mode-forwards slide-in-from-top-2\",\n data: {state: :closed, ruby_ui__select_target: \"panel\"},\n &block\n )\n end\n end\n\n private\n\n def default_attrs\n {\n id: @id,\n role: \"listbox\",\n tabindex: \"-1\",\n data: {\n ruby_ui__select_target: \"content\"\n },\n class: \"hidden w-full absolute top-0 left-0 z-50\"\n }\n end\n end\nend\n" }, { "path": "select_controller.js", - "content": "import { Controller } from \"@hotwired/stimulus\";\nimport { computePosition, autoUpdate, offset, flip } from \"@floating-ui/dom\";\n\nexport default class extends Controller {\n static targets = [\"trigger\", \"content\", \"input\", \"value\", \"item\"];\n static values = { open: Boolean };\n static outlets = [\"ruby-ui--select-item\"];\n\n constructor(...args) {\n super(...args);\n this.cleanup;\n }\n\n connect() {\n this.setFloatingElement();\n this.generateItemsIds();\n }\n\n disconnect() {\n this.cleanup();\n }\n\n selectItem(event) {\n event.preventDefault();\n\n this.rubyUiSelectItemOutlets.forEach((item) =>\n item.handleSelectItem(event),\n );\n\n const oldValue = this.inputTarget.value;\n const newValue = event.target.dataset.value;\n\n this.inputTarget.value = newValue;\n this.valueTarget.innerText = event.target.innerText;\n\n this.dispatchOnChange(oldValue, newValue);\n this.closeContent();\n }\n\n onClick() {\n this.toogleContent();\n\n if (this.openValue) {\n this.setFocusAndCurrent();\n } else {\n this.resetCurrent();\n }\n }\n\n handleKeyDown(event) {\n event.preventDefault();\n\n const currentIndex = this.itemTargets.findIndex(\n (item) => item.getAttribute(\"aria-current\") === \"true\",\n );\n\n if (currentIndex + 1 < this.itemTargets.length) {\n this.itemTargets[currentIndex].removeAttribute(\"aria-current\");\n this.setAriaCurrentAndActiveDescendant(currentIndex + 1);\n }\n }\n\n handleKeyUp(event) {\n event.preventDefault();\n\n const currentIndex = this.itemTargets.findIndex(\n (item) => item.getAttribute(\"aria-current\") === \"true\",\n );\n\n if (currentIndex > 0) {\n this.itemTargets[currentIndex].removeAttribute(\"aria-current\");\n this.setAriaCurrentAndActiveDescendant(currentIndex - 1);\n }\n }\n\n handleEsc(event) {\n event.preventDefault();\n this.closeContent();\n }\n\n setFocusAndCurrent() {\n const selectedItem = this.itemTargets.find(\n (item) => item.getAttribute(\"aria-selected\") === \"true\",\n );\n\n if (selectedItem) {\n selectedItem.focus({ preventScroll: true });\n selectedItem.setAttribute(\"aria-current\", \"true\");\n this.triggerTarget.setAttribute(\n \"aria-activedescendant\",\n selectedItem.getAttribute(\"id\"),\n );\n } else {\n this.itemTarget.focus({ preventScroll: true });\n this.itemTarget.setAttribute(\"aria-current\", \"true\");\n this.triggerTarget.setAttribute(\n \"aria-activedescendant\",\n this.itemTarget.getAttribute(\"id\"),\n );\n }\n }\n\n resetCurrent() {\n this.itemTargets.forEach((item) => item.removeAttribute(\"aria-current\"));\n }\n\n clickOutside(event) {\n if (!this.openValue) return;\n if (this.element.contains(event.target)) return;\n\n event.preventDefault();\n this.toogleContent();\n }\n\n toogleContent() {\n this.openValue = !this.openValue;\n this.contentTarget.classList.toggle(\"hidden\");\n this.triggerTarget.setAttribute(\"aria-expanded\", this.openValue);\n }\n\n setFloatingElement() {\n this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => {\n computePosition(this.triggerTarget, this.contentTarget, {\n middleware: [offset(4), flip()],\n }).then(({ x, y }) => {\n Object.assign(this.contentTarget.style, {\n left: `${x}px`,\n top: `${y}px`,\n });\n });\n });\n }\n\n generateItemsIds() {\n const contentId = this.contentTarget.getAttribute(\"id\");\n this.triggerTarget.setAttribute(\"aria-controls\", contentId);\n\n this.itemTargets.forEach((item, index) => {\n item.id = `${contentId}-${index}`;\n });\n }\n\n setAriaCurrentAndActiveDescendant(currentIndex) {\n const currentItem = this.itemTargets[currentIndex];\n currentItem.focus({ preventScroll: true });\n currentItem.setAttribute(\"aria-current\", \"true\");\n this.triggerTarget.setAttribute(\n \"aria-activedescendant\",\n currentItem.getAttribute(\"id\"),\n );\n }\n\n closeContent() {\n this.toogleContent();\n this.resetCurrent();\n\n this.triggerTarget.setAttribute(\"aria-activedescendant\", true);\n this.triggerTarget.focus({ preventScroll: true });\n }\n\n dispatchOnChange(oldValue, newValue) {\n if (oldValue === newValue) return;\n\n const event = new InputEvent(\"change\", {\n bubbles: true,\n cancelable: true,\n });\n\n this.inputTarget.dispatchEvent(event);\n }\n}\n" + "content": "import { Controller } from \"@hotwired/stimulus\";\nimport { computePosition, autoUpdate, offset, flip } from \"@floating-ui/dom\";\n\nexport default class extends Controller {\n static targets = [\"trigger\", \"content\", \"panel\", \"input\", \"value\", \"item\"];\n static values = { open: Boolean };\n static outlets = [\"ruby-ui--select-item\"];\n\n constructor(...args) {\n super(...args);\n this.cleanup;\n }\n\n connect() {\n this.setFloatingElement();\n this.generateItemsIds();\n }\n\n disconnect() {\n // Nothing is left to wait for the exit animation, so apply the pending hide now.\n if (this.hasPanelTarget) this.settleExit(this.panelTarget);\n this.cleanup();\n }\n\n selectItem(event) {\n event.preventDefault();\n\n this.rubyUiSelectItemOutlets.forEach((item) =>\n item.handleSelectItem(event),\n );\n\n const oldValue = this.inputTarget.value;\n const newValue = event.target.dataset.value;\n\n this.inputTarget.value = newValue;\n this.valueTarget.innerText = event.target.innerText;\n\n this.dispatchOnChange(oldValue, newValue);\n this.closeContent();\n }\n\n onClick() {\n this.toogleContent();\n\n if (this.openValue) {\n this.setFocusAndCurrent();\n } else {\n this.resetCurrent();\n }\n }\n\n handleKeyDown(event) {\n event.preventDefault();\n\n const currentIndex = this.itemTargets.findIndex(\n (item) => item.getAttribute(\"aria-current\") === \"true\",\n );\n\n if (currentIndex + 1 < this.itemTargets.length) {\n this.itemTargets[currentIndex].removeAttribute(\"aria-current\");\n this.setAriaCurrentAndActiveDescendant(currentIndex + 1);\n }\n }\n\n handleKeyUp(event) {\n event.preventDefault();\n\n const currentIndex = this.itemTargets.findIndex(\n (item) => item.getAttribute(\"aria-current\") === \"true\",\n );\n\n if (currentIndex > 0) {\n this.itemTargets[currentIndex].removeAttribute(\"aria-current\");\n this.setAriaCurrentAndActiveDescendant(currentIndex - 1);\n }\n }\n\n handleEsc(event) {\n event.preventDefault();\n this.closeContent();\n }\n\n setFocusAndCurrent() {\n const selectedItem = this.itemTargets.find(\n (item) => item.getAttribute(\"aria-selected\") === \"true\",\n );\n\n if (selectedItem) {\n selectedItem.focus({ preventScroll: true });\n selectedItem.setAttribute(\"aria-current\", \"true\");\n this.triggerTarget.setAttribute(\n \"aria-activedescendant\",\n selectedItem.getAttribute(\"id\"),\n );\n } else {\n this.itemTarget.focus({ preventScroll: true });\n this.itemTarget.setAttribute(\"aria-current\", \"true\");\n this.triggerTarget.setAttribute(\n \"aria-activedescendant\",\n this.itemTarget.getAttribute(\"id\"),\n );\n }\n }\n\n resetCurrent() {\n this.itemTargets.forEach((item) => item.removeAttribute(\"aria-current\"));\n }\n\n clickOutside(event) {\n if (!this.openValue) return;\n if (this.element.contains(event.target)) return;\n\n event.preventDefault();\n this.toogleContent();\n }\n\n toogleContent() {\n this.openValue = !this.openValue;\n this.triggerTarget.setAttribute(\"aria-expanded\", this.openValue);\n\n if (this.openValue) {\n this.contentTarget.classList.remove(\"hidden\");\n this.panelTarget.dataset.state = \"open\";\n return;\n }\n\n this.panelTarget.dataset.state = \"closed\";\n this.hideAfterExitAnimation(this.panelTarget);\n }\n\n afterExit() {\n this.contentTarget.classList.add(\"hidden\");\n }\n\n // Overlay exit — the same block in every overlay controller, so keep them in sync.\n exitAnimationNames = new WeakMap();\n\n hideAfterExitAnimation(animated) {\n const exitAnimations = animated\n .getAnimations()\n .filter((animation) => animation instanceof CSSAnimation);\n\n // No exit animation, or no box to run it in: animationend would never fire.\n if (exitAnimations.length === 0) {\n this.settleExit(animated);\n return;\n }\n\n this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName));\n animated.addEventListener(\"animationend\", this.handleExitAnimationEnd);\n animated.addEventListener(\"animationcancel\", this.handleExitAnimationEnd);\n }\n\n handleExitAnimationEnd = (event) => {\n // animationend bubbles — an animated child must not hide its container.\n if (event.target !== event.currentTarget) return;\n // Closing mid-open cancels the enter animation; only the exit run settles this.\n if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return;\n\n this.settleExit(event.currentTarget);\n };\n\n settleExit(animated) {\n animated.removeEventListener(\"animationend\", this.handleExitAnimationEnd);\n animated.removeEventListener(\"animationcancel\", this.handleExitAnimationEnd);\n // Reopened mid-exit: it is on its way back in, leave it visible.\n if (animated.dataset.state !== \"closed\") return;\n\n this.afterExit(animated);\n }\n\n setFloatingElement() {\n this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, () => {\n computePosition(this.triggerTarget, this.contentTarget, {\n middleware: [offset(4), flip()],\n }).then(({ x, y }) => {\n Object.assign(this.contentTarget.style, {\n left: `${x}px`,\n top: `${y}px`,\n });\n });\n });\n }\n\n generateItemsIds() {\n const contentId = this.contentTarget.getAttribute(\"id\");\n this.triggerTarget.setAttribute(\"aria-controls\", contentId);\n\n this.itemTargets.forEach((item, index) => {\n item.id = `${contentId}-${index}`;\n });\n }\n\n setAriaCurrentAndActiveDescendant(currentIndex) {\n const currentItem = this.itemTargets[currentIndex];\n currentItem.focus({ preventScroll: true });\n currentItem.setAttribute(\"aria-current\", \"true\");\n this.triggerTarget.setAttribute(\n \"aria-activedescendant\",\n currentItem.getAttribute(\"id\"),\n );\n }\n\n closeContent() {\n this.toogleContent();\n this.resetCurrent();\n\n this.triggerTarget.setAttribute(\"aria-activedescendant\", true);\n this.triggerTarget.focus({ preventScroll: true });\n }\n\n dispatchOnChange(oldValue, newValue) {\n if (oldValue === newValue) return;\n\n const event = new InputEvent(\"change\", {\n bubbles: true,\n cancelable: true,\n });\n\n this.inputTarget.dispatchEvent(event);\n }\n}\n" }, { "path": "select_group.rb", @@ -2280,11 +2280,11 @@ }, { "path": "sheet_content.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SheetContent < Base\n SIDE_CLASS = {\n top: \"inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top\",\n right: \"inset-y-0 right-0 h-full border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right\",\n bottom: \"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom\",\n left: \"inset-y-0 left-0 h-full border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left\"\n }\n\n def initialize(side: :right, **attrs)\n @side = side\n @side_classes = SIDE_CLASS[side]\n super(**attrs)\n end\n\n def view_template(&block)\n template(data: {ruby_ui__sheet_target: \"content\"}) do\n div(data: {controller: \"ruby-ui--sheet-content\"}) do\n backdrop\n div(**attrs) do\n block&.call\n close_button\n end\n end\n end\n end\n\n private\n\n def default_attrs\n {\n data_state: \"open\", # For animate in\n class: [\n \"fixed pointer-events-auto z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500 overflow-scroll\",\n @side_classes\n ]\n }\n end\n\n def close_button\n button(\n type: \"button\",\n class: \"absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground\",\n data_action: \"click->ruby-ui--sheet-content#close\"\n ) do\n svg(\n width: \"15\",\n height: \"15\",\n viewbox: \"0 0 15 15\",\n fill: \"none\",\n xmlns: \"http://www.w3.org/2000/svg\",\n class: \"h-4 w-4\"\n ) do |s|\n s.path(\n d:\n \"M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z\",\n fill: \"currentColor\",\n fill_rule: \"evenodd\",\n clip_rule: \"evenodd\"\n )\n end\n span(class: \"sr-only\") { \"Close\" }\n end\n end\n\n def backdrop\n div(\n data_state: \"open\",\n data_action: \"click->ruby-ui--sheet-content#close\",\n class:\n \"fixed pointer-events-auto inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0\"\n )\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class SheetContent < Base\n SIDE_CLASS = {\n top: \"inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top\",\n right: \"inset-y-0 right-0 h-full border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right\",\n bottom: \"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom\",\n left: \"inset-y-0 left-0 h-full border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left\"\n }\n\n def initialize(side: :right, **attrs)\n @side = side\n @side_classes = SIDE_CLASS[side]\n super(**attrs)\n end\n\n def view_template(&block)\n template(data: {ruby_ui__sheet_target: \"content\"}) do\n div(data: {controller: \"ruby-ui--sheet-content\"}) do\n backdrop\n div(**attrs) do\n block&.call\n close_button\n end\n end\n end\n end\n\n private\n\n def default_attrs\n {\n data_state: \"open\", # For animate in\n data_ruby_ui__sheet_content_target: \"panel\",\n class: [\n \"fixed pointer-events-auto z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fill-mode-forwards data-[state=closed]:duration-300 data-[state=open]:duration-500 overflow-scroll\",\n @side_classes\n ]\n }\n end\n\n def close_button\n button(\n type: \"button\",\n class: \"absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground\",\n data_action: \"click->ruby-ui--sheet-content#close\"\n ) do\n svg(\n width: \"15\",\n height: \"15\",\n viewbox: \"0 0 15 15\",\n fill: \"none\",\n xmlns: \"http://www.w3.org/2000/svg\",\n class: \"h-4 w-4\"\n ) do |s|\n s.path(\n d:\n \"M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z\",\n fill: \"currentColor\",\n fill_rule: \"evenodd\",\n clip_rule: \"evenodd\"\n )\n end\n span(class: \"sr-only\") { \"Close\" }\n end\n end\n\n def backdrop\n div(\n data_state: \"open\",\n data_action: \"click->ruby-ui--sheet-content#close\",\n data_ruby_ui__sheet_content_target: \"backdrop\",\n class:\n \"fixed pointer-events-auto inset-0 z-50 bg-background/80 backdrop-blur-sm 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]:fill-mode-forwards\"\n )\n end\n end\nend\n" }, { "path": "sheet_content_controller.js", - "content": "import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n close() {\n this.element.remove()\n }\n}\n" + "content": "import { Controller } from \"@hotwired/stimulus\";\n\nexport default class extends Controller {\n static targets = [\"backdrop\", \"panel\"];\n\n disconnect() {\n // Nothing is left to wait for the exit animation, so apply the pending removal now.\n if (this.hasPanelTarget) this.settleExit(this.panelTarget);\n }\n\n close() {\n this.backdropTarget.dataset.state = \"closed\";\n this.panelTarget.dataset.state = \"closed\";\n // The panel carries the longer exit, so the backdrop has finished by the time it settles.\n this.hideAfterExitAnimation(this.panelTarget);\n }\n\n afterExit() {\n this.element.remove();\n }\n\n // Overlay exit — the same block in every overlay controller, so keep them in sync.\n exitAnimationNames = new WeakMap();\n\n hideAfterExitAnimation(animated) {\n const exitAnimations = animated\n .getAnimations()\n .filter((animation) => animation instanceof CSSAnimation);\n\n // No exit animation, or no box to run it in: animationend would never fire.\n if (exitAnimations.length === 0) {\n this.settleExit(animated);\n return;\n }\n\n this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName));\n animated.addEventListener(\"animationend\", this.handleExitAnimationEnd);\n animated.addEventListener(\"animationcancel\", this.handleExitAnimationEnd);\n }\n\n handleExitAnimationEnd = (event) => {\n // animationend bubbles — an animated child must not hide its container.\n if (event.target !== event.currentTarget) return;\n // Closing mid-open cancels the enter animation; only the exit run settles this.\n if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return;\n\n this.settleExit(event.currentTarget);\n };\n\n settleExit(animated) {\n animated.removeEventListener(\"animationend\", this.handleExitAnimationEnd);\n animated.removeEventListener(\"animationcancel\", this.handleExitAnimationEnd);\n // Reopened mid-exit: it is on its way back in, leave it visible.\n if (animated.dataset.state !== \"closed\") return;\n\n this.afterExit(animated);\n }\n}\n" }, { "path": "sheet_controller.js",