feat(member-live): sort the composite Member column by first or last name

This commit is contained in:
Simon 2026-07-16 09:39:07 +02:00
parent ca2aaa069c
commit f7005f395f
11 changed files with 443 additions and 34 deletions

View file

@ -407,6 +407,12 @@ Hooks.PopoverTooltip = {
this.el.addEventListener("mouseleave", this.hide)
this.el.addEventListener("blur", this.hide)
this.el.addEventListener("keydown", this.onKey)
// Hide on activation: a click (sort/reset) triggers a LiveView re-render that
// re-emits the tooltip element without the JS-set inline top/left, which would
// otherwise leave the still-open popover stuck at the default (0,0) position,
// covering the header. Closing it here keeps it out of the re-render; the next
// hover repositions it cleanly.
this.el.addEventListener("click", this.hide)
},
destroyed() {
@ -416,6 +422,121 @@ Hooks.PopoverTooltip = {
this.el.removeEventListener("mouseleave", this.hide)
this.el.removeEventListener("blur", this.hide)
this.el.removeEventListener("keydown", this.onKey)
this.el.removeEventListener("click", this.hide)
}
}
// SubfieldMenu hook: toggles a native Popover menu (top layer, so it escapes the
// overview table's overflow clipping) for choosing the composite Member column's
// sort sub-field. Attached to the "⋮" trigger; data-menu-id names the <ul
// popover> menu. Handles click / keyboard open, roving arrow-key focus,
// Escape / click-away close, and closes after a selection (the LiveView
// re-renders on sort_composite anyway). Positioned with getBoundingClientRect
// (portable; no CSS anchor-positioning needed).
Hooks.SubfieldMenu = {
mounted() {
this.menu = document.getElementById(this.el.dataset.menuId)
if (!this.menu || typeof this.menu.showPopover !== "function") return
// Hover/focus hint on the trigger, shown while the menu is closed. Same
// .popover-tooltip look as the sort-header tooltips; placement is CSS anchor
// positioning (no coordinate math).
this.tip = document.getElementById(this.el.dataset.tooltipId)
this.showTip = () => {
if (!this.tip || this.menu.matches(":popover-open")) return
try {
if (!this.tip.matches(":popover-open")) this.tip.showPopover()
} catch (_e) {}
}
this.hideTip = () => {
try {
if (this.tip && this.tip.matches(":popover-open")) this.tip.hidePopover()
} catch (_e) {}
}
this.items = () => [...this.menu.querySelectorAll('[role="menuitemradio"]')]
// Placement is pure CSS anchor positioning (see `.popover-menu` in app.css);
// this hook only toggles visibility, focus and keyboard interaction.
this.open = ({focusFirst = true} = {}) => {
this.hideTip()
if (!this.menu.matches(":popover-open")) this.menu.showPopover()
this.el.setAttribute("aria-expanded", "true")
if (focusFirst) {
const active = this.menu.querySelector('[aria-checked="true"]') || this.items()[0]
if (active) active.focus()
}
}
this.close = ({focusTrigger = false} = {}) => {
if (this.menu.matches(":popover-open")) this.menu.hidePopover()
this.el.setAttribute("aria-expanded", "false")
if (focusTrigger) this.el.focus()
}
this.onTriggerClick = (e) => {
e.preventDefault()
e.stopPropagation()
this.menu.matches(":popover-open") ? this.close() : this.open()
}
this.onTriggerKey = (e) => {
if (["ArrowDown", "Enter", " "].includes(e.key)) {
e.preventDefault()
this.open()
} else if (e.key === "ArrowUp") {
e.preventDefault()
this.open({focusFirst: false})
const items = this.items()
if (items.length) items[items.length - 1].focus()
}
}
this.onMenuKey = (e) => {
const items = this.items()
const i = items.indexOf(document.activeElement)
if (e.key === "ArrowDown") {
e.preventDefault()
;(items[i + 1] || items[0]).focus()
} else if (e.key === "ArrowUp") {
e.preventDefault()
;(items[i - 1] || items[items.length - 1]).focus()
} else if (e.key === "Escape") {
e.preventDefault()
this.close({focusTrigger: true})
} else if (e.key === "Tab") {
this.close()
}
}
// A selection fires the LiveView sort; drop the menu.
this.onMenuClick = () => this.close()
this.onDocPointer = (e) => {
if (!this.el.contains(e.target) && !this.menu.contains(e.target)) this.close()
}
this.el.addEventListener("click", this.onTriggerClick)
this.el.addEventListener("keydown", this.onTriggerKey)
this.el.addEventListener("mouseenter", this.showTip)
this.el.addEventListener("focus", this.showTip)
this.el.addEventListener("mouseleave", this.hideTip)
this.el.addEventListener("blur", this.hideTip)
this.menu.addEventListener("keydown", this.onMenuKey)
this.menu.addEventListener("click", this.onMenuClick)
document.addEventListener("pointerdown", this.onDocPointer, true)
},
destroyed() {
this.el.removeEventListener("click", this.onTriggerClick)
this.el.removeEventListener("keydown", this.onTriggerKey)
this.el.removeEventListener("mouseenter", this.showTip)
this.el.removeEventListener("focus", this.showTip)
this.el.removeEventListener("mouseleave", this.hideTip)
this.el.removeEventListener("blur", this.hideTip)
this.hideTip()
if (this.menu) {
this.menu.removeEventListener("keydown", this.onMenuKey)
this.menu.removeEventListener("click", this.onMenuClick)
if (this.menu.matches(":popover-open")) this.menu.hidePopover()
}
document.removeEventListener("pointerdown", this.onDocPointer, true)
}
}