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.
This commit is contained in:
Mats Bosson 2026-03-29 19:34:13 +07:00
parent 2a07d9ceaa
commit 8f075f1792
62 changed files with 2408 additions and 8 deletions

View File

@ -59,7 +59,19 @@
"./listbox": { "solid": "./src/components/listbox/index.ts", "import": "./dist/components/listbox/index.js", "require": "./dist/components/listbox/index.cjs" },
"./dropdown-menu": { "solid": "./src/components/dropdown-menu/index.ts", "import": "./dist/components/dropdown-menu/index.js", "require": "./dist/components/dropdown-menu/index.cjs" },
"./context-menu": { "solid": "./src/components/context-menu/index.ts", "import": "./dist/components/context-menu/index.js", "require": "./dist/components/context-menu/index.cjs" },
"./toast": { "solid": "./src/components/toast/index.ts", "import": "./dist/components/toast/index.js", "require": "./dist/components/toast/index.cjs" }
"./toast": { "solid": "./src/components/toast/index.ts", "import": "./dist/components/toast/index.js", "require": "./dist/components/toast/index.cjs" },
"./tooltip": { "solid": "./src/components/tooltip/index.ts", "import": "./dist/components/tooltip/index.js", "require": "./dist/components/tooltip/index.cjs" },
"./alert": { "solid": "./src/components/alert/index.ts", "import": "./dist/components/alert/index.js", "require": "./dist/components/alert/index.cjs" },
"./badge": { "solid": "./src/components/badge/index.ts", "import": "./dist/components/badge/index.js", "require": "./dist/components/badge/index.cjs" },
"./skeleton": { "solid": "./src/components/skeleton/index.ts", "import": "./dist/components/skeleton/index.js", "require": "./dist/components/skeleton/index.cjs" },
"./popover": { "solid": "./src/components/popover/index.ts", "import": "./dist/components/popover/index.js", "require": "./dist/components/popover/index.cjs" },
"./breadcrumbs": { "solid": "./src/components/breadcrumbs/index.ts", "import": "./dist/components/breadcrumbs/index.js", "require": "./dist/components/breadcrumbs/index.cjs" },
"./link": { "solid": "./src/components/link/index.ts", "import": "./dist/components/link/index.js", "require": "./dist/components/link/index.cjs" },
"./button": { "solid": "./src/components/button/index.ts", "import": "./dist/components/button/index.js", "require": "./dist/components/button/index.cjs" },
"./hover-card": { "solid": "./src/components/hover-card/index.ts", "import": "./dist/components/hover-card/index.js", "require": "./dist/components/hover-card/index.cjs" },
"./image": { "solid": "./src/components/image/index.ts", "import": "./dist/components/image/index.js", "require": "./dist/components/image/index.cjs" },
"./meter": { "solid": "./src/components/meter/index.ts", "import": "./dist/components/meter/index.js", "require": "./dist/components/meter/index.cjs" },
"./number-field": { "solid": "./src/components/number-field/index.ts", "import": "./dist/components/number-field/index.js", "require": "./dist/components/number-field/index.cjs" }
},
"scripts": {
"build": "tsdown",

View File

@ -0,0 +1,16 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
export interface AlertProps extends JSX.HTMLAttributes<HTMLDivElement> {
children?: JSX.Element;
}
/** An alert element that announces important messages to screen readers via role="alert". */
export function Alert(props: AlertProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
return (
<div role="alert" {...rest}>
{local.children}
</div>
);
}

View File

@ -0,0 +1,2 @@
export { Alert } from "./alert";
export type { AlertProps } from "./alert";

View File

@ -0,0 +1,16 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
export interface BadgeProps extends JSX.HTMLAttributes<HTMLSpanElement> {
children?: JSX.Element;
}
/** A status badge that announces its content to screen readers via role="status". */
export function Badge(props: BadgeProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
return (
<span role="status" {...rest}>
{local.children}
</span>
);
}

View File

@ -0,0 +1,2 @@
export { Badge } from "./badge";
export type { BadgeProps } from "./badge";

View File

@ -0,0 +1,20 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
/** Props for a breadcrumbs list item. */
export interface BreadcrumbsItemProps extends JSX.LiHTMLAttributes<HTMLLIElement> {
/** When true, marks this item as the current page via aria-current="page". */
current?: boolean | undefined;
children?: JSX.Element | undefined;
}
/** A single item in the breadcrumbs trail. Renders as `<li>`. */
export function BreadcrumbsItem(props: BreadcrumbsItemProps): JSX.Element {
const [local, rest] = splitProps(props, ["current", "children"]);
return (
<li aria-current={local.current ? "page" : undefined} {...rest}>
{local.children}
</li>
);
}

View File

@ -0,0 +1,20 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
/** Props for a breadcrumbs link anchor element. */
export interface BreadcrumbsLinkProps extends JSX.AnchorHTMLAttributes<HTMLAnchorElement> {
/** The URL the breadcrumb link navigates to. */
href: string;
children?: JSX.Element | undefined;
}
/** An anchor link inside a breadcrumbs item. */
export function BreadcrumbsLink(props: BreadcrumbsLinkProps): JSX.Element {
const [local, rest] = splitProps(props, ["href", "children"]);
return (
<a href={local.href} {...rest}>
{local.children}
</a>
);
}

View File

@ -0,0 +1,23 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
/** Props for the root breadcrumbs navigation container. */
export interface BreadcrumbsRootProps extends JSX.HTMLAttributes<HTMLElement> {
/** Accessible label for the navigation landmark. @default "Breadcrumbs" */
"aria-label"?: string | undefined;
children: JSX.Element;
}
/**
* Root navigation container for breadcrumbs.
* Renders a `<nav>` with an `<ol>` list inside.
*/
export function BreadcrumbsRoot(props: BreadcrumbsRootProps): JSX.Element {
const [local, rest] = splitProps(props, ["aria-label", "children"]);
return (
<nav aria-label={local["aria-label"] ?? "Breadcrumbs"} {...rest}>
<ol>{local.children}</ol>
</nav>
);
}

View File

@ -0,0 +1,18 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
/** Props for the breadcrumbs separator element. */
export interface BreadcrumbsSeparatorProps extends JSX.LiHTMLAttributes<HTMLLIElement> {
children?: JSX.Element | undefined;
}
/** A decorative separator between breadcrumb items. Hidden from assistive technology. */
export function BreadcrumbsSeparator(props: BreadcrumbsSeparatorProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
return (
<li role="presentation" aria-hidden="true" {...rest}>
{local.children}
</li>
);
}

View File

@ -0,0 +1,16 @@
import { BreadcrumbsItem } from "./breadcrumbs-item";
import { BreadcrumbsLink } from "./breadcrumbs-link";
import { BreadcrumbsRoot } from "./breadcrumbs-root";
import { BreadcrumbsSeparator } from "./breadcrumbs-separator";
/** Compound breadcrumbs component with Item, Link, and Separator sub-components. */
export const Breadcrumbs = Object.assign(BreadcrumbsRoot, {
Item: BreadcrumbsItem,
Link: BreadcrumbsLink,
Separator: BreadcrumbsSeparator,
});
export type { BreadcrumbsRootProps } from "./breadcrumbs-root";
export type { BreadcrumbsItemProps } from "./breadcrumbs-item";
export type { BreadcrumbsLinkProps } from "./breadcrumbs-link";
export type { BreadcrumbsSeparatorProps } from "./breadcrumbs-separator";

View File

@ -0,0 +1,38 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
/** Props for the accessible Button component. */
export interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
/** Button type attribute. @default "button" */
type?: "button" | "submit" | "reset" | undefined;
/** When true, disables the button and prevents click handlers. */
disabled?: boolean | undefined;
children?: JSX.Element | undefined;
}
/**
* An accessible button component.
* Defaults to type="button" and suppresses onClick when disabled.
*/
export function Button(props: ButtonProps): JSX.Element {
const [local, rest] = splitProps(props, ["type", "disabled", "children", "onClick"]);
const handleClick: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> = (e) => {
if (local.disabled) return;
if (typeof local.onClick === "function") {
local.onClick(e);
}
};
return (
<button
type={local.type ?? "button"}
disabled={local.disabled}
data-disabled={local.disabled || undefined}
onClick={handleClick}
{...rest}
>
{local.children}
</button>
);
}

View File

@ -0,0 +1,2 @@
export { Button } from "./button";
export type { ButtonProps } from "./button";

View File

@ -0,0 +1,59 @@
import type { JSX } from "solid-js";
import { Show, createEffect, onCleanup, splitProps } from "solid-js";
import { useInternalHoverCardContext } from "./hover-card-context";
export interface HoverCardContentProps extends JSX.HTMLAttributes<HTMLDivElement> {
/** Keep mounted when closed. @default false */
forceMount?: boolean | undefined;
children?: JSX.Element;
}
/**
* Floating content panel for HoverCard. Displayed when the trigger is hovered.
* No role is set since hover card content is supplementary (not announced).
* Handles Escape to close and pointer safe-area so the user can move
* from the trigger into the content without triggering a close.
*/
export function HoverCardContent(props: HoverCardContentProps): JSX.Element {
const [local, rest] = splitProps(props, ["children", "forceMount"]);
const ctx = useInternalHoverCardContext();
const handlePointerEnter: JSX.EventHandler<HTMLDivElement, PointerEvent> = () => {
ctx.cancelTimers();
};
const handlePointerLeave: JSX.EventHandler<HTMLDivElement, PointerEvent> = () => {
ctx.scheduleClose();
};
createEffect(() => {
if (!ctx.isOpen()) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
ctx.cancelTimers();
ctx.close();
}
};
document.addEventListener("keydown", handleKeyDown);
onCleanup(() => document.removeEventListener("keydown", handleKeyDown));
});
return (
<Show when={local.forceMount || ctx.isOpen()}>
<div
ref={(el) => ctx.setContentRef(el)}
id={ctx.contentId}
data-state={ctx.isOpen() ? "open" : "closed"}
style={ctx.floatingStyle()}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
{...rest}
>
{local.children}
</div>
</Show>
);
}

