restructure project, add claudemd

This commit is contained in:
2026-07-08 16:36:17 -04:00
parent a7964f9410
commit 2a5fbffaa2
315 changed files with 81075 additions and 0 deletions

328
web/kit/Menu.tsx Normal file
View 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-white rounded-default shadow-lg border border-neutral-200 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-neutral-700 bg-transparent border-0 cursor-pointer hover:bg-neutral-100 hover:text-neutral-900 focus:bg-neutral-100 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-neutral-200";
const SECTION_CLS = "pt-1.5 pb-0.5 px-2 text-[10px] font-semibold text-neutral-400 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-neutral-700 cursor-pointer hover:bg-neutral-100 hover:text-neutral-900";
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-neutral-400"/>
</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-neutral-400"/>
</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>;
}