mirror of
https://github.com/we-promise/sure.git
synced 2026-06-05 18:59:04 +00:00
* feat(ds): elevate dropdown overlays and stabilize selection check gutter Menus and popovers floated at the same elevation as inline cards (shadow-border-xs), so dropdowns blended into the content beneath them. Bump DS::Menu and DS::Popover panels to shadow-border-lg. DS::MenuItem rendered its leading icon only when present, so a selection check shifted the row's text out of alignment with the unselected rows. Add a `selected:` param that reserves a fixed-width check gutter (check when selected, empty otherwise) so row text stays aligned. Apply the same reserved gutter to the bespoke category dropdown row, and add a `selectable` menu preview. * fix(ds): expose menu selection via menuitemradio + aria-checked Selectable DS::MenuItem rows conveyed selection only visually. Render them as role="menuitemradio" with aria-checked so assistive tech gets the selection state of single-select lists, merging the menu ARIA contract with any caller-supplied aria. Addresses CodeRabbit review feedback. * fix(ds): include selectable roles in menu roving-focus query DS::MenuItem selectable rows render as role=menuitemradio, but the menu controller built its roving-focus list from [role=menuitem] only, leaving single-select menus with no keyboard focus/arrow handling. Query the menuitemradio/menuitemcheckbox roles too. Addresses Codex review feedback.
185 lines
5.1 KiB
JavaScript
185 lines
5.1 KiB
JavaScript
import {
|
|
autoUpdate,
|
|
computePosition,
|
|
flip,
|
|
offset,
|
|
shift,
|
|
} from "@floating-ui/dom";
|
|
import { Controller } from "@hotwired/stimulus";
|
|
|
|
/**
|
|
* Strict action-list menu. Container is `role="menu"`, items are
|
|
* `role="menuitem"`. Arrow Up/Down moves focus between items, Home/End
|
|
* jumps to first/last, Escape closes the menu and returns focus to the
|
|
* trigger. Use DS::Popover for mixed-content panels (forms, pickers).
|
|
*/
|
|
export default class extends Controller {
|
|
static targets = ["button", "content"];
|
|
|
|
static values = {
|
|
show: Boolean,
|
|
placement: { type: String, default: "bottom-end" },
|
|
offset: { type: Number, default: 6 },
|
|
mobileFullwidth: { type: Boolean, default: true },
|
|
};
|
|
|
|
connect() {
|
|
this.show = this.showValue;
|
|
this.boundUpdate = this.update.bind(this);
|
|
this.addEventListeners();
|
|
this.startAutoUpdate();
|
|
}
|
|
|
|
disconnect() {
|
|
this.removeEventListeners();
|
|
this.stopAutoUpdate();
|
|
this.close();
|
|
}
|
|
|
|
addEventListeners() {
|
|
this.buttonTarget.addEventListener("click", this.toggle);
|
|
this.element.addEventListener("keydown", this.handleKeydown);
|
|
document.addEventListener("click", this.handleOutsideClick);
|
|
document.addEventListener("turbo:load", this.handleTurboLoad);
|
|
}
|
|
|
|
removeEventListeners() {
|
|
this.buttonTarget.removeEventListener("click", this.toggle);
|
|
this.element.removeEventListener("keydown", this.handleKeydown);
|
|
document.removeEventListener("click", this.handleOutsideClick);
|
|
document.removeEventListener("turbo:load", this.handleTurboLoad);
|
|
}
|
|
|
|
handleTurboLoad = () => {
|
|
if (!this.show) this.close();
|
|
};
|
|
|
|
handleOutsideClick = (event) => {
|
|
if (this.show && !this.element.contains(event.target)) this.close();
|
|
};
|
|
|
|
handleKeydown = (event) => {
|
|
if (event.key === "Escape") {
|
|
this.close();
|
|
this.buttonTarget.focus();
|
|
return;
|
|
}
|
|
if (!this.show) return;
|
|
|
|
const items = this.#menuItems();
|
|
if (items.length === 0) return;
|
|
const currentIndex = items.indexOf(event.target);
|
|
|
|
// Activate the focused item on Enter / Space (ARIA menu pattern).
|
|
// Without this, link-based menuitems can't be activated by keyboard
|
|
// once focus has moved off the native default.
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
if (currentIndex < 0) return;
|
|
event.preventDefault();
|
|
items[currentIndex].click();
|
|
return;
|
|
}
|
|
|
|
let nextIndex = null;
|
|
switch (event.key) {
|
|
case "ArrowDown":
|
|
nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length;
|
|
break;
|
|
case "ArrowUp":
|
|
nextIndex = currentIndex < 0 ? items.length - 1 : (currentIndex - 1 + items.length) % items.length;
|
|
break;
|
|
case "Home":
|
|
nextIndex = 0;
|
|
break;
|
|
case "End":
|
|
nextIndex = items.length - 1;
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
items.forEach((item, i) => item.setAttribute("tabindex", i === nextIndex ? "0" : "-1"));
|
|
items[nextIndex].focus();
|
|
};
|
|
|
|
toggle = () => {
|
|
this.show = !this.show;
|
|
this.contentTarget.classList.toggle("hidden", !this.show);
|
|
this.buttonTarget.setAttribute("aria-expanded", this.show.toString());
|
|
if (this.show) {
|
|
this.update();
|
|
this.#focusFirstMenuItem();
|
|
}
|
|
};
|
|
|
|
close() {
|
|
this.show = false;
|
|
this.contentTarget.classList.add("hidden");
|
|
this.buttonTarget.setAttribute("aria-expanded", "false");
|
|
}
|
|
|
|
#menuItems() {
|
|
// Include selectable roles (menuitemradio/menuitemcheckbox) so roving focus
|
|
// and keyboard handling work for single/multi-select menus, not just plain
|
|
// action items.
|
|
return Array.from(
|
|
this.contentTarget.querySelectorAll(
|
|
'[role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"]',
|
|
),
|
|
);
|
|
}
|
|
|
|
#focusFirstMenuItem() {
|
|
const items = this.#menuItems();
|
|
if (items.length === 0) return;
|
|
items.forEach((item, i) => item.setAttribute("tabindex", i === 0 ? "0" : "-1"));
|
|
items[0].focus({ preventScroll: true });
|
|
}
|
|
|
|
startAutoUpdate() {
|
|
if (!this._cleanup) {
|
|
this._cleanup = autoUpdate(
|
|
this.buttonTarget,
|
|
this.contentTarget,
|
|
this.boundUpdate,
|
|
);
|
|
}
|
|
}
|
|
|
|
stopAutoUpdate() {
|
|
if (this._cleanup) {
|
|
this._cleanup();
|
|
this._cleanup = null;
|
|
}
|
|
}
|
|
|
|
update() {
|
|
if (!this.buttonTarget || !this.contentTarget) return;
|
|
|
|
const isSmallScreen = !window.matchMedia("(min-width: 768px)").matches;
|
|
const useMobileFullwidth = isSmallScreen && this.mobileFullwidthValue;
|
|
|
|
computePosition(this.buttonTarget, this.contentTarget, {
|
|
placement: useMobileFullwidth ? "bottom" : this.placementValue,
|
|
middleware: [offset(this.offsetValue), flip({ padding: 5 }), shift({ padding: 5 })],
|
|
strategy: "fixed",
|
|
}).then(({ x, y }) => {
|
|
if (useMobileFullwidth) {
|
|
Object.assign(this.contentTarget.style, {
|
|
position: "fixed",
|
|
left: "0px",
|
|
width: "100vw",
|
|
top: `${y}px`,
|
|
});
|
|
} else {
|
|
Object.assign(this.contentTarget.style, {
|
|
position: "fixed",
|
|
left: `${x}px`,
|
|
top: `${y}px`,
|
|
width: "",
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|