View File

@ -0,0 +1,68 @@
import type { Accessor, JSX } from "solid-js";
import { createContext, useContext } from "solid-js";
/** Internal context shared between all HoverCard sub-components. */
export interface InternalHoverCardContextValue {
isOpen: Accessor<boolean>;
open: () => void;
close: () => void;
triggerRef: Accessor<HTMLElement | null>;
setTriggerRef: (el: HTMLElement | null) => void;
contentRef: Accessor<HTMLElement | null>;
setContentRef: (el: HTMLElement | null) => void;
contentId: string;
triggerId: string;
floatingStyle: Accessor<JSX.CSSProperties>;
/** Open delay in ms. */
openDelay: number;
/** Close delay in ms. */
closeDelay: number;
/** Schedule open after openDelay. */
scheduleOpen: () => void;
/** Schedule close after closeDelay. */
scheduleClose: () => void;
/** Cancel any pending open/close timer. */
cancelTimers: () => void;
}
const InternalHoverCardContext = createContext<InternalHoverCardContextValue>();
/**
* Returns the internal HoverCard context. Throws if used outside HoverCard.
*/
export function useInternalHoverCardContext(): InternalHoverCardContextValue {
const ctx = useContext(InternalHoverCardContext);
if (!ctx) {
throw new Error(
"[PettyUI] HoverCard parts must be used inside <HoverCard>.\n" +
" Fix: <HoverCard>\n" +
" <HoverCard.Trigger>...</HoverCard.Trigger>\n" +
" <HoverCard.Content>...</HoverCard.Content>\n" +
" </HoverCard>",
);
}
return ctx;
}
export const InternalHoverCardContextProvider = InternalHoverCardContext.Provider;
/** Public context exposed via HoverCard.useContext(). */
export interface HoverCardContextValue {
/** Whether the hover card is open. */
open: Accessor<boolean>;
}
const HoverCardPublicContext = createContext<HoverCardContextValue>();
/**
* Returns the public HoverCard context. Throws if used outside HoverCard.
*/
export function useHoverCardContext(): HoverCardContextValue {
const ctx = useContext(HoverCardPublicContext);
if (!ctx) {
throw new Error("[PettyUI] HoverCard.useContext() called outside of <HoverCard>.");
}
return ctx;
}
export const HoverCardPublicContextProvider = HoverCardPublicContext.Provider;

View File

@ -0,0 +1,140 @@
import type { Middleware, Placement } from "@floating-ui/dom";
import { flip, offset, shift } from "@floating-ui/dom";
import type { Accessor, JSX } from "solid-js";
import { createSignal, createUniqueId, onCleanup, splitProps } from "solid-js";
import { createDisclosureState } from "../../primitives/create-disclosure-state";
import { createFloating } from "../../primitives/create-floating";
import { HoverCardContent } from "./hover-card-content";
import {
HoverCardPublicContextProvider,
InternalHoverCardContextProvider,
useHoverCardContext,
} from "./hover-card-context";
import type { InternalHoverCardContextValue } from "./hover-card-context";
import { HoverCardTrigger } from "./hover-card-trigger";
export interface HoverCardRootProps {
/** Controlled open state. */
open?: boolean | undefined;
/** Initial open state (uncontrolled). */
defaultOpen?: boolean | undefined;
/** Called when open state changes. */
onOpenChange?: ((open: boolean) => void) | undefined;
/** Delay in ms before opening. @default 700 */
openDelay?: number | undefined;
/** Delay in ms before closing. @default 300 */
closeDelay?: number | undefined;
children: JSX.Element;
}
/**
* Root component for HoverCard. Manages open state, floating
* positioning, and hover delay timers via context.
*/
export function HoverCardRoot(props: HoverCardRootProps): JSX.Element {
const [local] = splitProps(props, [
"open",
"defaultOpen",
"onOpenChange",
"openDelay",
"closeDelay",
"children",
]);
const triggerId = createUniqueId();
const contentId = createUniqueId();
const [triggerRef, setTriggerRef] = createSignal<HTMLElement | null>(null);
const [contentRef, setContentRef] = createSignal<HTMLElement | null>(null);
const openDelay = local.openDelay ?? 700;
const closeDelay = local.closeDelay ?? 300;
let openTimer: ReturnType<typeof setTimeout> | undefined;
let closeTimer: ReturnType<typeof setTimeout> | undefined;
const disclosure = createDisclosureState({
get open() {
return local.open;
},
get defaultOpen() {
return local.defaultOpen;
},
get onOpenChange() {
return local.onOpenChange;
},
});
const floating = createFloating({
anchor: triggerRef,
floating: contentRef,
placement: (() => "bottom") as Accessor<Placement>,
middleware: (() => [offset(8), flip(), shift({ padding: 8 })]) as Accessor<Middleware[]>,
open: disclosure.isOpen,
});
/** Cancel any pending open/close timer. */
const cancelTimers = () => {
if (openTimer !== undefined) {
clearTimeout(openTimer);
openTimer = undefined;
}
if (closeTimer !== undefined) {
clearTimeout(closeTimer);
closeTimer = undefined;
}
};
/** Schedule open after openDelay. */
const scheduleOpen = () => {
cancelTimers();
openTimer = setTimeout(() => {
disclosure.open();
openTimer = undefined;
}, openDelay);
};
/** Schedule close after closeDelay. */
const scheduleClose = () => {
cancelTimers();
closeTimer = setTimeout(() => {
disclosure.close();
closeTimer = undefined;
}, closeDelay);
};
onCleanup(cancelTimers);
const ctx: InternalHoverCardContextValue = {
isOpen: disclosure.isOpen,
open: disclosure.open,
close: disclosure.close,
triggerRef,
setTriggerRef,
contentRef,
setContentRef,
contentId,
triggerId,
floatingStyle: floating.style,
openDelay,
closeDelay,
scheduleOpen,
scheduleClose,
cancelTimers,
};
return (
<InternalHoverCardContextProvider value={ctx}>
<HoverCardPublicContextProvider value={{ open: disclosure.isOpen }}>
{local.children}
</HoverCardPublicContextProvider>
</InternalHoverCardContextProvider>
);
}
/** Compound HoverCard component with all sub-components as static properties. */
export const HoverCard = Object.assign(HoverCardRoot, {
Trigger: HoverCardTrigger,
Content: HoverCardContent,
useContext: useHoverCardContext,
});

View File

@ -0,0 +1,57 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useInternalHoverCardContext } from "./hover-card-context";
export interface HoverCardTriggerProps extends JSX.AnchorHTMLAttributes<HTMLAnchorElement> {
children?: JSX.Element;
}
/**
* Anchor element that triggers the HoverCard on pointer enter.
* Renders as an `<a>` since hover cards are typically used on links.
*/
export function HoverCardTrigger(props: HoverCardTriggerProps): JSX.Element {
const [local, rest] = splitProps(props, [
"children",
"onPointerEnter",
"onPointerLeave",
"onFocus",
"onBlur",
]);
const ctx = useInternalHoverCardContext();
const handlePointerEnter: JSX.EventHandler<HTMLAnchorElement, PointerEvent> = (e) => {
if (typeof local.onPointerEnter === "function") local.onPointerEnter(e);
ctx.scheduleOpen();
};
const handlePointerLeave: JSX.EventHandler<HTMLAnchorElement, PointerEvent> = (e) => {
if (typeof local.onPointerLeave === "function") local.onPointerLeave(e);
ctx.scheduleClose();
};
const handleFocus: JSX.EventHandler<HTMLAnchorElement, FocusEvent> = (e) => {
if (typeof local.onFocus === "function") local.onFocus(e);
ctx.scheduleOpen();
};
const handleBlur: JSX.EventHandler<HTMLAnchorElement, FocusEvent> = (e) => {
if (typeof local.onBlur === "function") local.onBlur(e);
ctx.scheduleClose();
};
return (
<a
ref={(el) => ctx.setTriggerRef(el)}
id={ctx.triggerId}
data-state={ctx.isOpen() ? "open" : "closed"}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
onFocus={handleFocus}
onBlur={handleBlur}
{...rest}
>
{local.children}
</a>
);
}

View File

@ -0,0 +1,9 @@
export { HoverCard, HoverCardRoot } from "./hover-card-root";
export { HoverCardTrigger } from "./hover-card-trigger";
export { HoverCardContent } from "./hover-card-content";
export { useHoverCardContext } from "./hover-card-context";
export type { HoverCardRootProps } from "./hover-card-root";
export type { HoverCardTriggerProps } from "./hover-card-trigger";
export type { HoverCardContentProps } from "./hover-card-content";
export type { HoverCardContextValue } from "./hover-card-context";

View File

@ -0,0 +1,30 @@
// packages/core/src/components/image/image-context.ts
import type { Accessor } from "solid-js";
import { createContext, useContext } from "solid-js";
export type ImageLoadingStatus = "loading" | "loaded" | "error";
export interface ImageContextValue {
loadingStatus: Accessor<ImageLoadingStatus>;
setLoadingStatus: (status: ImageLoadingStatus) => void;
src: Accessor<string | undefined>;
}
const ImageContext = createContext<ImageContextValue>();
/**
* Returns the Image context. Throws if used outside <Image>.
*/
export function useImageContext(): ImageContextValue {
const ctx = useContext(ImageContext);
if (!ctx) {
throw new Error(
"[PettyUI] Image parts must be used inside <Image>.\n" +
" Fix: Wrap Image.Img, Image.Fallback, etc. inside <Image>.",
);
}
return ctx;
}
/** Provider for Image context. */
export const ImageContextProvider = ImageContext.Provider;

