Packages
noora
0.84.1
0.84.1
0.84.0
0.83.2
0.83.1
0.83.0
0.82.7
0.82.6
0.82.5
0.82.4
0.82.3
0.82.2
0.82.1
0.82.0
0.81.4
0.81.3
0.81.2
0.81.1
0.81.0
0.80.1
0.80.0
0.79.1
0.79.0
0.78.1
0.78.0
0.77.4
0.77.3
0.77.2
0.77.1
0.77.0
0.76.0
0.75.0
0.74.0
0.73.0
0.72.0
0.71.0
0.70.0
0.69.1
0.69.0
0.68.0
0.67.0
0.66.0
0.65.0
0.64.2
0.64.1
0.64.0
0.63.2
0.63.1
0.63.0
0.62.0
0.61.0
0.60.0
0.59.0
0.58.0
0.57.0
0.56.1
0.56.0
0.55.0
0.54.0
0.53.1
0.53.0
0.52.1
0.52.0
0.51.0
0.50.1
0.50.0
0.49.0
0.48.0
0.47.0
0.46.0
0.45.0
0.44.3
0.44.2
0.44.1
0.44.0
0.43.0
0.42.0
0.41.0
0.40.6
0.40.5
0.40.4
0.40.3
0.40.2
0.40.1
0.40.0
0.39.1
0.39.0
0.38.0
0.37.0
0.36.0
0.35.0
0.34.0
0.33.0
0.32.1
0.32.0
0.31.0
0.30.0
0.29.2
0.29.1
0.29.0
0.28.5
0.28.4
0.28.3
0.28.2
0.28.1
0.28.0
0.27.0
0.26.1
0.26.0
0.25.0
0.24.0
0.23.1
0.23.0
0.22.1
0.22.0
0.21.0
0.20.0
0.19.0
0.18.0
0.17.0
0.16.0
0.15.0
0.14.0
0.13.0
0.12.1
0.12.0
0.11.2
0.11.1
0.11.0
0.10.0
0.9.0
0.8.0
0.7.0
0.6.1
0.6.0
0.5.1
0.5.0
0.4.0
0.3.2
0.3.1
0.3.0
0.2.2
0.2.0
0.1.0
0.1.0-rc.2
0.1.0-rc.1
0.1.0-alpha.6
0.1.0-alpha.5
0.1.0-alpha.4
0.1.0-alpha.3
0.1.0-alpha.2
0.1.0-alpha.1
A component library for Phoenix LiveView applications
Current section
Files
Jump to
Current section
Files
js/Dropdown.js
import * as menu from "@zag-js/menu";
import {
normalizeProps,
spreadProps,
renderPart,
getBooleanOption,
getOption,
getPartSelector,
} from "./util.js";
import { Component } from "./component.js";
import { VanillaMachine } from "./machine.js";
class Menu extends Component {
initMachine(context) {
return new VanillaMachine(menu.machine, context);
}
initApi() {
return menu.connect(this.machine.service, normalizeProps);
}
render() {
const parts = [
"trigger",
"trigger:indicator",
"positioner",
"positioner:content",
];
for (const part of parts) renderPart(this.el, part, this.api);
this.syncFixedPositionerSize();
this.renderItemGroupLabels();
this.renderItemGroups();
this.renderItems();
this.renderSeparators();
}
syncFixedPositionerSize() {
if (this.el.dataset.positioningStrategy !== "fixed") return;
const positioner = this.el.querySelector(getPartSelector("positioner"));
if (!positioner) return;
positioner.style.minWidth = "var(--reference-width, max-content)";
}
renderItemGroupLabels() {
for (const itemGroupLabel of this.el.querySelectorAll(
getPartSelector("positioner:content:item-group-label"),
)) {
const htmlFor = itemGroupLabel.getAttribute("for");
if (!htmlFor) {
console.error("Missing `for` attribute on item group label.");
return;
}
spreadProps(itemGroupLabel, this.api.getItemGroupLabelProps({ htmlFor }));
}
}
renderItemGroups() {
for (const itemGroup of this.el.querySelectorAll(
getPartSelector("positioner:content:item-group"),
)) {
const value = itemGroup.dataset.value;
if (!value) {
console.error("Missing `data-value` attribute on item group.");
return;
}
spreadProps(itemGroup, this.api.getItemGroupProps({ id: value }));
}
}
renderItems() {
const content = this.el.querySelector('[data-part="content"]');
if (!content) return;
for (const item of content.querySelectorAll('[data-part="item"]')) {
const value = item.dataset.value;
if (!value) {
console.error("Missing `data-value` attribute on item.");
return;
}
// Skip spreading props if item is inside breadcrumbs as that leads to issues on LiveView >= 1.1 due to conflicting state management between LiveView and JS layers.
if (item.closest(".noora-breadcrumbs")) {
continue;
}
spreadProps(item, this.api.getItemProps({ value }));
}
}
renderSeparators() {
for (const separator of this.el.querySelectorAll(
getPartSelector("positioner:content:separator"),
)) {
spreadProps(separator, this.api.getSeparatorProps());
}
}
}
/**
* Phoenix LiveView Hook for Menu component
*/
export default {
mounted() {
this.metadata = {};
// Breadcrumb dropdowns can't use zag's item props (see Menu.renderItems —
// they conflict with LiveView's DOM patching), so they get no built-in
// arrow-key navigation. We drive roving focus over the native <a> items
// ourselves instead.
this.isBreadcrumb = this.el.classList.contains("noora-breadcrumb");
for (const key in this.el.dataset) {
if (key.startsWith("meta")) {
const metaKey = key.charAt(4).toLowerCase() + key.slice(5);
this.metadata[metaKey] = this.el.dataset[key];
}
}
const positioningStrategy =
getOption(this.el, "positioningStrategy", ["absolute", "fixed"]) ||
(this.el.closest(".noora-table") ? "fixed" : undefined);
if (positioningStrategy) {
this.el.dataset.positioningStrategy = positioningStrategy;
}
this.context = {
id: this.el.id,
loopFocus: getBooleanOption(this.el, "loopFocus"),
closeOnSelect: getBooleanOption(this.el, "closeOnSelect"),
typeahead: getBooleanOption(this.el, "typeahead"),
positioning: {
placement: getOption(this.el, "positioningPlacement") || "bottom-start",
strategy: positioningStrategy,
offset: { mainAxis: getOption(this.el, "positioningOffsetMainAxis") },
...(() => {
const anchorPart = getOption(this.el, "positioningAnchor");
if (!anchorPart) return {};
const el = this.el;
return {
getAnchorRect: () => {
const anchorEl = el.querySelector(`[data-part="${anchorPart}"]`);
return anchorEl ? anchorEl.getBoundingClientRect() : null;
},
};
})(),
},
onOpenChange: (details) => {
if (this.isBreadcrumb) this.handleBreadcrumbOpenChange(details.open);
if (this.el.dataset.onOpenChange) {
this.pushEvent(this.el.dataset.onOpenChange, details);
}
},
onHighlightChange: (details) => {
if (this.el.dataset.onHighlightChange) {
this.pushEvent(this.el.dataset.onHighlightChange, details);
}
},
onSelect: (details) => {
if (this.el.dataset.onSelect) {
this.pushEvent(this.el.dataset.onSelect, {
...this.metadata,
...details,
});
}
},
onEscapeKeyDown: (details) => {
if (this.el.dataset.onEscapeKeyDown) {
this.pushEvent(this.el.dataset.onEscapeKeyDown, details);
}
},
onPointerDownOutside: (details) => {
if (this.el.dataset.onPointerDownOutside) {
this.pushEvent(this.el.dataset.onPointerDownOutside, details);
}
},
onFocusOutside: (details) => {
if (this.el.dataset.onFocusOutside) {
this.pushEvent(this.el.dataset.onFocusOutside, details);
}
},
onInteractOutside: (details) => {
if (this.el.dataset.onInteractOutside) {
this.pushEvent(this.el.dataset.onInteractOutside, details);
}
},
};
this.menu = new Menu(this.el, this.context);
this.menu.init();
this.handleSearchInput = (event) => {
if (event.target.matches('[data-part="search-input"]')) {
this.applySearchFilter();
}
};
this.handleSearchKeydown = (event) => {
if (event.target.matches('[data-part="search-input"]')) {
event.stopPropagation();
}
};
this.el.addEventListener("input", this.handleSearchInput);
this.el.addEventListener("keydown", this.handleSearchKeydown);
this.applySearchFilter();
this.handleOpenDropdown = (event) => {
if (event.detail.id == this.el.id) {
this.menu.api.setOpen(true);
window.requestAnimationFrame(() => this.applySearchFilter());
}
};
this.handleCloseDropdown = (event) => {
if (event.detail.all || event.detail.id === this.el.id) {
this.menu.api.setOpen(false);
}
};
window.addEventListener("phx:open-dropdown", this.handleOpenDropdown);
window.addEventListener("phx:close-dropdown", this.handleCloseDropdown);
if (this.isBreadcrumb) {
this.breadcrumbKeydown = (event) => this.handleBreadcrumbKeydown(event);
// Capture phase so zag's content keydown handler doesn't consume the
// arrow keys before we move focus.
this.el.addEventListener("keydown", this.breadcrumbKeydown, true);
}
},
breadcrumbItems() {
return [...this.el.querySelectorAll('[data-part="item"]')].filter(
(item) => item.style.display !== "none",
);
},
handleBreadcrumbOpenChange(open) {
if (open) {
// Defer until zag has shown the positioner so the items are focusable.
window.requestAnimationFrame(() => {
const items = this.breadcrumbItems();
if (!items.length) return;
const selected = items.find((item) =>
item.hasAttribute("data-selected"),
);
(selected || items[0]).focus();
});
} else if (this.el.contains(document.activeElement)) {
this.el.querySelector('[data-part="trigger"]')?.focus();
}
},
handleBreadcrumbKeydown(event) {
if (!this.menu.api.open) return;
// Don't hijack typing in a dropdown search field.
if (event.target.matches('[data-part="search-input"]')) return;
const items = this.breadcrumbItems();
if (!items.length) return;
const current = items.indexOf(document.activeElement);
let next;
switch (event.key) {
case "ArrowDown":
next = current < 0 ? 0 : (current + 1) % items.length;
break;
case "ArrowUp":
next =
current < 0
? items.length - 1
: (current - 1 + items.length) % items.length;
break;
case "Home":
next = 0;
break;
case "End":
next = items.length - 1;
break;
case "Enter":
case " ":
// The items aren't registered with zag, so its content handler
// swallows Enter/Space (it has no highlighted item to select) and the
// native <a> activation never fires. Trigger the focused item's click
// ourselves — LiveView's delegated click listener handles navigation.
if (current < 0) return;
event.preventDefault();
event.stopPropagation();
items[current].click();
return;
case "Tab":
// A menu is a single tab stop: the items are tabindex="-1", so close
// the menu and hand focus back to the trigger instead of letting Tab
// strand focus on a now-hidden item.
event.preventDefault();
event.stopPropagation();
this.menu.api.setOpen(false);
this.el.querySelector('[data-part="trigger"]')?.focus();
return;
default:
// Typeahead: jump to the item whose label matches the typed characters.
if (
event.key.length === 1 &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey
) {
event.preventDefault();
event.stopPropagation();
this.breadcrumbTypeahead(event.key, items);
}
return;
}
event.preventDefault();
event.stopPropagation();
items[next].focus();
},
breadcrumbTypeahead(char, items) {
clearTimeout(this.typeaheadTimer);
this.typeaheadQuery = (this.typeaheadQuery || "") + char.toLowerCase();
this.typeaheadTimer = setTimeout(() => {
this.typeaheadQuery = "";
}, 500);
const labelOf = (item) =>
(item.dataset.label || item.textContent || "").trim().toLowerCase();
const match =
items.find((item) => labelOf(item).startsWith(this.typeaheadQuery)) ||
items.find((item) => labelOf(item).includes(this.typeaheadQuery));
if (match) match.focus();
},
applySearchFilter() {
const searchInput = this.el.querySelector('[data-part="search-input"]');
if (!searchInput) return;
const query = searchInput.value.toLowerCase();
const content = this.el.querySelector('[data-part="content"]');
if (!content) return;
let visibleCount = 0;
for (const item of content.querySelectorAll('[data-part="item"]')) {
const label = (
item.dataset.label ||
item.textContent ||
""
).toLowerCase();
const visible = label.includes(query);
item.style.display = visible ? "" : "none";
if (visible) visibleCount++;
}
let emptyState = content.querySelector('[data-part="search-empty"]');
if (visibleCount === 0) {
if (!emptyState) {
emptyState = document.createElement("span");
emptyState.setAttribute("data-part", "search-empty");
emptyState.textContent = "No results";
content.querySelector('[data-part="items"]')?.appendChild(emptyState);
}
emptyState.style.display = "";
} else if (emptyState) {
emptyState.style.display = "none";
}
},
updated() {
this.menu.render();
this.applySearchFilter();
},
beforeDestroy() {
this.menu.destroy();
},
destroyed() {
this.el.removeEventListener("input", this.handleSearchInput);
this.el.removeEventListener("keydown", this.handleSearchKeydown);
if (this.breadcrumbKeydown) {
this.el.removeEventListener("keydown", this.breadcrumbKeydown, true);
}
clearTimeout(this.typeaheadTimer);
window.removeEventListener("phx:open-dropdown", this.handleOpenDropdown);
window.removeEventListener("phx:close-dropdown", this.handleCloseDropdown);
},
};