Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
69 changes: 69 additions & 0 deletions app/components/primer/alpha/action_list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,82 @@ export class ActionListTruncationObserver {
@controller
export class ActionListElement extends HTMLElement {
#truncationObserver: ActionListTruncationObserver
#abortController: AbortController

connectedCallback() {
this.#truncationObserver = new ActionListTruncationObserver(this)
this.#abortController = new AbortController()
this.#setupHoverMenus()
}

disconnectedCallback() {
this.#truncationObserver.unobserve(this)
this.#abortController.abort()
}

#setupHoverMenus() {
const {signal} = this.#abortController
const itemsWithHoverMenus = this.querySelectorAll('[data-has-hover-menu="true"]')

for (const item of itemsWithHoverMenus) {
const actionMenu = item.querySelector('action-menu')
if (!actionMenu) continue

let hideTimeout: number | null = null

const showMenu = () => {
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = null
}

// For hover menus, we directly access the popover within the action-menu
// since there's no invoker button that would normally provide the popover reference
const popover = actionMenu.querySelector('[popover]') as HTMLElement & {showPopover(): void}
if (popover && !popover.matches(':popover-open')) {
popover.showPopover()
}
}

const hideMenu = () => {
hideTimeout = window.setTimeout(() => {
const popover = actionMenu.querySelector('[popover]') as HTMLElement & {hidePopover(): void}
if (popover && popover.matches(':popover-open')) {
popover.hidePopover()
}
}, 200)
}

const cancelHide = () => {
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = null
}
}

// Show menu when hovering over the item
item.addEventListener('mouseenter', showMenu, {signal})

// Hide menu when leaving the item, unless moving to the menu
item.addEventListener(
'mouseleave',
(event: Event) => {
const mouseEvent = event as MouseEvent
const relatedTarget = mouseEvent.relatedTarget as HTMLElement
if (!relatedTarget || !actionMenu.contains(relatedTarget)) {
hideMenu()
}
},
{signal},
)

// Keep menu open when hovering over the menu itself
const popover = actionMenu.querySelector('[popover]')
if (popover) {
popover.addEventListener('mouseenter', cancelHide, {signal})
popover.addEventListener('mouseleave', hideMenu, {signal})
}
}
}
}

Expand Down
3 changes: 3 additions & 0 deletions app/components/primer/alpha/action_list/item.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,8 @@
<% if trailing_action %>
<%= trailing_action %>
<% end %>
<% if hover_menu %>
<%= hover_menu %>
<% end %>
<%= private_content %>
<% end %>
39 changes: 39 additions & 0 deletions app/components/primer/alpha/action_list/item.rb
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,33 @@ class Item < Primer::Component
Primer::Alpha::Tooltip.new(**system_arguments)
}

# An `ActionMenu` that appears when the item is hovered. The menu will be positioned relative to the item
# and will appear on mouse enter and disappear on mouse leave with a small delay.
#
# @param menu_id [String] Optional. Unique identifier for the menu. If not provided, one will be generated.
# @param anchor_side [Symbol] Optional. <%= one_of(Primer::Alpha::Overlay::ANCHOR_SIDE_OPTIONS) %>
# @param anchor_align [Symbol] Optional. <%= one_of(Primer::Alpha::Overlay::ANCHOR_ALIGN_OPTIONS) %>
# @param system_arguments [Hash] The arguments accepted by <%= link_to_component(Primer::Alpha::ActionMenu) %>.
renders_one :hover_menu, lambda { |menu_id: nil, **system_arguments|
menu_id ||= "hover-menu-#{SecureRandom.hex(4)}"

# Generate a consistent item ID that will be used for anchoring
item_id = @id || @item_id || "action-list-item-#{SecureRandom.hex(4)}"
@hover_menu_anchor_id = item_id # Store for use in before_render

# Extract overlay-specific arguments for proper anchoring
overlay_arguments = system_arguments.delete(:overlay_arguments) || {}

# Create the ActionMenu with proper overlay configuration for hover anchoring
Primer::Alpha::ActionMenu.new(
menu_id: menu_id,
anchor_side: :outside_right,
# Pass the anchor through overlay_arguments to the Overlay
overlay_arguments: overlay_arguments.merge(anchor: item_id),
**system_arguments
)
}

# Used internally.
#
# @private
Expand Down Expand Up @@ -311,6 +338,18 @@ def before_render
"ActionListItem--withActions" => trailing_action.present?
)

# Add hover menu data attributes if hover menu is present
if hover_menu?
# Use the anchor ID that was set when creating the hover menu
@system_arguments[:id] = @hover_menu_anchor_id if @hover_menu_anchor_id

@system_arguments[:data] = merge_data(
@system_arguments, {
data: { "has-hover-menu": true }
}
)
end

if @truncate_label == :show_tooltip && !tooltip?
with_tooltip(text: @label, direction: :ne)
end
Expand Down
Empty file.
24 changes: 24 additions & 0 deletions previews/primer/beta/nav_list_preview.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,30 @@ def default
end
end

# @label Hover menu
# @snapshot
def hover_menu
render(Primer::Beta::NavList.new(selected_item_id: :code_review_limits, aria: { label: "Repositories" })) do |list|
list.with_group do |group|
group.with_heading(title: "Repositories")
group.with_avatar_item(label: "github/github", href: "/interaction-limits", selected_by_ids: :interaction_limits, src: "https://avatars.githubusercontent.com/u/9919?v=4", username: "github") do |interaction_item|
interaction_item.with_hover_menu(anchor_side: :outside_right) do |menu|
menu.with_item(label: "Code") do |block_item|
block_item.with_leading_visual_icon(icon: :code)
end
menu.with_item(label: "Pull requests") do |limit_item|
limit_item.with_leading_visual_icon(icon: :"git-pull-request")
end
menu.with_divider
menu.with_item(label: "Repository settings", href: "/interaction-limits/settings") do |settings_item|
settings_item.with_leading_visual_icon(icon: :gear)
end
end
end
end
end
end

# @label Top-level items
#
def top_level_items
Expand Down
Loading