View File

@ -0,0 +1,23 @@
// packages/core/src/components/image/image-fallback.tsx
import type { JSX } from "solid-js";
import { Show, splitProps } from "solid-js";
import { useImageContext } from "./image-context";
export interface ImageFallbackProps extends JSX.HTMLAttributes<HTMLSpanElement> {
/** Fallback content rendered when the image fails to load. */
children?: JSX.Element | undefined;
}
/**
* Shown when the image status is "error". Renders fallback content inside a <span>.
*/
export function ImageFallback(props: ImageFallbackProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
const ctx = useImageContext();
return (
<Show when={ctx.loadingStatus() === "error"}>
<span {...rest}>{local.children}</span>
</Show>
);
}

View File

@ -0,0 +1,44 @@
// packages/core/src/components/image/image-img.tsx
import type { JSX } from "solid-js";
import { onMount, splitProps } from "solid-js";
import { useImageContext } from "./image-context";
export interface ImageImgProps extends JSX.ImgHTMLAttributes<HTMLImageElement> {
/** Image source URL. */
src?: string | undefined;
/** Alternative text for the image. */
alt?: string | undefined;
}
/**
* The <img> element. Updates loading status context on load/error events.
*/
export function ImageImg(props: ImageImgProps): JSX.Element {
const [local, rest] = splitProps(props, ["src", "alt"]);
const ctx = useImageContext();
onMount(() => {
if (!local.src) {
ctx.setLoadingStatus("error");
}
});
const handleLoad = () => {
ctx.setLoadingStatus("loaded");
};
const handleError = () => {
ctx.setLoadingStatus("error");
};
return (
<img
src={local.src}
alt={local.alt}
data-state={ctx.loadingStatus()}
onLoad={handleLoad}
onError={handleError}
{...rest}
/>
);
}

View File

@ -0,0 +1,33 @@
// packages/core/src/components/image/image-root.tsx
import type { JSX } from "solid-js";
import { createSignal, splitProps } from "solid-js";
import {
ImageContextProvider,
type ImageContextValue,
type ImageLoadingStatus,
} from "./image-context";
export interface ImageRootProps extends JSX.HTMLAttributes<HTMLSpanElement> {
/** Child elements (Image.Img, Image.Fallback, etc.). */
children: JSX.Element;
}
/**
* Root container for Image. Provides loading status context to all Image parts.
*/
export function ImageRoot(props: ImageRootProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
const [loadingStatus, setLoadingStatus] = createSignal<ImageLoadingStatus>("loading");
const ctx: ImageContextValue = {
loadingStatus,
setLoadingStatus,
src: () => undefined,
};
return (
<ImageContextProvider value={ctx}>
<span {...rest}>{local.children}</span>
</ImageContextProvider>
);
}

View File

@ -0,0 +1,16 @@
// packages/core/src/components/image/index.ts
import { useImageContext } from "./image-context";
import { ImageFallback } from "./image-fallback";
import { ImageImg } from "./image-img";
import { ImageRoot } from "./image-root";
export const Image = Object.assign(ImageRoot, {
Img: ImageImg,
Fallback: ImageFallback,
useContext: useImageContext,
});
export type { ImageRootProps } from "./image-root";
export type { ImageImgProps } from "./image-img";
export type { ImageFallbackProps } from "./image-fallback";
export type { ImageContextValue, ImageLoadingStatus } from "./image-context";

View File

@ -0,0 +1,2 @@
export { Link } from "./link";
export type { LinkProps } from "./link";

View File

@ -0,0 +1,31 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
/** Props for the accessible Link component. */
export interface LinkProps extends JSX.AnchorHTMLAttributes<HTMLAnchorElement> {
/** The URL the link navigates to. Removed when disabled. */
href?: string | undefined;
/** When true, prevents navigation and marks the link as aria-disabled. */
disabled?: boolean | undefined;
children?: JSX.Element | undefined;
}
/**
* An accessible link component.
* When disabled, removes href and adds aria-disabled="true" with role="link".
*/
export function Link(props: LinkProps): JSX.Element {
const [local, rest] = splitProps(props, ["href", "disabled", "children"]);
return (
<a
href={local.disabled ? undefined : local.href}
role="link"
aria-disabled={local.disabled ? "true" : undefined}
data-disabled={local.disabled || undefined}
{...rest}
>
{local.children}
</a>
);
}

View File

@ -0,0 +1,2 @@
export { Meter } from "./meter";
export type { MeterProps } from "./meter";

View File

@ -0,0 +1,45 @@
// packages/core/src/components/meter/meter.tsx
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
export interface MeterProps extends JSX.HTMLAttributes<HTMLDivElement> {
/** Current value of the meter. */
value: number;
/** Minimum value. @default 0 */
min?: number | undefined;
/** Maximum value. @default 100 */
max?: number | undefined;
/** Custom label function for aria-valuetext. */
getValueLabel?: ((value: number, max: number) => string) | undefined;
}
/**
* Displays a scalar measurement within a known range (e.g., disk usage, password strength).
* Unlike Progress, Meter always has a determinate value.
*/
export function Meter(props: MeterProps): JSX.Element {
const [local, rest] = splitProps(props, ["value", "min", "max", "getValueLabel"]);
const min = () => local.min ?? 0;
const max = () => local.max ?? 100;
const valueLabel = (): string | undefined => {
if (local.getValueLabel) return local.getValueLabel(local.value, max());
return undefined;
};
return (
// biome-ignore lint/a11y/useFocusableInteractive: meter is read-only, not interactive
<div
role="meter"
aria-valuenow={local.value}
aria-valuemin={min()}
aria-valuemax={max()}
aria-valuetext={valueLabel()}
data-value={local.value}
data-min={min()}
data-max={max()}
{...rest}
/>
);
}

View File

@ -0,0 +1,28 @@
// packages/core/src/components/number-field/index.ts
import { useNumberFieldContext } from "./number-field-context";
import { NumberFieldDecrementTrigger } from "./number-field-decrement-trigger";
import { NumberFieldDescription } from "./number-field-description";
import { NumberFieldErrorMessage } from "./number-field-error-message";
import { NumberFieldIncrementTrigger } from "./number-field-increment-trigger";
import { NumberFieldInput } from "./number-field-input";
import { NumberFieldLabel } from "./number-field-label";
import { NumberFieldRoot } from "./number-field-root";
export const NumberField = Object.assign(NumberFieldRoot, {
Label: NumberFieldLabel,
Input: NumberFieldInput,
IncrementTrigger: NumberFieldIncrementTrigger,
DecrementTrigger: NumberFieldDecrementTrigger,
Description: NumberFieldDescription,
ErrorMessage: NumberFieldErrorMessage,
useContext: useNumberFieldContext,
});
export type { NumberFieldRootProps } from "./number-field-root";
export type { NumberFieldLabelProps } from "./number-field-label";
export type { NumberFieldInputProps } from "./number-field-input";
export type { NumberFieldIncrementTriggerProps } from "./number-field-increment-trigger";
export type { NumberFieldDecrementTriggerProps } from "./number-field-decrement-trigger";
export type { NumberFieldDescriptionProps } from "./number-field-description";
export type { NumberFieldErrorMessageProps } from "./number-field-error-message";
export type { NumberFieldContextValue } from "./number-field-context";

View File

@ -0,0 +1,38 @@
// packages/core/src/components/number-field/number-field-context.ts
import type { Accessor } from "solid-js";
import { createContext, useContext } from "solid-js";
export interface NumberFieldContextValue {
value: Accessor<number>;
setValue: (value: number) => void;
inputId: Accessor<string>;
descriptionId: Accessor<string | undefined>;
errorMessageId: Accessor<string | undefined>;
min: Accessor<number | undefined>;
max: Accessor<number | undefined>;
step: Accessor<number>;
disabled: Accessor<boolean>;
increment: () => void;
decrement: () => void;
setDescriptionId: (id: string | undefined) => void;
setErrorMessageId: (id: string | undefined) => void;
}
const NumberFieldContext = createContext<NumberFieldContextValue>();
/**
* Returns the NumberField context. Throws if used outside <NumberField>.
*/
export function useNumberFieldContext(): NumberFieldContextValue {
const ctx = useContext(NumberFieldContext);
if (!ctx) {
throw new Error(
"[PettyUI] NumberField parts must be used inside <NumberField>.\n" +
" Fix: Wrap NumberField.Input, NumberField.Label, etc. inside <NumberField>.",
);
}
return ctx;
}
/** Provider for NumberField context. */
export const NumberFieldContextProvider = NumberFieldContext.Provider;

View File

@ -0,0 +1,48 @@
// packages/core/src/components/number-field/number-field-decrement-trigger.tsx
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useNumberFieldContext } from "./number-field-context";
export interface NumberFieldDecrementTriggerProps
extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
/** Button content. */
children?: JSX.Element | undefined;
}
/**
* Button that decrements the number field value by the step amount.
* Automatically disabled when the value reaches the minimum.
*/
export function NumberFieldDecrementTrigger(
props: NumberFieldDecrementTriggerProps,
): JSX.Element {
const [local, rest] = splitProps(props, ["children", "onClick", "disabled"]);
const ctx = useNumberFieldContext();
const isDisabled = (): boolean => {
if (local.disabled ?? ctx.disabled()) return true;
const minVal = ctx.min();
if (minVal !== undefined && ctx.value() <= minVal) return true;
return false;
};
const handleClick: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> = (e) => {
ctx.decrement();
if (typeof local.onClick === "function") {
local.onClick(e);
}
};
return (
<button
type="button"
tabIndex={-1}
aria-label="Decrement"
disabled={isDisabled()}
onClick={handleClick}
{...rest}
>
{local.children}
</button>
);
}

