Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ Active Admin is a Ruby on Rails framework for creating elegant backends for webs
## Technology Stack

- **Backend**: Ruby on Rails (currently Rails ~> 8.1.0)
- **Frontend**: JavaScript (ES6+), Tailwind CSS, Flowbite
- **Frontend**: JavaScript (ES6+), Tailwind CSS
- **Testing**: RSpec (unit tests), Cucumber (feature tests), Capybara (integration tests)
- **Build Tools**: Rollup (JavaScript bundling), cssbundling-rails
- **Key Dependencies**: Devise (authentication), Ransack (search), Formtastic (forms), Kaminari (pagination)
- **Key Dependencies**: Devise (authentication), Ransack (search), Formtastic (forms), Kaminari (pagination), Floating UI (dropdown positioning)

## Ruby Conventions

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ We try not to reinvent the wheel, so Active Admin is built with other open sourc

* [Arbre]
* [Devise]
* [Flowbite](https://flowbite.com)
* [Floating UI](https://floating-ui.com)
* [Formtastic]
* [Inherited Resources]
* [Kaminari]
Expand Down
4 changes: 2 additions & 2 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,9 @@ The Tailwind config file now uses ES module syntax. To fix it, either:
<details>
<summary>Batch Action Partial Example</summary>

Assuming a Post resource (in the default namespace) with a `mark_published` batch action, we set the partial name and a set of HTML data attributes to trigger a modal using Flowbite which is included by default.
Assuming a Post resource (in the default namespace) with a `mark_published` batch action, we set the partial name and a set of HTML data attributes to trigger ActiveAdmin's built-in modal.

Note that you can use any modal JS library you want as long as it can be triggered to open using data attributes. Flowbite usage is not a requirement.
Note that you can use any modal JS library you want as long as it can be triggered to open using data attributes.

```ruby
batch_action(
Expand Down
4 changes: 3 additions & 1 deletion app/javascript/active_admin.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import "flowbite"
import Rails from "@rails/ujs"
import "active_admin/features/batch_actions"
import "active_admin/features/dark_mode_toggle"
import "active_admin/features/drawer"
import "active_admin/features/dropdown"
import "active_admin/features/has_many"
import "active_admin/features/filters"
import "active_admin/features/main_menu"
import "active_admin/features/modal"
import "active_admin/features/per_page"

Rails.start()
106 changes: 106 additions & 0 deletions app/javascript/active_admin/features/drawer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import Rails from '@rails/ujs';
import { trapFocus, focusFirstDescendant } from 'active_admin/utils/focus_trap';

let activeDrawer = null
let triggerElement = null
let backdrop = null
let cleanupTrap = null

const escapeKey = function(event) {
if (event.key !== "Escape") { return }
if (event.currentTarget !== activeDrawer) { return }

event.preventDefault()
event.stopPropagation()
close()
}

const open = function(drawer, trigger) {
triggerElement = trigger

drawer.classList.remove("-translate-x-full")
drawer.classList.add("translate-x-0")
drawer.setAttribute("aria-modal", "true")
drawer.setAttribute("role", "dialog")

document.body.classList.add("overflow-hidden", "xl:overflow-auto")

@tagliala tagliala Mar 8, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot:

Short answer: xl:overflow-auto applies overflow-auto only at the xl breakpoint and up, whereas applying overflow on the body would affect every viewport size. It's a responsive change to avoid global side-effects on smaller screens.

Why this is done (concise):

  • Breakpoint scope: xl:overflow-auto → only on extra‑large screens; body { overflow: auto } → always.
  • Prevents unwanted scroll behavior on mobile: a global body overflow can create double scrollbars or allow background scrolling when modals/drawers are shown.
  • Keeps smaller-screen UX native (touch/scroll) while enabling an internal scroller for large desktop layouts (e.g., fixed sidebars, content columns).
  • Helps contain scrollable regions (drawers, dropdowns, modals) to a specific layout at large widths without breaking responsive stacking at smaller widths.

I think this does not apply here, because we have a backdrop.

There is actually a difference in behavior, but it only happens if you are at small window size, you open the drawer, you resize the window to xl screen, you try to scroll


backdrop = document.createElement("div")
backdrop.className = "fixed inset-0 z-30 bg-gray-900/50 dark:bg-gray-900/80"
backdrop.setAttribute("data-drawer-backdrop", "")
backdrop.addEventListener("click", backdropClick)
document.body.appendChild(backdrop)

activeDrawer = drawer
activeDrawer.addEventListener("keydown", escapeKey)
cleanupTrap = trapFocus(drawer)
focusFirstDescendant(drawer)
}

const close = function() {
if (!activeDrawer) { return }

backdrop.removeEventListener("click", backdropClick)
activeDrawer.removeEventListener("keydown", escapeKey)
activeDrawer.classList.add("-translate-x-full")
activeDrawer.classList.remove("translate-x-0")
activeDrawer.removeAttribute("aria-modal")
activeDrawer.removeAttribute("role")
document.body.classList.remove("overflow-hidden", "xl:overflow-auto")
backdrop.remove()
cleanupTrap()

activeDrawer = null
backdrop = null
cleanupTrap = null

if (triggerElement && triggerElement.isConnected) { triggerElement.focus() }
triggerElement = null
}

const showClick = function(event) {
event.preventDefault()
const id = this.dataset.drawerShow
const drawer = document.getElementById(id)
if (!drawer) { return }
close()
open(drawer, this)
}

const toggleClick = function(event) {
event.preventDefault()
const id = this.dataset.drawerToggle
const drawer = document.getElementById(id)
if (!drawer) { return }

if (activeDrawer === drawer) {
close()
} else {
close()
open(drawer, this)
}
}

const hideClick = function(event) {
event.preventDefault()
const id = this.dataset.drawerHide || this.dataset.drawerDismiss

if (!id) {
close()
return
}

const drawer = document.getElementById(id)
if (!drawer) { return }
if (activeDrawer !== drawer) { return }

close()
}

const backdropClick = function(event) {
if (event.currentTarget === backdrop) { close() }
}

Rails.delegate(document, "[data-drawer-show]", "click", showClick)
Rails.delegate(document, "[data-drawer-toggle]", "click", toggleClick)
Rails.delegate(document, "[data-drawer-hide], [data-drawer-dismiss]", "click", hideClick)
167 changes: 167 additions & 0 deletions app/javascript/active_admin/features/dropdown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import Rails from '@rails/ujs';
import { autoUpdate, computePosition, flip, offset } from '@floating-ui/dom';

let activeDropdown = null

const FOCUS_DIRECTIONS = {
ArrowDown: "next",
ArrowUp: "prev",
Home: "first",
End: "last",
}

const TOGGLE_KEYS = new Set(["Enter", " ", "ArrowDown", "ArrowUp", "Escape"])

const menuItems = function(menu) {
return menu.querySelectorAll('a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])')
}

const setExpanded = function(toggle, expanded) {
toggle.setAttribute("aria-expanded", expanded ? "true" : "false")
}

const position = function(toggle, menu) {
const placement = toggle.dataset.dropdownPlacement || "bottom-start"
const distance = parseInt(toggle.dataset.dropdownOffsetDistance, 10) || 0

computePosition(toggle, menu, {
placement: placement,
middleware: [offset(distance), flip()]
}).then(({x, y}) => {
Object.assign(menu.style, {
position: "absolute",
left: `${x}px`,
top: `${y}px`,
});
})
}

const open = function(toggle, menu) {
setExpanded(toggle, true)
menu.classList.remove("hidden")
menu.classList.add("block")
position(toggle, menu)
menu.addEventListener("keydown", menuKeydown)
document.addEventListener("click", outsideClick)

const cleanupAutoUpdate = autoUpdate(toggle, menu, () => {
position(toggle, menu)
})

activeDropdown = {
toggle: toggle,
menu: menu,
cleanupAutoUpdate: cleanupAutoUpdate,
}
}

const close = function() {
if (!activeDropdown) { return }
const { toggle, menu, cleanupAutoUpdate } = activeDropdown

setExpanded(toggle, false)
menu.classList.remove("block")
menu.classList.add("hidden")
menu.removeEventListener("keydown", menuKeydown)
document.removeEventListener("click", outsideClick)
cleanupAutoUpdate()
activeDropdown = null

if (document.activeElement && menu.contains(document.activeElement)) {
toggle.focus()
}
}

const toggleClick = function(event) {
event.preventDefault()
const menu = document.getElementById(this.dataset.dropdownToggle)
if (!menu) { return }

if (activeDropdown && activeDropdown.menu === menu) {
close()
} else {
close()
open(this, menu)
}
}

const focusItem = function(menu, direction) {
const items = Array.from(menuItems(menu))
if (items.length === 0) { return }

const index = items.indexOf(document.activeElement)

if (direction === "first") {
items[0].focus()
} else if (direction === "last") {
items[items.length - 1].focus()
} else if (direction === "next") {
items[index < items.length - 1 ? index + 1 : 0].focus()
} else if (direction === "prev") {
items[index > 0 ? index - 1 : items.length - 1].focus()
}
}

const menuKeydown = function(event) {
if (!activeDropdown) { return }
if (event.currentTarget !== activeDropdown.menu) { return }

if (event.key === "Escape") {
event.preventDefault()
event.stopPropagation()
close()
return
}

const dir = FOCUS_DIRECTIONS[event.key]
if (dir) {
event.preventDefault()
focusItem(activeDropdown.menu, dir)
}
}

const toggleKeydown = function(event) {
if (!TOGGLE_KEYS.has(event.key)) { return }

const menu = document.getElementById(this.dataset.dropdownToggle)
if (!menu) { return }

if (event.key === "Escape") {
if (activeDropdown && activeDropdown.menu === menu) {
event.preventDefault()
event.stopPropagation()
close()
}

return
}

event.preventDefault()

if (activeDropdown && activeDropdown.menu === menu) {
close()
return
}

close()
open(this, menu)

const items = menuItems(menu)
if (items.length > 0) {
if (event.key === "ArrowUp") {
items[items.length - 1].focus()
} else {
items[0].focus()
}
}
}

const outsideClick = function(event) {
if (!activeDropdown) { return }
if (activeDropdown.toggle.contains(event.target)) { return }
if (activeDropdown.menu.contains(event.target)) { return }
close()
}

Rails.delegate(document, "[data-dropdown-toggle]", "click", toggleClick)
Rails.delegate(document, "[data-dropdown-toggle]", "keydown", toggleKeydown)
Loading
Loading