Add js web stuff to landing page + documentation
This commit is contained in:
328
go/jsruntime/uikit/Menu.tsx
Normal file
328
go/jsruntime/uikit/Menu.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import { createContext, useContext, createSignal, createEffect, onCleanup, Show, JSXElement } from "solid-js";
|
||||
import { FloatingRoot, FloatingTrigger, FloatingContent, useFloatingContext, useFloatingHover, Placement } from "./Floating.tsx";
|
||||
import { Icon } from "./Icons.tsx";
|
||||
|
||||
// Tailwind utility class groups for the menu UI — replaces the old
|
||||
// `.ui-menu` / `.item` / `.divider` / `.section` @scope CSS. These
|
||||
// are plain utility strings so they compose with any caller-supplied
|
||||
// classes via simple concatenation.
|
||||
// Padded container + inset, rounded items (the highlight is a rounded rectangle
|
||||
// that doesn't reach the menu edges) — matching the popover insert menus.
|
||||
const MENU_CLS = "bg-surface rounded-default shadow-lg border border-line p-1.5 min-w-48 max-h-96 overflow-y-auto";
|
||||
const ITEM_CLS = "flex items-center gap-2 w-full py-1.5 px-2 rounded-default text-sm text-left text-ink bg-transparent border-0 cursor-pointer hover:bg-surface-raised hover:text-ink focus:bg-surface-raised focus:outline-hidden disabled:opacity-50 disabled:cursor-not-allowed";
|
||||
// Divider runs edge-to-edge (negated container padding) for a clean separator.
|
||||
const DIVIDER_CLS = "my-1 -mx-1.5 border-0 border-t border-line";
|
||||
const SECTION_CLS = "pt-1.5 pb-0.5 px-2 text-[10px] font-semibold text-ink-faint uppercase tracking-wide text-left";
|
||||
const SUBMENU_TRIGGER_CLS = "flex items-center justify-between gap-2 w-full py-1.5 px-2 rounded-default text-sm text-left text-ink cursor-pointer hover:bg-surface-raised hover:text-ink";
|
||||
|
||||
interface MenuContextValue {
|
||||
openOnHover: boolean;
|
||||
hoverCloseDelay: number;
|
||||
closeMenu: () => void;
|
||||
cancelParentClose?: () => void;
|
||||
}
|
||||
|
||||
const MenuContext = createContext<MenuContextValue | null>(null);
|
||||
|
||||
function useMenuContext(): MenuContextValue {
|
||||
const context = useContext(MenuContext);
|
||||
if (!context) {
|
||||
throw new Error("Menu components must be used within a Menu");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface MenuProps {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
placement?: Placement;
|
||||
offset?: number;
|
||||
openOnHover?: boolean;
|
||||
hoverCloseDelay?: number;
|
||||
children?: JSXElement;
|
||||
}
|
||||
|
||||
export function Menu(props: MenuProps) {
|
||||
return (
|
||||
<FloatingRoot open={props.open} onOpenChange={(v: boolean) => props.onOpenChange?.(v)} placement={props.placement ?? "bottom-start"} offset={props.offset ?? 4}>
|
||||
<MenuContextProvider openOnHover={props.openOnHover ?? false} hoverCloseDelay={props.hoverCloseDelay ?? 150}>
|
||||
{props.children}
|
||||
</MenuContextProvider>
|
||||
</FloatingRoot>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuContextProviderProps {
|
||||
openOnHover: boolean;
|
||||
hoverCloseDelay: number;
|
||||
children?: JSXElement;
|
||||
}
|
||||
|
||||
function MenuContextProvider(props: MenuContextProviderProps) {
|
||||
const { setIsOpen, cancelHoverClose } = useFloatingContext();
|
||||
|
||||
const closeMenu = () => setIsOpen(false);
|
||||
|
||||
return (
|
||||
<MenuContext.Provider value={{
|
||||
get openOnHover() { return props.openOnHover; },
|
||||
get hoverCloseDelay() { return props.hoverCloseDelay; },
|
||||
closeMenu,
|
||||
cancelParentClose: cancelHoverClose,
|
||||
}}>
|
||||
{props.children}
|
||||
</MenuContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuTriggerProps {
|
||||
asChild?: boolean;
|
||||
class?: string;
|
||||
children?: JSXElement | ((state: { isOpen: boolean }) => JSXElement);
|
||||
}
|
||||
|
||||
export function MenuTrigger(props: MenuTriggerProps) {
|
||||
const menuCtx = useMenuContext();
|
||||
const { isOpen } = useFloatingContext();
|
||||
|
||||
const resolvedChildren = () => typeof props.children === "function"
|
||||
? (props.children as (state: { isOpen: boolean }) => JSXElement)({ isOpen: isOpen() })
|
||||
: props.children;
|
||||
|
||||
return (
|
||||
<FloatingTrigger class={props.class || ""} openOnHover={menuCtx.openOnHover} hoverCloseDelay={menuCtx.hoverCloseDelay}>
|
||||
{resolvedChildren()}
|
||||
</FloatingTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuContentProps {
|
||||
class?: string;
|
||||
children?: JSXElement;
|
||||
}
|
||||
|
||||
export function MenuContent(props: MenuContentProps) {
|
||||
const menuCtx = useMenuContext();
|
||||
const hoverProps = useFloatingHover(menuCtx.openOnHover, menuCtx.hoverCloseDelay);
|
||||
|
||||
return (
|
||||
<FloatingContent class={MENU_CLS + " " + (props.class || "")} onMouseEnter={hoverProps.onMouseEnter} onMouseLeave={hoverProps.onMouseLeave}>
|
||||
{props.children}
|
||||
</FloatingContent>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuItemProps {
|
||||
icon?: string;
|
||||
disabled?: boolean;
|
||||
onclick?: ((_e: MouseEvent) => Promise<void>) | ((_e: MouseEvent) => void);
|
||||
closeOnClick?: boolean;
|
||||
class?: string;
|
||||
children?: JSXElement;
|
||||
}
|
||||
|
||||
export function MenuItem(props: MenuItemProps) {
|
||||
const { closeMenu } = useMenuContext();
|
||||
|
||||
const handleClick = (e?: MouseEvent) => {
|
||||
if (props.disabled) return;
|
||||
props.onclick?.(e);
|
||||
if (props.closeOnClick !== false) closeMenu();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleClick();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button type="button" role="menuitem" class={ITEM_CLS + " " + (props.class || "")} onclick={handleClick} onKeyDown={handleKeyDown} disabled={props.disabled}>
|
||||
<Show when={props.icon}>
|
||||
<Icon icon={props.icon!} size={16} class="shrink-0"/>
|
||||
</Show>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuLinkProps {
|
||||
href: string;
|
||||
icon?: string;
|
||||
class?: string;
|
||||
children?: JSXElement;
|
||||
}
|
||||
|
||||
export function MenuLink(props: MenuLinkProps) {
|
||||
const { closeMenu } = useMenuContext();
|
||||
|
||||
return (
|
||||
<a href={props.href} role="menuitem" class={ITEM_CLS + " " + (props.class || "")} onclick={closeMenu}>
|
||||
<Show when={props.icon}>
|
||||
<Icon icon={props.icon!} size={16} class="shrink-0"/>
|
||||
</Show>
|
||||
{props.children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuAnchorProps extends MenuLinkProps {
|
||||
target?: string;
|
||||
rel?: string;
|
||||
}
|
||||
|
||||
export function MenuAnchor(props: MenuAnchorProps) {
|
||||
const { closeMenu } = useMenuContext();
|
||||
|
||||
return (
|
||||
<a href={props.href} target={props.target ?? "_blank"} rel={props.rel ?? "noopener noreferrer"} role="menuitem" class={ITEM_CLS + " " + (props.class || "")} onclick={closeMenu}>
|
||||
<Show when={props.icon}>
|
||||
<Icon icon={props.icon!} size={16} class="shrink-0"/>
|
||||
</Show>
|
||||
{props.children}
|
||||
<Show when={props.target === "_blank"}>
|
||||
<Icon icon="arrow-right" size={12} class="shrink-0 ml-auto text-ink-faint"/>
|
||||
</Show>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuDivider(props: { class?: string }) {
|
||||
return <hr class={DIVIDER_CLS + " " + (props.class || "")} role="separator"/>;
|
||||
}
|
||||
|
||||
interface MenuSectionProps {
|
||||
class?: string;
|
||||
children?: JSXElement;
|
||||
}
|
||||
|
||||
export function MenuSection(props: MenuSectionProps) {
|
||||
return (
|
||||
<div class={SECTION_CLS + " " + (props.class || "")} role="presentation">
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SubmenuProps {
|
||||
trigger: string;
|
||||
icon?: string;
|
||||
class?: string;
|
||||
children?: JSXElement;
|
||||
}
|
||||
|
||||
interface SubmenuPosition {
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
export function Submenu(props: SubmenuProps) {
|
||||
const parentMenu = useMenuContext();
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
const [position, setPosition] = createSignal<SubmenuPosition | null>(null);
|
||||
let closeTimeoutRef: ReturnType<typeof setTimeout> | null = null;
|
||||
let triggerRef: HTMLDivElement | undefined;
|
||||
let contentRef: HTMLDivElement | undefined;
|
||||
|
||||
const clearCloseTimeout = () => {
|
||||
if (closeTimeoutRef) { clearTimeout(closeTimeoutRef); closeTimeoutRef = null; }
|
||||
};
|
||||
|
||||
const scheduleClose = (delay: number) => {
|
||||
clearCloseTimeout();
|
||||
closeTimeoutRef = setTimeout(() => setIsOpen(false), delay);
|
||||
};
|
||||
|
||||
const handleTriggerMouseEnter = () => {
|
||||
clearCloseTimeout();
|
||||
parentMenu.cancelParentClose?.();
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => scheduleClose(parentMenu.hoverCloseDelay);
|
||||
|
||||
const handleContentMouseEnter = () => {
|
||||
clearCloseTimeout();
|
||||
parentMenu.cancelParentClose?.();
|
||||
};
|
||||
|
||||
const handleClick = () => setIsOpen((prev) => !prev);
|
||||
|
||||
createEffect(() => {
|
||||
if (!isOpen() || !triggerRef) {
|
||||
setPosition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const updatePosition = () => {
|
||||
if (!triggerRef) return;
|
||||
const triggerRect = triggerRef.getBoundingClientRect();
|
||||
const contentEl = contentRef;
|
||||
|
||||
let top = triggerRect.top;
|
||||
let left = triggerRect.right;
|
||||
|
||||
if (contentEl) {
|
||||
const contentRect = contentEl.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
if (left + contentRect.width > vw - 8) left = triggerRect.left - contentRect.width;
|
||||
if (top + contentRect.height > vh - 8) top = vh - contentRect.height - 8;
|
||||
if (top < 8) top = 8;
|
||||
}
|
||||
|
||||
setPosition({ top, left });
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
window.addEventListener("scroll", updatePosition, true);
|
||||
window.addEventListener("resize", updatePosition);
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("scroll", updatePosition, true);
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
});
|
||||
});
|
||||
|
||||
onCleanup(clearCloseTimeout);
|
||||
|
||||
return (
|
||||
<MenuContext.Provider value={{
|
||||
openOnHover: parentMenu.openOnHover,
|
||||
hoverCloseDelay: parentMenu.hoverCloseDelay,
|
||||
closeMenu: parentMenu.closeMenu,
|
||||
cancelParentClose: clearCloseTimeout,
|
||||
}}>
|
||||
<div ref={(el: HTMLDivElement) => triggerRef = el} role="menuitem" aria-haspopup="menu" aria-expanded={isOpen()} class={SUBMENU_TRIGGER_CLS + " " + (props.class || "")} onMouseEnter={handleTriggerMouseEnter} onMouseLeave={handleMouseLeave} onclick={handleClick}>
|
||||
<span class="flex items-center gap-2">
|
||||
<Show when={props.icon}>
|
||||
<Icon icon={props.icon!} size={16} class="shrink-0"/>
|
||||
</Show>
|
||||
{props.trigger}
|
||||
</span>
|
||||
<Icon icon="chevron-right" size={16} class="shrink-0 ml-auto text-ink-faint"/>
|
||||
</div>
|
||||
|
||||
<div ref={(el: HTMLDivElement) => contentRef = el} role="menu" class={MENU_CLS} style={{
|
||||
position: "fixed",
|
||||
display: isOpen() ? "block" : "none",
|
||||
top: (position()?.top ?? 0) + "px",
|
||||
left: (position()?.left ?? 0) + "px",
|
||||
"z-index": 51,
|
||||
}} onMouseEnter={handleContentMouseEnter} onMouseLeave={handleMouseLeave}>
|
||||
{props.children}
|
||||
</div>
|
||||
</MenuContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuGroupProps {
|
||||
class?: string;
|
||||
children?: JSXElement;
|
||||
}
|
||||
|
||||
export function MenuGroup(props: MenuGroupProps) {
|
||||
return <div role="group" class={props.class || ""}>{props.children}</div>;
|
||||
}
|
||||
Reference in New Issue
Block a user