View File

@ -0,0 +1,23 @@
// packages/core/src/components/number-field/number-field-description.tsx
import type { JSX } from "solid-js";
import { createUniqueId, onCleanup, onMount, splitProps } from "solid-js";
import { useNumberFieldContext } from "./number-field-context";
export interface NumberFieldDescriptionProps extends JSX.HTMLAttributes<HTMLParagraphElement> {
/** Description content. */
children?: JSX.Element | undefined;
}
/** Helper text for the NumberField. Linked via aria-describedby. */
export function NumberFieldDescription(props: NumberFieldDescriptionProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
const ctx = useNumberFieldContext();
const id = createUniqueId();
onMount(() => ctx.setDescriptionId(id));
onCleanup(() => ctx.setDescriptionId(undefined));
return (
<p id={id} {...rest}>
{local.children}
</p>
);
}

View File

@ -0,0 +1,27 @@
// packages/core/src/components/number-field/number-field-error-message.tsx
import type { JSX } from "solid-js";
import { Show, createUniqueId, onCleanup, onMount, splitProps } from "solid-js";
import { useNumberFieldContext } from "./number-field-context";
export interface NumberFieldErrorMessageProps extends JSX.HTMLAttributes<HTMLParagraphElement> {
/** Error message content. */
children?: JSX.Element | undefined;
/** Whether the error is currently active. */
visible?: boolean | undefined;
}
/** Error message for the NumberField. Shown when visible is true. */
export function NumberFieldErrorMessage(props: NumberFieldErrorMessageProps): JSX.Element {
const [local, rest] = splitProps(props, ["children", "visible"]);
const ctx = useNumberFieldContext();
const id = createUniqueId();
onMount(() => ctx.setErrorMessageId(id));
onCleanup(() => ctx.setErrorMessageId(undefined));
return (
<Show when={local.visible}>
<p id={id} aria-live="polite" {...rest}>
{local.children}
</p>
</Show>
);
}

View File

@ -0,0 +1,48 @@
// packages/core/src/components/number-field/number-field-increment-trigger.tsx
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useNumberFieldContext } from "./number-field-context";
export interface NumberFieldIncrementTriggerProps
extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
/** Button content. */
children?: JSX.Element | undefined;
}
/**
* Button that increments the number field value by the step amount.
* Automatically disabled when the value reaches the maximum.
*/
export function NumberFieldIncrementTrigger(
props: NumberFieldIncrementTriggerProps,
): JSX.Element {
const [local, rest] = splitProps(props, ["children", "onClick", "disabled"]);
const ctx = useNumberFieldContext();
const isDisabled = (): boolean => {
if (local.disabled ?? ctx.disabled()) return true;
const maxVal = ctx.max();
if (maxVal !== undefined && ctx.value() >= maxVal) return true;
return false;
};
const handleClick: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> = (e) => {
ctx.increment();
if (typeof local.onClick === "function") {
local.onClick(e);
}
};
return (
<button
type="button"
tabIndex={-1}
aria-label="Increment"
disabled={isDisabled()}
onClick={handleClick}
{...rest}
>
{local.children}
</button>
);
}

View File

@ -0,0 +1,48 @@
// packages/core/src/components/number-field/number-field-input.tsx
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useNumberFieldContext } from "./number-field-context";
export type NumberFieldInputProps = JSX.InputHTMLAttributes<HTMLInputElement>;
/**
* The spinbutton input element. Supports ArrowUp/ArrowDown keyboard navigation.
*/
export function NumberFieldInput(props: NumberFieldInputProps): JSX.Element {
const [local, rest] = splitProps(props, [
"id",
"aria-describedby",
"disabled",
"onKeyDown",
]);
const ctx = useNumberFieldContext();
const handleKeyDown: JSX.EventHandlerUnion<HTMLInputElement, KeyboardEvent> = (e) => {
if (e.key === "ArrowUp") {
e.preventDefault();
ctx.increment();
} else if (e.key === "ArrowDown") {
e.preventDefault();
ctx.decrement();
}
if (typeof local.onKeyDown === "function") {
local.onKeyDown(e);
}
};
return (
<input
id={local.id ?? ctx.inputId()}
role="spinbutton"
inputMode="numeric"
aria-valuenow={ctx.value()}
aria-valuemin={ctx.min()}
aria-valuemax={ctx.max()}
aria-describedby={local["aria-describedby"] ?? ctx.descriptionId()}
disabled={local.disabled ?? ctx.disabled()}
value={ctx.value()}
onKeyDown={handleKeyDown}
{...rest}
/>
);
}

View File

@ -0,0 +1,24 @@
// packages/core/src/components/number-field/number-field-label.tsx
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useNumberFieldContext } from "./number-field-context";
export interface NumberFieldLabelProps extends JSX.HTMLAttributes<HTMLLabelElement> {
/** Label content. */
children?: JSX.Element | undefined;
}
/** Label element linked to the NumberField input via htmlFor. */
export function NumberFieldLabel(props: NumberFieldLabelProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
const ctx = useNumberFieldContext();
return (
<label
for={ctx.inputId()}
data-disabled={ctx.disabled() || undefined}
{...rest}
>
{local.children}
</label>
);
}

View File

@ -0,0 +1,99 @@
// packages/core/src/components/number-field/number-field-root.tsx
import type { JSX } from "solid-js";
import { createUniqueId, splitProps } from "solid-js";
import { createControllableSignal } from "../../primitives/create-controllable-signal";
import { createRegisterId } from "../../primitives/create-register-id";
import {
NumberFieldContextProvider,
type NumberFieldContextValue,
} from "./number-field-context";
export interface NumberFieldRootProps extends JSX.HTMLAttributes<HTMLDivElement> {
/** Controlled value. */
value?: number | undefined;
/** Default value when uncontrolled. @default 0 */
defaultValue?: number | undefined;
/** Called when the value changes. */
onValueChange?: ((value: number) => void) | undefined;
/** Minimum allowed value. */
min?: number | undefined;
/** Maximum allowed value. */
max?: number | undefined;
/** Step amount for increment/decrement. @default 1 */
step?: number | undefined;
/** Whether the field is disabled. */
disabled?: boolean | undefined;
/** Child elements. */
children: JSX.Element;
}
/**
* Root container for NumberField. Provides context with value, min/max/step, and increment/decrement.
*/
export function NumberFieldRoot(props: NumberFieldRootProps): JSX.Element {
const [local, rest] = splitProps(props, [
"value",
"defaultValue",
"onValueChange",
"min",
"max",
"step",
"disabled",
"children",
]);
const inputId = createUniqueId();
const [descriptionId, setDescriptionId] = createRegisterId();
const [errorMessageId, setErrorMessageId] = createRegisterId();
const [value, setValue] = createControllableSignal<number>({
value: () => local.value,
defaultValue: () => local.defaultValue ?? 0,
onChange: local.onValueChange,
});
const step = () => local.step ?? 1;
const min = () => local.min;
const max = () => local.max;
const increment = () => {
const next = value() + step();
const maxVal = max();
if (maxVal !== undefined && next > maxVal) return;
setValue(next);
};
const decrement = () => {
const next = value() - step();
const minVal = min();
if (minVal !== undefined && next < minVal) return;
setValue(next);
};
const ctx: NumberFieldContextValue = {
value,
setValue,
inputId: () => inputId,
descriptionId,
errorMessageId,
min,
max,
step,
disabled: () => local.disabled ?? false,
increment,
decrement,
setDescriptionId,
setErrorMessageId,
};
return (
<NumberFieldContextProvider value={ctx}>
<div
data-disabled={local.disabled || undefined}
{...rest}
>
{local.children}
</div>
</NumberFieldContextProvider>
);
}

View File

@ -0,0 +1,12 @@
import { PopoverClose } from "./popover-close";
import { PopoverContent } from "./popover-content";
import { usePopoverContext } from "./popover-context";
import { PopoverPortal } from "./popover-portal";
import { PopoverRoot } from "./popover-root";
import { PopoverTrigger } from "./popover-trigger";
export type { PopoverRootProps } from "./popover-root";
export type { PopoverContentProps } from "./popover-content";
export type { PopoverTriggerProps } from "./popover-trigger";
export type { PopoverCloseProps } from "./popover-close";
export type { PopoverPortalProps } from "./popover-portal";
export const Popover = Object.assign(PopoverRoot, { Content: PopoverContent, Trigger: PopoverTrigger, Close: PopoverClose, Portal: PopoverPortal, useContext: usePopoverContext });

View File

@ -0,0 +1,30 @@
// packages/core/src/components/popover/popover-close.tsx
import type { Component, JSX } from "solid-js";
import { mergeProps, splitProps } from "solid-js";
import { Dynamic } from "solid-js/web";
import { useInternalPopoverContext } from "./popover-context";
export interface PopoverCloseProps extends JSX.HTMLAttributes<HTMLButtonElement> {
/** Render as a different element or component. */
as?: string | Component;
children?: JSX.Element;
}
/** Closes the popover when clicked. Supports polymorphic rendering via `as`. */
export function PopoverClose(props: PopoverCloseProps): JSX.Element {
const [local, rest] = splitProps(props, ["as", "children", "onClick"]);
const ctx = useInternalPopoverContext();
const handleClick: JSX.EventHandler<HTMLElement, MouseEvent> = (e) => {
if (typeof local.onClick === "function")
local.onClick(e as MouseEvent & { currentTarget: HTMLButtonElement; target: Element });
ctx.setOpen(false);
};
const closeProps = mergeProps(rest, { onClick: handleClick });
return (
<Dynamic component={local.as ?? "button"} {...closeProps}>
{local.children}
</Dynamic>
);
}

