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:
parent
2a07d9ceaa
commit
8f075f1792
@ -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",
|
||||
|
||||
16
packages/core/src/components/alert/alert.tsx
Normal file
16
packages/core/src/components/alert/alert.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
packages/core/src/components/alert/index.ts
Normal file
2
packages/core/src/components/alert/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { Alert } from "./alert";
|
||||
export type { AlertProps } from "./alert";
|
||||
16
packages/core/src/components/badge/badge.tsx
Normal file
16
packages/core/src/components/badge/badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
packages/core/src/components/badge/index.ts
Normal file
2
packages/core/src/components/badge/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { Badge } from "./badge";
|
||||
export type { BadgeProps } from "./badge";
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
16
packages/core/src/components/breadcrumbs/index.ts
Normal file
16
packages/core/src/components/breadcrumbs/index.ts
Normal 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";
|
||||
38
packages/core/src/components/button/button.tsx
Normal file
38
packages/core/src/components/button/button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
packages/core/src/components/button/index.ts
Normal file
2
packages/core/src/components/button/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { Button } from "./button";
|
||||
export type { ButtonProps } from "./button";
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
140
packages/core/src/components/hover-card/hover-card-root.tsx
Normal file
140
packages/core/src/components/hover-card/hover-card-root.tsx
Normal 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,
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
9
packages/core/src/components/hover-card/index.ts
Normal file
9
packages/core/src/components/hover-card/index.ts
Normal 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";
|
||||
30
packages/core/src/components/image/image-context.ts
Normal file
30
packages/core/src/components/image/image-context.ts
Normal 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;
|
||||
23
packages/core/src/components/image/image-fallback.tsx
Normal file
23
packages/core/src/components/image/image-fallback.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
packages/core/src/components/image/image-img.tsx
Normal file
44
packages/core/src/components/image/image-img.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
33
packages/core/src/components/image/image-root.tsx
Normal file
33
packages/core/src/components/image/image-root.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
packages/core/src/components/image/index.ts
Normal file
16
packages/core/src/components/image/index.ts
Normal 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";
|
||||
2
packages/core/src/components/link/index.ts
Normal file
2
packages/core/src/components/link/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { Link } from "./link";
|
||||
export type { LinkProps } from "./link";
|
||||
31
packages/core/src/components/link/link.tsx
Normal file
31
packages/core/src/components/link/link.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
packages/core/src/components/meter/index.ts
Normal file
2
packages/core/src/components/meter/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { Meter } from "./meter";
|
||||
export type { MeterProps } from "./meter";
|
||||
45
packages/core/src/components/meter/meter.tsx
Normal file
45
packages/core/src/components/meter/meter.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
28
packages/core/src/components/number-field/index.ts
Normal file
28
packages/core/src/components/number-field/index.ts
Normal 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";
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
12
packages/core/src/components/popover/index.ts
Normal file
12
packages/core/src/components/popover/index.ts
Normal 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 });
|
||||
30
packages/core/src/components/popover/popover-close.tsx
Normal file
30
packages/core/src/components/popover/popover-close.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
packages/core/src/components/popover/popover-content.tsx
Normal file
84
packages/core/src/components/popover/popover-content.tsx
Normal 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(";");
|
||||
}
|
||||
72
packages/core/src/components/popover/popover-context.ts
Normal file
72
packages/core/src/components/popover/popover-context.ts
Normal 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;
|
||||
18
packages/core/src/components/popover/popover-portal.tsx
Normal file
18
packages/core/src/components/popover/popover-portal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
packages/core/src/components/popover/popover-root.tsx
Normal file
87
packages/core/src/components/popover/popover-root.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
packages/core/src/components/popover/popover-trigger.tsx
Normal file
57
packages/core/src/components/popover/popover-trigger.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
packages/core/src/components/skeleton/index.ts
Normal file
2
packages/core/src/components/skeleton/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { Skeleton } from "./skeleton";
|
||||
export type { SkeletonProps } from "./skeleton";
|
||||
33
packages/core/src/components/skeleton/skeleton.tsx
Normal file
33
packages/core/src/components/skeleton/skeleton.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
4
packages/core/src/components/tooltip/index.ts
Normal file
4
packages/core/src/components/tooltip/index.ts
Normal 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";
|
||||
50
packages/core/src/components/tooltip/tooltip-content.tsx
Normal file
50
packages/core/src/components/tooltip/tooltip-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
packages/core/src/components/tooltip/tooltip-context.ts
Normal file
72
packages/core/src/components/tooltip/tooltip-context.ts
Normal 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;
|
||||
151
packages/core/src/components/tooltip/tooltip-root.tsx
Normal file
151
packages/core/src/components/tooltip/tooltip-root.tsx
Normal 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,
|
||||
});
|
||||
58
packages/core/src/components/tooltip/tooltip-trigger.tsx
Normal file
58
packages/core/src/components/tooltip/tooltip-trigger.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
packages/core/tests/components/alert/alert.test.tsx
Normal file
22
packages/core/tests/components/alert/alert.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
22
packages/core/tests/components/badge/badge.test.tsx
Normal file
22
packages/core/tests/components/badge/badge.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
34
packages/core/tests/components/button/button.test.tsx
Normal file
34
packages/core/tests/components/button/button.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
40
packages/core/tests/components/image/image.test.tsx
Normal file
40
packages/core/tests/components/image/image.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
30
packages/core/tests/components/link/link.test.tsx
Normal file
30
packages/core/tests/components/link/link.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
28
packages/core/tests/components/meter/meter.test.tsx
Normal file
28
packages/core/tests/components/meter/meter.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
77
packages/core/tests/components/popover/popover.test.tsx
Normal file
77
packages/core/tests/components/popover/popover.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
28
packages/core/tests/components/skeleton/skeleton.test.tsx
Normal file
28
packages/core/tests/components/skeleton/skeleton.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
64
packages/core/tests/components/tooltip/tooltip.test.tsx
Normal file
64
packages/core/tests/components/tooltip/tooltip.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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`;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user