@flanksource/clicky-ui
Version:
Flanksource Clicky UI — React component library built on shadcn/ui with light/dark and density theming.
182 lines (181 loc) • 6.19 kB
JavaScript
import { jsxs, jsx } from "react/jsx-runtime";
import { forwardRef, useRef, useState, useImperativeHandle, useMemo, useCallback, useEffect } from "react";
import { cn } from "../lib/utils.js";
import { Icon } from "../data/Icon.js";
import { Button } from "./button.js";
function IconMenuPickerInner({
value,
onChange,
options,
ariaLabel,
triggerTitle,
footer,
className,
triggerClassName,
menuClassName
}, ref) {
const rootRef = useRef(null);
const triggerRef = useRef(null);
const itemRefs = useRef([]);
const [open, setOpen] = useState(false);
useImperativeHandle(ref, () => rootRef.current);
const activeIndex = useMemo(
() => Math.max(
0,
options.findIndex((option) => option.value === value)
),
[options, value]
);
const close = useCallback((returnFocus) => {
var _a;
setOpen(false);
if (returnFocus) (_a = triggerRef.current) == null ? void 0 : _a.focus();
}, []);
const select = useCallback(
(next) => {
onChange(next);
close(true);
},
[close, onChange]
);
useEffect(() => {
if (!open) return;
const onPointerDown = (event) => {
var _a;
if (event.button !== 0) return;
if (!((_a = rootRef.current) == null ? void 0 : _a.contains(event.target))) {
close(false);
}
};
const onKeyDown = (event) => {
if (event.key === "Escape") {
event.preventDefault();
close(true);
}
};
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [close, open]);
useEffect(() => {
if (!open) return;
const node = itemRefs.current[activeIndex];
node == null ? void 0 : node.focus();
}, [activeIndex, open]);
const focusItem = (index) => {
var _a;
const wrapped = (index + options.length) % options.length;
(_a = itemRefs.current[wrapped]) == null ? void 0 : _a.focus();
};
const onMenuKeyDown = (event) => {
const focusedIndex = itemRefs.current.findIndex((node) => node === document.activeElement);
if (event.key === "ArrowDown") {
event.preventDefault();
focusItem((focusedIndex < 0 ? activeIndex : focusedIndex) + 1);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
focusItem((focusedIndex < 0 ? activeIndex : focusedIndex) - 1);
return;
}
if (event.key === "Home") {
event.preventDefault();
focusItem(0);
return;
}
if (event.key === "End") {
event.preventDefault();
focusItem(options.length - 1);
return;
}
if (event.key === "Tab") {
close(false);
}
};
const onTriggerKeyDown = (event) => {
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
event.preventDefault();
setOpen(true);
}
};
const selected = options[activeIndex];
if (!selected) {
throw new Error(`IconMenuPicker: value "${value}" not found in options for ${ariaLabel}`);
}
return /* @__PURE__ */ jsxs("div", { ref: rootRef, className: cn("relative inline-flex", className), children: [
/* @__PURE__ */ jsx(
Button,
{
ref: triggerRef,
type: "button",
variant: "ghost",
size: "icon",
"aria-label": ariaLabel,
"aria-haspopup": "menu",
"aria-expanded": open,
title: triggerTitle ?? `${ariaLabel}: ${selected.label}`,
onClick: () => setOpen((current) => !current),
onKeyDown: onTriggerKeyDown,
className: cn("text-muted-foreground hover:text-foreground", triggerClassName),
children: /* @__PURE__ */ jsx(Icon, { name: selected.icon })
}
),
open && /* @__PURE__ */ jsxs(
"div",
{
role: "menu",
"aria-label": ariaLabel,
onKeyDown: onMenuKeyDown,
className: cn(
"absolute left-0 top-[calc(100%+0.375rem)] z-50 min-w-[12rem] rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg shadow-black/5",
menuClassName
),
children: [
options.map((option, index) => {
const active = option.value === value;
return /* @__PURE__ */ jsxs(
"button",
{
ref: (node) => {
itemRefs.current[index] = node;
},
type: "button",
role: "menuitemradio",
"aria-checked": active,
tabIndex: active ? 0 : -1,
onClick: () => select(option.value),
onKeyDown: (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
select(option.value);
}
},
className: cn(
"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm transition-colors",
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none",
active && "text-foreground"
),
children: [
/* @__PURE__ */ jsx(Icon, { name: option.icon, className: "shrink-0 text-muted-foreground" }),
/* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1 truncate capitalize", children: option.label }),
active ? /* @__PURE__ */ jsx(Icon, { name: "ph:check", className: "shrink-0 text-foreground" }) : /* @__PURE__ */ jsx("span", { className: "inline-block size-4 shrink-0", "aria-hidden": true })
]
},
option.value
);
}),
footer ? /* @__PURE__ */ jsx("div", { className: "mt-1 border-t border-border/60 px-2 py-1.5 text-[11px] text-muted-foreground", children: footer }) : null
]
}
)
] });
}
const IconMenuPicker = forwardRef(IconMenuPickerInner);
export {
IconMenuPicker
};
//# sourceMappingURL=icon-menu-picker.js.map