View File

@ -0,0 +1,84 @@
// 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(";");
}

View File

@ -0,0 +1,72 @@
// packages/core/src/components/popover/popover-context.ts
import type { Accessor, JSX } from "solid-js";
import { createContext, useContext } from "solid-js";
// ─── Internal Context (used only by Popover parts) ──────────────────────────
/** @internal Shared state between all Popover sub-components. */
export interface InternalPopoverContextValue {
isOpen: Accessor<boolean>;
setOpen: (open: boolean) => void;
modal: Accessor<boolean>;
/** SSR-safe ID for Popover.Content element. */
contentId: Accessor<string>;
triggerRef: Accessor<HTMLElement | null>;
setTriggerRef: (el: HTMLElement | null) => void;
contentRef: Accessor<HTMLElement | null>;
setContentRef: (el: HTMLElement | null) => void;
/** Computed floating position styles. */
floatingStyle: Accessor<JSX.CSSProperties>;
}
const InternalPopoverContext = createContext<InternalPopoverContextValue>();
/**
* Returns the internal Popover context value.
* Throws if used outside of a Popover root.
*/
export function useInternalPopoverContext(): InternalPopoverContextValue {
const ctx = useContext(InternalPopoverContext);
if (!ctx) {
throw new Error(
"[PettyUI] Popover parts must be used inside <Popover>.\n" +
" Fix: Wrap your Popover.Content, Popover.Trigger, etc. inside <Popover>.\n" +
" Docs: https://pettyui.dev/components/popover#composition",
);
}
return ctx;
}
/** @internal Provider for the internal Popover context. */
export const InternalPopoverContextProvider = InternalPopoverContext.Provider;
// ─── Public Context (exported via Popover.useContext) ───────────────────────
/** Public context exposed to consumers via Popover.useContext(). */
export interface PopoverContextValue {
/** Whether the popover is currently open. */
open: Accessor<boolean>;
/** Whether the popover renders as a modal (traps focus, locks scroll). */
modal: Accessor<boolean>;
}
const PopoverPublicContext = createContext<PopoverContextValue>();
/**
* Returns the public Popover context value.
* Throws if used outside of a Popover root.
*/
export function usePopoverContext(): PopoverContextValue {
const ctx = useContext(PopoverPublicContext);
if (!ctx) {
throw new Error(
"[PettyUI] Popover.useContext() was called outside of a <Popover>.\n" +
" Fix: Call Popover.useContext() inside a component rendered within <Popover>.\n" +
" Docs: https://pettyui.dev/components/popover#context",
);
}
return ctx;
}
/** @internal Provider for the public Popover context. */
export const PopoverPublicContextProvider = PopoverPublicContext.Provider;

View File

@ -0,0 +1,18 @@
// packages/core/src/components/popover/popover-portal.tsx
import type { JSX } from "solid-js";
import { Portal } from "../../utilities/portal/portal";
export interface PopoverPortalProps {
/** Override the portal target container. */
target?: Element | null | undefined;
children: JSX.Element;
}
/** Renders children into a portal (defaults to document.body). */
export function PopoverPortal(props: PopoverPortalProps): JSX.Element {
return props.target !== undefined ? (
<Portal target={props.target}>{props.children}</Portal>
) : (
<Portal>{props.children}</Portal>
);
}

View File

@ -0,0 +1,87 @@
// packages/core/src/components/popover/popover-root.tsx
import type { Middleware, Placement } from "@floating-ui/dom";
import { flip, offset, shift } from "@floating-ui/dom";
import type { Accessor, JSX } from "solid-js";
import { createSignal, createUniqueId } from "solid-js";
import {
type CreateDisclosureStateOptions,
createDisclosureState,
} from "../../primitives/create-disclosure-state";
import { createFloating } from "../../primitives/create-floating";
import {
InternalPopoverContextProvider,
PopoverPublicContextProvider,
type InternalPopoverContextValue,
} from "./popover-context";
export interface PopoverRootProps {
/** Controlled open state. */
open?: boolean | undefined;
/** Default open state (uncontrolled). */
defaultOpen?: boolean | undefined;
/** Called when open state should change. */
onOpenChange?: ((open: boolean) => void) | undefined;
/**
* Whether the popover blocks outside interaction and traps focus.
* @default false
*/
modal?: boolean | undefined;
/**
* Floating UI placement.
* @default "bottom"
*/
placement?: Placement | undefined;
children: JSX.Element;
}
/**
* Root component for Popover. Manages open state, floating positioning,
* and provides context to all Popover parts. Renders no DOM elements.
*/
export function PopoverRoot(props: PopoverRootProps): JSX.Element {
const disclosure = createDisclosureState({
get open() {
return props.open;
},
get defaultOpen() {
return props.defaultOpen;
},
get onOpenChange() {
return props.onOpenChange;
},
} as CreateDisclosureStateOptions);
const contentId = createUniqueId();
const [triggerRef, setTriggerRef] = createSignal<HTMLElement | null>(null);
const [contentRef, setContentRef] = createSignal<HTMLElement | null>(null);
const floating = createFloating({
anchor: triggerRef,
floating: contentRef,
placement: (() => props.placement ?? "bottom") as Accessor<Placement>,
middleware: (() => [offset(8), flip(), shift({ padding: 8 })]) as Accessor<Middleware[]>,
open: disclosure.isOpen,
});
const internalCtx: InternalPopoverContextValue = {
isOpen: disclosure.isOpen,
setOpen: (open) => (open ? disclosure.open() : disclosure.close()),
modal: () => props.modal ?? false,
contentId: () => contentId,
triggerRef,
setTriggerRef,
contentRef,
setContentRef,
floatingStyle: floating.style,
};
return (
<InternalPopoverContextProvider value={internalCtx}>
<PopoverPublicContextProvider
value={{ open: disclosure.isOpen, modal: () => props.modal ?? false }}
>
{props.children}
</PopoverPublicContextProvider>
</InternalPopoverContextProvider>
);
}

View File

@ -0,0 +1,57 @@
// packages/core/src/components/popover/popover-trigger.tsx
import type { Component, JSX } from "solid-js";
import { mergeProps, splitProps } from "solid-js";
import { Dynamic } from "solid-js/web";
import { useInternalPopoverContext } from "./popover-context";
export interface PopoverTriggerProps
extends Omit<JSX.HTMLAttributes<HTMLButtonElement>, "children"> {
/** Render as a different element or component. */
as?: string | Component;
children?: JSX.Element | ((props: JSX.HTMLAttributes<HTMLElement>) => JSX.Element);
}
/**
* Toggles the popover when clicked. Supports polymorphic rendering via `as`
* and children-as-function for full control.
*/
export function PopoverTrigger(props: PopoverTriggerProps): JSX.Element {
const [local, rest] = splitProps(props, ["as", "children", "onClick"]);
const ctx = useInternalPopoverContext();
const handleClick: JSX.EventHandler<HTMLElement, MouseEvent> = (e) => {
if (typeof local.onClick === "function")
local.onClick(e as MouseEvent & { currentTarget: HTMLButtonElement; target: Element });
ctx.setOpen(!ctx.isOpen());
};
const triggerProps = mergeProps(rest, {
get "aria-haspopup"() {
return "dialog" as const;
},
get "aria-expanded"() {
return ctx.isOpen();
},
get "aria-controls"() {
return ctx.contentId();
},
get "data-state"() {
return ctx.isOpen() ? "open" : "closed";
},
onClick: handleClick,
});
if (typeof local.children === "function") {
return <>{local.children(triggerProps as JSX.HTMLAttributes<HTMLElement>)}</>;
}
return (
<Dynamic
component={local.as ?? "button"}
ref={(el: HTMLElement) => ctx.setTriggerRef(el)}
{...triggerProps}
>
{local.children}
</Dynamic>
);
}

View File

@ -0,0 +1,2 @@
export { Skeleton } from "./skeleton";
export type { SkeletonProps } from "./skeleton";

View File

@ -0,0 +1,33 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
export interface SkeletonProps extends JSX.HTMLAttributes<HTMLDivElement> {
/** Whether the skeleton is visible (animating). @default true */
visible?: boolean | undefined;
/** Render as a circle. @default false */
circle?: boolean | undefined;
/** Width in CSS units. */
width?: string | undefined;
/** Height in CSS units. */
height?: string | undefined;
}
/** A loading placeholder skeleton with data attributes for styling. */
export function Skeleton(props: SkeletonProps): JSX.Element {
const [local, rest] = splitProps(props, ["visible", "circle", "width", "height"]);
const isVisible = () => local.visible ?? true;
return (
<div
data-visible={isVisible() ? "" : undefined}
data-animate={isVisible() ? "" : undefined}
data-circle={local.circle ? "" : undefined}
aria-hidden="true"
style={{
...(local.width ? { width: local.width } : {}),
...(local.height ? { height: local.height } : {}),
}}
{...rest}
/>
);
}

View File

@ -0,0 +1,4 @@
export { Tooltip, TooltipRoot } from "./tooltip-root";
export { TooltipTrigger } from "./tooltip-trigger";
export { TooltipContent } from "./tooltip-content";
export { useTooltipContext } from "./tooltip-context";

View File

