PettyUI/packages/core/src/components/popover/popover-content.tsx
Mats Bosson 8f075f1792 feat: add 12 components — Tooltip, Popover, HoverCard, Alert, Badge,
Skeleton, Breadcrumbs, Link, Button, Image, Meter, NumberField
Floating components: Tooltip (hover/focus), Popover (click, with focus
trap and dismiss), HoverCard (hover with safe area).
Simple components: Alert (role=alert), Badge (role=status), Skeleton
(loading placeholder with data attributes).
Navigation: Breadcrumbs (nav>ol>li with separators), Link (accessible
anchor with disabled), Button (with disabled click suppression).
Data/Form: Image (Img+Fallback with loading status), Meter (like
Progress for known ranges), NumberField (spinbutton with inc/dec).
302 tests across 46 files, typecheck clean, build produces 176 files.
2026-03-29 19:34:13 +07:00

85 lines
2.7 KiB
TypeScript

// packages/core/src/components/popover/popover-content.tsx
import type { JSX } from "solid-js";
import { Show, createEffect, onCleanup, splitProps } from "solid-js";
import { createDismiss } from "../../utilities/dismiss/create-dismiss";
import { createFocusTrap } from "../../utilities/focus-trap/create-focus-trap";
import { createScrollLock } from "../../utilities/scroll-lock/create-scroll-lock";
import { useInternalPopoverContext } from "./popover-context";
export interface PopoverContentProps extends JSX.HTMLAttributes<HTMLDivElement> {
/** Keep mounted even when closed (for animation control). */
forceMount?: boolean | undefined;
children?: JSX.Element;
}
/**
* Popover content panel with `role="dialog"`. Uses floating positioning.
* When modal: focus trap + scroll lock. When non-modal: Tab closes popover.
* Wrap with Popover.Portal to render outside the DOM tree.
*/
export function PopoverContent(props: PopoverContentProps): JSX.Element {
const [local, rest] = splitProps(props, ["children", "forceMount", "style"]);
const ctx = useInternalPopoverContext();
const focusTrap = createFocusTrap(() => ctx.contentRef());
const scrollLock = createScrollLock();
const dismiss = createDismiss({
getContainer: () => ctx.contentRef(),
onDismiss: () => ctx.setOpen(false),
});
/** Non-modal Tab handler: Tab closes popover instead of trapping focus. */
const handleKeyDown: JSX.EventHandler<HTMLDivElement, KeyboardEvent> = (e) => {
if (!ctx.modal() && e.key === "Tab") {
ctx.setOpen(false);
}
};
createEffect(() => {
if (ctx.isOpen()) {
dismiss.attach();
if (ctx.modal()) {
focusTrap.activate();
scrollLock.lock();
}
} else {
focusTrap.deactivate();
scrollLock.unlock();
dismiss.detach();
}
onCleanup(() => {
focusTrap.deactivate();
scrollLock.unlock();
dismiss.detach();
});
});
return (
<Show when={local.forceMount || ctx.isOpen()}>
<div
ref={(el) => ctx.setContentRef(el)}
id={ctx.contentId()}
role="dialog"
aria-modal={ctx.modal() || undefined}
data-state={ctx.isOpen() ? "open" : "closed"}
style={
typeof local.style === "string"
? `${styleToString(ctx.floatingStyle())};${local.style}`
: { ...ctx.floatingStyle(), ...(local.style as JSX.CSSProperties) }
}
onKeyDown={handleKeyDown}
{...rest}
>
{local.children}
</div>
</Show>
);
}
/** Convert a JSX.CSSProperties object to an inline CSS string. */
function styleToString(style: JSX.CSSProperties): string {
return Object.entries(style)
.map(([k, v]) => `${k}:${v}`)
.join(";");
}