@ -0,0 +1,50 @@
import type { JSX } from "solid-js";
import { Show, splitProps } from "solid-js";
import { useInternalTooltipContext } from "./tooltip-context";
export interface TooltipContentProps extends JSX.HTMLAttributes<HTMLDivElement> {
/** Keep mounted when closed. @default false */
forceMount?: boolean | undefined;
children?: JSX.Element;
}
/**
* Floating content for Tooltip. Renders with `role="tooltip"` and
* is positioned relative to the trigger via floating-ui.
*/
export function TooltipContent(props: TooltipContentProps): JSX.Element {
const [local, rest] = splitProps(props, [
"children",
"forceMount",
"onPointerEnter",
"onPointerLeave",
]);
const ctx = useInternalTooltipContext();
const handlePointerEnter: JSX.EventHandler<HTMLDivElement, PointerEvent> = (e) => {
if (typeof local.onPointerEnter === "function") local.onPointerEnter(e);
ctx.cancelTimers();
};
const handlePointerLeave: JSX.EventHandler<HTMLDivElement, PointerEvent> = (e) => {
if (typeof local.onPointerLeave === "function") local.onPointerLeave(e);
ctx.closeDelayed();
};
return (
<Show when={local.forceMount || ctx.isOpen()}>
<div
ref={(el) => ctx.setContentRef(el)}
id={ctx.contentId}
role="tooltip"
data-state={ctx.isOpen() ? "open" : "closed"}
style={ctx.floatingStyle()}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
{...rest}
>
{local.children}
</div>
</Show>
);
}

View File

@ -0,0 +1,72 @@
import type { Accessor, JSX } from "solid-js";
import { createContext, useContext } from "solid-js";
// --- Internal Context (used only by Tooltip parts) ---
/** Internal context shared between all Tooltip sub-components. */
export interface InternalTooltipContextValue {
isOpen: Accessor<boolean>;
open: () => void;
close: () => void;
triggerRef: Accessor<HTMLElement | null>;
setTriggerRef: (el: HTMLElement | null) => void;
contentRef: Accessor<HTMLElement | null>;
setContentRef: (el: HTMLElement | null) => void;
contentId: string;
triggerId: string;
floatingStyle: Accessor<JSX.CSSProperties>;
openDelayed: () => void;
closeDelayed: () => void;
cancelTimers: () => void;
}
const InternalTooltipContext = createContext<InternalTooltipContextValue>();
/**
* Returns the internal Tooltip context value.
* Throws if used outside of a Tooltip root.
*/
export function useInternalTooltipContext(): InternalTooltipContextValue {
const ctx = useContext(InternalTooltipContext);
if (!ctx) {
throw new Error(
"[PettyUI] Tooltip parts must be used inside <Tooltip>.\n" +
" Fix: <Tooltip>\n" +
" <Tooltip.Trigger>...</Tooltip.Trigger>\n" +
" <Tooltip.Content>...</Tooltip.Content>\n" +
" </Tooltip>",
);
}
return ctx;
}
/** Provider for the internal Tooltip context. */
export const InternalTooltipContextProvider = InternalTooltipContext.Provider;
// --- Public Context (exported via Tooltip.useContext) ---
/** Public context exposed via Tooltip.useContext(). */
export interface TooltipContextValue {
/** Whether the tooltip is currently open. */
open: Accessor<boolean>;
}
const TooltipContext = createContext<TooltipContextValue>();
/**
* Returns the public Tooltip context value.
* Throws if used outside of a Tooltip root.
*/
export function useTooltipContext(): TooltipContextValue {
const ctx = useContext(TooltipContext);
if (!ctx) {
throw new Error(
"[PettyUI] Tooltip.useContext() was called outside of a <Tooltip>.\n" +
" Fix: Call Tooltip.useContext() inside a component rendered within <Tooltip>.",
);
}
return ctx;
}
/** Provider for the public Tooltip context. */
export const TooltipContextProvider = TooltipContext.Provider;

View File

@ -0,0 +1,151 @@
import type { Middleware, Placement } from "@floating-ui/dom";
import { flip, offset, shift } from "@floating-ui/dom";
import type { Accessor, JSX } from "solid-js";
import { createEffect, createSignal, createUniqueId, onCleanup, splitProps } from "solid-js";
import { createDisclosureState } from "../../primitives/create-disclosure-state";
import { createFloating } from "../../primitives/create-floating";
import { TooltipContent } from "./tooltip-content";
import {
InternalTooltipContextProvider,
TooltipContextProvider,
useTooltipContext,
} from "./tooltip-context";
import type { InternalTooltipContextValue } from "./tooltip-context";
import { TooltipTrigger } from "./tooltip-trigger";
export interface TooltipRootProps {
/** Controlled open state. */
open?: boolean | undefined;
/** Initial open state (uncontrolled). */
defaultOpen?: boolean | undefined;
/** Called when open state changes. */
onOpenChange?: ((open: boolean) => void) | undefined;
/** Delay in ms before the tooltip opens on hover. @default 700 */
openDelay?: number | undefined;
/** Delay in ms before the tooltip closes after leaving. @default 300 */
closeDelay?: number | undefined;
children: JSX.Element;
}
/**
* Root component for Tooltip. Manages open state, delay timers,
* floating positioning, and Escape key dismissal via context.
*/
export function TooltipRoot(props: TooltipRootProps): JSX.Element {
const [local] = splitProps(props, [
"open",
"defaultOpen",
"onOpenChange",
"openDelay",
"closeDelay",
"children",
]);
const triggerId = createUniqueId();
const contentId = createUniqueId();
const [triggerRef, setTriggerRef] = createSignal<HTMLElement | null>(null);
const [contentRef, setContentRef] = createSignal<HTMLElement | null>(null);
const disclosure = createDisclosureState({
get open() {
return local.open;
},
get defaultOpen() {
return local.defaultOpen;
},
get onOpenChange() {
return local.onOpenChange;
},
});
const floating = createFloating({
anchor: triggerRef,
floating: contentRef,
placement: (() => "top") as Accessor<Placement>,
middleware: (() => [offset(8), flip(), shift({ padding: 8 })]) as Accessor<Middleware[]>,
open: disclosure.isOpen,
});
// --- Delay timers ---
let openTimer: ReturnType<typeof setTimeout> | undefined;
let closeTimer: ReturnType<typeof setTimeout> | undefined;
const cancelTimers = () => {
if (openTimer !== undefined) {
clearTimeout(openTimer);
openTimer = undefined;
}
if (closeTimer !== undefined) {
clearTimeout(closeTimer);
closeTimer = undefined;
}
};
const openDelayed = () => {
cancelTimers();
const delay = local.openDelay ?? 700;
openTimer = setTimeout(() => {
disclosure.open();
}, delay);
};
const closeDelayed = () => {
cancelTimers();
const delay = local.closeDelay ?? 300;
closeTimer = setTimeout(() => {
disclosure.close();
}, delay);
};
// --- Escape key listener ---
createEffect(() => {
if (!disclosure.isOpen()) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
cancelTimers();
disclosure.close();
}
};
document.addEventListener("keydown", handleKeyDown);
onCleanup(() => document.removeEventListener("keydown", handleKeyDown));
});
// Clean up timers on disposal
onCleanup(cancelTimers);
const ctx: InternalTooltipContextValue = {
isOpen: disclosure.isOpen,
open: disclosure.open,
close: disclosure.close,
triggerRef,
setTriggerRef,
contentRef,
setContentRef,
contentId,
triggerId,
floatingStyle: floating.style,
openDelayed,
closeDelayed,
cancelTimers,
};
return (
<InternalTooltipContextProvider value={ctx}>
<TooltipContextProvider value={{ open: disclosure.isOpen }}>
{local.children}
</TooltipContextProvider>
</InternalTooltipContextProvider>
);
}
/** Compound Tooltip component with all sub-components as static properties. */
export const Tooltip = Object.assign(TooltipRoot, {
Trigger: TooltipTrigger,
Content: TooltipContent,
useContext: useTooltipContext,
});

View File

@ -0,0 +1,58 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useInternalTooltipContext } from "./tooltip-context";
export interface TooltipTriggerProps extends JSX.HTMLAttributes<HTMLElement> {
children?: JSX.Element;
}
/**
* Element that triggers the Tooltip on hover/focus.
* Renders as a `<span>` to allow wrapping any content.
*/
export function TooltipTrigger(props: TooltipTriggerProps): JSX.Element {
const [local, rest] = splitProps(props, [
"children",
"onPointerEnter",
"onPointerLeave",
"onFocus",
"onBlur",
]);
const ctx = useInternalTooltipContext();
const handlePointerEnter: JSX.EventHandler<HTMLElement, PointerEvent> = (e) => {
if (typeof local.onPointerEnter === "function") local.onPointerEnter(e);
ctx.openDelayed();
};
const handlePointerLeave: JSX.EventHandler<HTMLElement, PointerEvent> = (e) => {
if (typeof local.onPointerLeave === "function") local.onPointerLeave(e);
ctx.closeDelayed();
};
const handleFocus: JSX.EventHandler<HTMLElement, FocusEvent> = (e) => {
if (typeof local.onFocus === "function") local.onFocus(e);
ctx.openDelayed();
};
const handleBlur: JSX.EventHandler<HTMLElement, FocusEvent> = (e) => {
if (typeof local.onBlur === "function") local.onBlur(e);
ctx.closeDelayed();
};
return (
<span
ref={(el) => ctx.setTriggerRef(el)}
id={ctx.triggerId}
aria-describedby={ctx.isOpen() ? ctx.contentId : undefined}
data-state={ctx.isOpen() ? "open" : "closed"}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
onFocus={handleFocus}
onBlur={handleBlur}
{...rest}
>
{local.children}
</span>
);
}

View File

@ -0,0 +1,22 @@
import { render, screen } from "@solidjs/testing-library";
import { describe, expect, it } from "vitest";
import { Alert } from "../../../src/components/alert/index";
describe("Alert", () => {
it("has role=alert", () => {
render(() => <Alert>Warning!</Alert>);
expect(screen.getByRole("alert")).toBeTruthy();
});
it("renders children", () => {
render(() => <Alert>Important message</Alert>);
expect(screen.getByText("Important message")).toBeTruthy();
});
it("spreads props", () => {
render(() => (
<Alert data-testid="a" class="custom">
Text
</Alert>
));
expect(screen.getByTestId("a").getAttribute("class")).toBe("custom");
});
});

View File

@ -0,0 +1,22 @@
import { render, screen } from "@solidjs/testing-library";
import { describe, expect, it } from "vitest";
import { Badge } from "../../../src/components/badge/index";
describe("Badge", () => {
it("has role=status", () => {
render(() => <Badge>New</Badge>);
expect(screen.getByRole("status")).toBeTruthy();
});
it("renders children", () => {
render(() => <Badge>5</Badge>);
expect(screen.getByText("5")).toBeTruthy();
});
it("spreads props", () => {
render(() => (
<Badge data-testid="b" class="red">
Hot
</Badge>
));
expect(screen.getByTestId("b").getAttribute("class")).toBe("red");
});
});

View File

@ -0,0 +1,59 @@
import { render, screen } from "@solidjs/testing-library";
import { describe, expect, it } from "vitest";
import { Breadcrumbs } from "../../../src/components/breadcrumbs/index";
describe("Breadcrumbs", () => {
it("has role=navigation", () => {
render(() => (
<Breadcrumbs>
<Breadcrumbs.Item>
<Breadcrumbs.Link href="/">Home</Breadcrumbs.Link>
</Breadcrumbs.Item>
<Breadcrumbs.Separator>/</Breadcrumbs.Separator>
<Breadcrumbs.Item>Current</Breadcrumbs.Item>
</Breadcrumbs>
));
expect(screen.getByRole("navigation")).toBeTruthy();
});
it("has aria-label", () => {
render(() => (
<Breadcrumbs>
<Breadcrumbs.Item>Home</Breadcrumbs.Item>
</Breadcrumbs>
));
expect(screen.getByRole("navigation").getAttribute("aria-label")).toBe("Breadcrumbs");
});
it("renders as ol > li", () => {
render(() => (
<Breadcrumbs>
<Breadcrumbs.Item>Home</Breadcrumbs.Item>
<Breadcrumbs.Item>Page</Breadcrumbs.Item>
</Breadcrumbs>
));
expect(screen.getByRole("navigation").querySelector("ol")).toBeTruthy();
expect(screen.getByRole("navigation").querySelectorAll("li")).toHaveLength(2);
});
it("link renders as anchor", () => {
render(() => (
<Breadcrumbs>
<Breadcrumbs.Item>
<Breadcrumbs.Link href="/home">Home</Breadcrumbs.Link>
</Breadcrumbs.Item>
</Breadcrumbs>
));
expect(screen.getByRole("link")).toBeTruthy();
expect(screen.getByRole("link").getAttribute("href")).toBe("/home");
});
it("current item has aria-current=page", () => {
render(() => (
<Breadcrumbs>
<Breadcrumbs.Item>
<Breadcrumbs.Link href="/">Home</Breadcrumbs.Link>
</Breadcrumbs.Item>
<Breadcrumbs.Item current>Current</Breadcrumbs.Item>
</Breadcrumbs>
));
const items = screen.getByRole("navigation").querySelectorAll("li");
expect(items[1].getAttribute("aria-current")).toBe("page");
});
});

View File

@ -0,0 +1,34 @@
import { fireEvent, render, screen } from "@solidjs/testing-library";
import { describe, expect, it, vi } from "vitest";
import { Button } from "../../../src/components/button/index";
describe("Button", () => {
it("renders as button element", () => {
render(() => <Button>Click</Button>);
expect(screen.getByRole("button")).toBeTruthy();
});
it("has type=button by default", () => {
render(() => <Button>Click</Button>);
expect(screen.getByRole("button").getAttribute("type")).toBe("button");
});
it("disabled button", () => {
render(() => <Button disabled>Click</Button>);
expect(screen.getByRole("button")).toBeDisabled();
});
it("fires onClick", () => {
const onClick = vi.fn();
render(() => <Button onClick={onClick}>Click</Button>);
fireEvent.click(screen.getByRole("button"));
expect(onClick).toHaveBeenCalled();
});
it("does not fire onClick when disabled", () => {
const onClick = vi.fn();
render(() => (
<Button disabled onClick={onClick}>
Click
</Button>
));
fireEvent.click(screen.getByRole("button"));
expect(onClick).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,61 @@
import { fireEvent, render, screen } from "@solidjs/testing-library";
import { describe, expect, it } from "vitest";
import { HoverCard } from "../../../src/components/hover-card/index";
describe("HoverCard", () => {
it("content not rendered when closed", () => {
render(() => (
<HoverCard>
<HoverCard.Trigger>Hover</HoverCard.Trigger>
<HoverCard.Content data-testid="content">Details</HoverCard.Content>
</HoverCard>
));
expect(screen.queryByTestId("content")).toBeNull();
});
it("opens with defaultOpen", () => {
render(() => (
<HoverCard defaultOpen>
<HoverCard.Trigger>Hover</HoverCard.Trigger>
<HoverCard.Content data-testid="content">Details</HoverCard.Content>
</HoverCard>
));
expect(screen.getByTestId("content")).toBeTruthy();
});
it("closes on Escape", () => {
render(() => (
<HoverCard defaultOpen>
<HoverCard.Trigger>Hover</HoverCard.Trigger>
<HoverCard.Content data-testid="content">Details</HoverCard.Content>
</HoverCard>
));
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByTestId("content")).toBeNull();
});
it("content has data-state", () => {
render(() => (
<HoverCard defaultOpen>
<HoverCard.Trigger>Hover</HoverCard.Trigger>
<HoverCard.Content data-testid="content">Details</HoverCard.Content>
</HoverCard>
));
expect(screen.getByTestId("content").getAttribute("data-state")).toBe("open");
});
it("controlled mode", () => {
render(() => (
<HoverCard open={true} onOpenChange={() => {}}>
<HoverCard.Trigger>Hover</HoverCard.Trigger>
<HoverCard.Content data-testid="content">Details</HoverCard.Content>
</HoverCard>
));
expect(screen.getByTestId("content")).toBeTruthy();
});
it("content is positioned", () => {
render(() => (
<HoverCard defaultOpen>
<HoverCard.Trigger>Hover</HoverCard.Trigger>
<HoverCard.Content data-testid="content">Details</HoverCard.Content>
</HoverCard>
));
expect(screen.getByTestId("content").style.position).toBeTruthy();
});
});

View File

@ -0,0 +1,40 @@
import { render, screen } from "@solidjs/testing-library";
import { describe, expect, it } from "vitest";
import { Image } from "../../../src/components/image/index";
describe("Image", () => {
it("renders img element", () => {
render(() => (
<Image>
<Image.Img src="test.jpg" alt="Test" />
<Image.Fallback>FB</Image.Fallback>
</Image>
));
expect(screen.getByRole("img")).toBeTruthy();
});
it("img has alt text", () => {
render(() => (
<Image>
<Image.Img src="test.jpg" alt="Photo" />
</Image>
));
expect(screen.getByAltText("Photo")).toBeTruthy();
});
it("fallback renders when no src", () => {
render(() => (
<Image>
<Image.Img src="" alt="Test" />
<Image.Fallback>FB</Image.Fallback>
</Image>
));
expect(screen.getByText("FB")).toBeTruthy();
});
it("data-state reflects loading status", () => {
render(() => (
<Image>
<Image.Img src="test.jpg" alt="Test" data-testid="img" />
</Image>
));
expect(screen.getByTestId("img").getAttribute("data-state")).toBeTruthy();
});
});

View File

@ -0,0 +1,30 @@
import { render, screen } from "@solidjs/testing-library";
import { describe, expect, it } from "vitest";
import { Link } from "../../../src/components/link/index";
describe("Link", () => {
it("renders as anchor with role=link", () => {
render(() => <Link href="/page">Go</Link>);
expect(screen.getByRole("link")).toBeTruthy();
});
it("has href attribute", () => {
render(() => <Link href="/page">Go</Link>);
expect(screen.getByRole("link").getAttribute("href")).toBe("/page");
});
it("disabled link has aria-disabled", () => {
render(() => (
<Link href="/page" disabled>
Go
</Link>
));
expect(screen.getByRole("link").getAttribute("aria-disabled")).toBe("true");
});
it("disabled link prevents navigation", () => {
render(() => (
<Link href="/page" disabled>
Go
</Link>
));
expect(screen.getByRole("link").getAttribute("href")).toBeNull();
});
});

View File

@ -0,0 +1,28 @@
import { render, screen } from "@solidjs/testing-library";
import { describe, expect, it } from "vitest";
import { Meter } from "../../../src/components/meter/index";
describe("Meter", () => {
it("has role=meter", () => {
render(() => <Meter value={50} />);
expect(screen.getByRole("meter")).toBeTruthy();
});
it("sets aria-valuenow", () => {
render(() => <Meter value={75} />);
expect(screen.getByRole("meter").getAttribute("aria-valuenow")).toBe("75");
});
it("sets min and max", () => {
render(() => <Meter value={5} min={0} max={10} />);
const el = screen.getByRole("meter");
expect(el.getAttribute("aria-valuemin")).toBe("0");
expect(el.getAttribute("aria-valuemax")).toBe("10");
});
it("uses custom getValueLabel", () => {
render(() => <Meter value={75} getValueLabel={(v, m) => `${v}/${m}`} />);
expect(screen.getByRole("meter").getAttribute("aria-valuetext")).toBe("75/100");
});
it("has data-value", () => {
render(() => <Meter value={50} data-testid="m" />);
expect(screen.getByTestId("m").getAttribute("data-value")).toBe("50");
});
});

View File

@ -0,0 +1,79 @@
import { fireEvent, render, screen } from "@solidjs/testing-library";
import { describe, expect, it, vi } from "vitest";
import { NumberField } from "../../../src/components/number-field/index";
describe("NumberField basics", () => {
it("input has role=spinbutton", () => {
render(() => (
<NumberField>
<NumberField.Input />
</NumberField>
));
expect(screen.getByRole("spinbutton")).toBeTruthy();
});
it("sets aria-valuenow", () => {
render(() => (
<NumberField defaultValue={5}>
<NumberField.Input />
</NumberField>
));
expect(screen.getByRole("spinbutton").getAttribute("aria-valuenow")).toBe("5");
});
it("label linked to input", () => {
render(() => (
<NumberField>
<NumberField.Label>Quantity</NumberField.Label>
<NumberField.Input />
</NumberField>
));
const label = screen.getByText("Quantity");
const input = screen.getByRole("spinbutton");
expect(label.getAttribute("for")).toBe(input.id);
});
});
describe("NumberField interactions", () => {
it("increment button increases value", () => {
const onChange = vi.fn();
render(() => (
<NumberField defaultValue={5} onValueChange={onChange}>
<NumberField.Input />
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
</NumberField>
));
fireEvent.click(screen.getByText("+"));
expect(onChange).toHaveBeenCalledWith(6);
});
it("decrement button decreases value", () => {
const onChange = vi.fn();
render(() => (
<NumberField defaultValue={5} onValueChange={onChange}>
<NumberField.Input />
<NumberField.DecrementTrigger>-</NumberField.DecrementTrigger>
</NumberField>
));
fireEvent.click(screen.getByText("-"));
expect(onChange).toHaveBeenCalledWith(4);
});
it("respects min/max", () => {
const onChange = vi.fn();
render(() => (
<NumberField defaultValue={10} max={10} onValueChange={onChange}>
<NumberField.Input />
<NumberField.IncrementTrigger>+</NumberField.IncrementTrigger>
</NumberField>
));
fireEvent.click(screen.getByText("+"));
expect(onChange).not.toHaveBeenCalled();
});
it("ArrowUp increments", () => {
const onChange = vi.fn();
render(() => (
<NumberField defaultValue={5} onValueChange={onChange}>
<NumberField.Input />
</NumberField>
));
fireEvent.keyDown(screen.getByRole("spinbutton"), { key: "ArrowUp" });
expect(onChange).toHaveBeenCalledWith(6);
});
});

View File

@ -0,0 +1,77 @@
import { fireEvent, render, screen } from "@solidjs/testing-library";
import { describe, expect, it } from "vitest";
import { Popover } from "../../../src/components/popover/index";
describe("Popover", () => {
it("content has role=dialog when open", () => {
render(() => (
<Popover defaultOpen>
<Popover.Trigger>Open</Popover.Trigger>
<Popover.Content>Content</Popover.Content>
</Popover>
));
expect(screen.getByRole("dialog")).toBeTruthy();
});
it("trigger has correct ARIA", () => {
render(() => (
<Popover>
<Popover.Trigger>Open</Popover.Trigger>
<Popover.Content>Content</Popover.Content>
</Popover>
));
const trigger = screen.getByText("Open");
expect(trigger.getAttribute("aria-haspopup")).toBe("dialog");
expect(trigger.getAttribute("aria-expanded")).toBe("false");
});
it("click trigger opens", () => {
render(() => (
<Popover>
<Popover.Trigger>Open</Popover.Trigger>
<Popover.Content>Content</Popover.Content>
</Popover>
));
fireEvent.click(screen.getByText("Open"));
expect(screen.getByRole("dialog")).toBeTruthy();
});
it("Escape closes", () => {
render(() => (
<Popover defaultOpen>
<Popover.Trigger>Open</Popover.Trigger>
<Popover.Content>Content</Popover.Content>
</Popover>
));
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("dialog")).toBeNull();
});
it("Close button closes", () => {
render(() => (
<Popover defaultOpen>
<Popover.Trigger>Open</Popover.Trigger>
<Popover.Content>
<Popover.Close>X</Popover.Close>
Content
</Popover.Content>
</Popover>
));
fireEvent.click(screen.getByText("X"));
expect(screen.queryByRole("dialog")).toBeNull();
});
it("controlled mode", () => {
render(() => (
<Popover open={true} onOpenChange={() => {}}>
<Popover.Trigger>Open</Popover.Trigger>
<Popover.Content>Content</Popover.Content>
</Popover>
));
expect(screen.getByRole("dialog")).toBeTruthy();
});
it("content is positioned", () => {
render(() => (
<Popover defaultOpen>
<Popover.Trigger>Open</Popover.Trigger>
<Popover.Content data-testid="content">Content</Popover.Content>
</Popover>
));
expect(screen.getByTestId("content").style.position).toBeTruthy();
});
});

View File

@ -0,0 +1,28 @@
import { render, screen } from "@solidjs/testing-library";
import { describe, expect, it } from "vitest";
import { Skeleton } from "../../../src/components/skeleton/index";
describe("Skeleton", () => {
it("is aria-hidden", () => {
render(() => <Skeleton data-testid="s" />);
expect(screen.getByTestId("s").getAttribute("aria-hidden")).toBe("true");
});
it("has data-visible by default", () => {
render(() => <Skeleton data-testid="s" />);
expect(screen.getByTestId("s")).toHaveAttribute("data-visible");
});
it("no data-visible when visible=false", () => {
render(() => <Skeleton data-testid="s" visible={false} />);
expect(screen.getByTestId("s")).not.toHaveAttribute("data-visible");
});
it("data-circle when circle=true", () => {
render(() => <Skeleton data-testid="s" circle />);
expect(screen.getByTestId("s")).toHaveAttribute("data-circle");
});
it("applies width and height", () => {
render(() => <Skeleton data-testid="s" width="100px" height="20px" />);
const el = screen.getByTestId("s");
expect(el.style.width).toBe("100px");
expect(el.style.height).toBe("20px");
});
});

View File

@ -0,0 +1,64 @@
import { fireEvent, render, screen } from "@solidjs/testing-library";
import { describe, expect, it } from "vitest";
import { Tooltip } from "../../../src/components/tooltip/index";
describe("Tooltip", () => {
it("content has role=tooltip when open", () => {
render(() => (
<Tooltip defaultOpen>
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
<Tooltip.Content>Help text</Tooltip.Content>
</Tooltip>
));
expect(screen.getByRole("tooltip")).toBeTruthy();
});
it("trigger has aria-describedby when open", () => {
render(() => (
<Tooltip defaultOpen>
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
<Tooltip.Content>Help text</Tooltip.Content>
</Tooltip>
));
const trigger = screen.getByText("Hover me");
const tooltip = screen.getByRole("tooltip");
expect(trigger.getAttribute("aria-describedby")).toBe(tooltip.id);
});
it("content not rendered when closed", () => {
render(() => (
<Tooltip>
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
<Tooltip.Content>Help text</Tooltip.Content>
</Tooltip>
));
expect(screen.queryByRole("tooltip")).toBeNull();
});
it("closes on Escape", () => {
render(() => (
<Tooltip defaultOpen>
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
<Tooltip.Content>Help text</Tooltip.Content>
</Tooltip>
));
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("tooltip")).toBeNull();
});
it("controlled mode", () => {
render(() => (
<Tooltip open={true} onOpenChange={() => {}}>
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
<Tooltip.Content>Help text</Tooltip.Content>
</Tooltip>
));
expect(screen.getByRole("tooltip")).toBeTruthy();
});
it("content is positioned", () => {
render(() => (
<Tooltip defaultOpen>
<Tooltip.Trigger>Hover me</Tooltip.Trigger>
<Tooltip.Content data-testid="content">Help text</Tooltip.Content>
</Tooltip>
));
const content = screen.getByTestId("content");
expect(content.style.position).toBeTruthy();
});
});

View File

@ -1,14 +1,13 @@
import { defineConfig } from "tsdown";
const components = [
"dialog", "separator", "toggle", "switch", "checkbox", "progress",
"text-field", "radio-group", "toggle-group", "collapsible", "accordion",
"alert-dialog", "tabs", "slider", "pagination", "drawer",
"listbox", "select", "combobox", "dropdown-menu", "context-menu", "toast",
];
const utilities = [
"presence", "focus-trap", "scroll-lock", "dismiss", "portal", "visually-hidden",
"dialog", "separator", "toggle", "switch", "checkbox", "progress", "text-field",
"radio-group", "toggle-group", "collapsible", "accordion", "alert-dialog", "tabs",
"slider", "pagination", "drawer", "listbox", "select", "combobox", "dropdown-menu",
"context-menu", "toast", "tooltip", "popover", "hover-card", "alert", "badge",
"skeleton", "breadcrumbs", "link", "button", "image", "meter", "number-field",
];
const utilities = ["presence", "focus-trap", "scroll-lock", "dismiss", "portal", "visually-hidden"];
const entry: Record<string, string> = {};
for (const c of components) entry[`${c}/index`] = `src/components/${c}/index.ts`;