DatePicker component

Composes Calendar inside a floating dropdown anchored to an input, with Intl locale formatting, controlled/uncontrolled date+open state, createDismiss for outside-click handling, and hidden input for form submission.
This commit is contained in:
Mats Bosson 2026-03-29 21:20:09 +07:00
parent ea18b5b62c
commit 894df74d7f
8 changed files with 380 additions and 0 deletions

View File

@ -0,0 +1,52 @@
import type { JSX } from "solid-js";
import { Show, createEffect, onCleanup, splitProps } from "solid-js";
import { createDismiss } from "../../utilities/dismiss/create-dismiss";
import { useDatePickerContext } from "./date-picker-context";
export interface DatePickerContentProps extends JSX.HTMLAttributes<HTMLDivElement> {
children?: JSX.Element;
}
/** Floating calendar panel. Renders only when open; dismissed by outside click or Escape. */
export function DatePickerContent(props: DatePickerContentProps): JSX.Element {
const [local, rest] = splitProps(props, ["children", "style"]);
const ctx = useDatePickerContext();
const dismiss = createDismiss({
getContainer: () => ctx.contentRef(),
onDismiss: () => ctx.setOpen(false),
});
createEffect(() => {
if (ctx.isOpen()) {
dismiss.attach();
} else {
dismiss.detach();
}
onCleanup(() => dismiss.detach());
});
return (
<Show when={ctx.isOpen()}>
<div
ref={(el) => ctx.setContentRef(el)}
id={ctx.contentId()}
role="dialog"
aria-modal={false}
data-state="open"
style={
typeof local.style === "string"
? `${styleToString(ctx.floatingStyle())};${local.style}`
: { ...ctx.floatingStyle(), ...(local.style as JSX.CSSProperties) }
}
{...rest}
>
{local.children}
</div>
</Show>
);
}
function styleToString(style: JSX.CSSProperties): string {
return Object.entries(style).map(([k, v]) => `${k}:${v}`).join(";");
}

View File

@ -0,0 +1,38 @@
import type { Accessor } from "solid-js";
import { createContext, useContext } from "solid-js";
export interface DatePickerContextValue {
isOpen: Accessor<boolean>;
setOpen: (open: boolean) => void;
toggle: () => void;
value: Accessor<string | undefined>;
setValue: (iso: string) => void;
formatDate: (iso: string) => string;
placeholder: Accessor<string>;
disabled: Accessor<boolean>;
name: Accessor<string | undefined>;
minDate: Accessor<string | undefined>;
maxDate: Accessor<string | undefined>;
locale: Accessor<string>;
contentId: Accessor<string>;
triggerRef: Accessor<HTMLElement | null>;
setTriggerRef: (el: HTMLElement | null) => void;
contentRef: Accessor<HTMLElement | null>;
setContentRef: (el: HTMLElement | null) => void;
floatingStyle: Accessor<import("solid-js").JSX.CSSProperties>;
}
const DatePickerContext = createContext<DatePickerContextValue>();
/** Returns the DatePicker context. Throws if used outside a DatePicker root. */
export function useDatePickerContext(): DatePickerContextValue {
const ctx = useContext(DatePickerContext);
if (!ctx) {
throw new Error(
"[PettyUI] DatePicker parts must be used inside <DatePicker>. Wrap your DatePicker.Input, DatePicker.Content, etc. inside <DatePicker>.",
);
}
return ctx;
}
export const DatePickerContextProvider = DatePickerContext.Provider;

View File

@ -0,0 +1,44 @@
import type { JSX } from "solid-js";
import { Show, splitProps } from "solid-js";
import { useDatePickerContext } from "./date-picker-context";
export interface DatePickerInputProps extends JSX.HTMLAttributes<HTMLInputElement> {
children?: never;
}
/** Read-only input displaying the formatted selected date. Clicking opens the calendar popover. */
export function DatePickerInput(props: DatePickerInputProps): JSX.Element {
const [, rest] = splitProps(props, []);
const ctx = useDatePickerContext();
const displayValue = () => {
const v = ctx.value();
return v ? ctx.formatDate(v) : "";
};
const handleClick: JSX.EventHandler<HTMLInputElement, MouseEvent> = (e) => {
if (typeof props.onClick === "function") props.onClick(e);
if (!ctx.disabled()) ctx.toggle();
};
return (
<>
<input
type="text"
readOnly
value={displayValue()}
placeholder={ctx.placeholder()}
disabled={ctx.disabled()}
aria-haspopup="dialog"
aria-expanded={ctx.isOpen()}
aria-controls={ctx.contentId()}
data-state={ctx.isOpen() ? "open" : "closed"}
onClick={handleClick}
{...rest}
/>
<Show when={ctx.name() && ctx.value()}>
<input type="hidden" name={ctx.name()} value={ctx.value()} />
</Show>
</>
);
}

View File

@ -0,0 +1,76 @@
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 { createControllableSignal } from "../../primitives/create-controllable-signal";
import { createDisclosureState, type CreateDisclosureStateOptions } from "../../primitives/create-disclosure-state";
import { createFloating } from "../../primitives/create-floating";
import { DatePickerContextProvider } from "./date-picker-context";
import type { DatePickerRootProps } from "./date-picker.props";
/** Formats an ISO date string using Intl.DateTimeFormat for the given locale. */
function formatDate(iso: string, locale: string): string {
const [y, m, d] = iso.split("-").map(Number);
return new Intl.DateTimeFormat(locale, { year: "numeric", month: "long", day: "numeric" }).format(
new Date(y, m - 1, d),
);
}
/** Root component for DatePicker. Manages open state, date value, floating position, and provides context. */
export function DatePickerRoot(props: DatePickerRootProps): JSX.Element {
const disclosure = createDisclosureState({
get open() { return props.open; },
get defaultOpen() { return props.defaultOpen; },
get onOpenChange() { return props.onOpenChange; },
} as CreateDisclosureStateOptions);
const [value, setValue] = createControllableSignal<string | undefined>({
value: () => props.value,
defaultValue: () => props.defaultValue,
onChange: props.onValueChange,
});
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: (() => "bottom-start") as Accessor<Placement>,
middleware: (() => [offset(8), flip(), shift({ padding: 8 })]) as Accessor<Middleware[]>,
open: disclosure.isOpen,
});
const handleSelect = (iso: string) => {
setValue(iso);
disclosure.close();
};
const ctx = {
isOpen: disclosure.isOpen,
setOpen: (open: boolean) => (open ? disclosure.open() : disclosure.close()),
toggle: disclosure.toggle,
value,
setValue: handleSelect,
formatDate: (iso: string) => formatDate(iso, props.locale ?? "en-US"),
placeholder: () => props.placeholder ?? "Select date",
disabled: () => props.disabled ?? false,
name: () => props.name,
minDate: () => props.minDate,
maxDate: () => props.maxDate,
locale: () => props.locale ?? "en-US",
contentId: () => contentId,
triggerRef,
setTriggerRef,
contentRef,
setContentRef,
floatingStyle: floating.style,
};
return (
<DatePickerContextProvider value={ctx}>
{props.children}
</DatePickerContextProvider>
);
}

View File

@ -0,0 +1,34 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useDatePickerContext } from "./date-picker-context";
export interface DatePickerTriggerProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
children?: JSX.Element;
}
/** Button that toggles the DatePicker calendar popover open or closed. */
export function DatePickerTrigger(props: DatePickerTriggerProps): JSX.Element {
const [local, rest] = splitProps(props, ["onClick", "children"]);
const ctx = useDatePickerContext();
const handleClick: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (e) => {
if (typeof local.onClick === "function") local.onClick(e);
if (!ctx.disabled()) ctx.toggle();
};
return (
<button
type="button"
ref={(el) => ctx.setTriggerRef(el)}
aria-haspopup="dialog"
aria-expanded={ctx.isOpen()}
aria-controls={ctx.contentId()}
disabled={ctx.disabled()}
data-state={ctx.isOpen() ? "open" : "closed"}
onClick={handleClick}
{...rest}
>
{local.children}
</button>
);
}

View File

@ -0,0 +1,20 @@
import { z } from "zod/v4";
import type { JSX } from "solid-js";
import type { ComponentMeta } from "../../meta";
export const DatePickerRootPropsSchema = z.object({
value: z.string().optional(), defaultValue: z.string().optional(),
open: z.boolean().optional(), defaultOpen: z.boolean().optional(),
minDate: z.string().optional(), maxDate: z.string().optional(),
disabled: z.boolean().optional(), required: z.boolean().optional(),
name: z.string().optional(), placeholder: z.string().optional(), locale: z.string().optional(),
});
export interface DatePickerRootProps extends z.infer<typeof DatePickerRootPropsSchema> {
onValueChange?: (date: string) => void;
onOpenChange?: (open: boolean) => void;
children: JSX.Element;
}
export const DatePickerMeta: ComponentMeta = {
name: "DatePicker", description: "Date input with dropdown calendar for selecting dates, with locale formatting and constraints",
parts: ["Root", "Input", "Trigger", "Content", "Calendar"] as const,
requiredParts: ["Root", "Input", "Content"] as const,
} as const;

View File

@ -0,0 +1,16 @@
import { DatePickerRoot } from "./date-picker-root";
import { DatePickerInput } from "./date-picker-input";
import { DatePickerTrigger } from "./date-picker-trigger";
import { DatePickerContent } from "./date-picker-content";
import { useDatePickerContext } from "./date-picker-context";
export type { DatePickerRootProps } from "./date-picker.props";
export type { DatePickerInputProps } from "./date-picker-input";
export type { DatePickerTriggerProps } from "./date-picker-trigger";
export type { DatePickerContentProps } from "./date-picker-content";
export { DatePickerRootPropsSchema, DatePickerMeta } from "./date-picker.props";
export const DatePicker = Object.assign(DatePickerRoot, {
Input: DatePickerInput,
Trigger: DatePickerTrigger,
Content: DatePickerContent,
useContext: useDatePickerContext,
});

View File

@ -0,0 +1,100 @@
import { fireEvent, render, screen } from "@solidjs/testing-library";
import { describe, expect, it, vi } from "vitest";
import { DatePicker } from "../../../src/components/date-picker/index";
import { DatePickerRootPropsSchema, DatePickerMeta } from "../../../src/components/date-picker/index";
function renderPicker(props: Record<string, unknown> = {}) {
return render(() => (
<DatePicker {...props}>
<DatePicker.Input data-testid="input" />
<DatePicker.Trigger data-testid="trigger">Open</DatePicker.Trigger>
<DatePicker.Content data-testid="content">
<span>Calendar goes here</span>
</DatePicker.Content>
</DatePicker>
));
}
describe("DatePicker — rendering", () => {
it("renders input with placeholder", () => {
renderPicker();
const input = screen.getByTestId("input") as HTMLInputElement;
expect(input.placeholder).toBe("Select date");
});
it("renders custom placeholder", () => {
renderPicker({ placeholder: "Pick a date" });
const input = screen.getByTestId("input") as HTMLInputElement;
expect(input.placeholder).toBe("Pick a date");
});
it("shows formatted date when value set", () => {
renderPicker({ value: "2024-01-15" });
const input = screen.getByTestId("input") as HTMLInputElement;
expect(input.value).toMatch(/january/i);
expect(input.value).toMatch(/15/);
expect(input.value).toMatch(/2024/);
});
it("content hidden when closed", () => {
renderPicker();
expect(screen.queryByTestId("content")).toBeNull();
});
});
describe("DatePicker — open/close", () => {
it("trigger click opens content", () => {
renderPicker();
fireEvent.click(screen.getByTestId("trigger"));
expect(screen.getByTestId("content")).toBeTruthy();
});
it("input click opens content", () => {
renderPicker();
fireEvent.click(screen.getByTestId("input"));
expect(screen.getByTestId("content")).toBeTruthy();
});
it("renders open when defaultOpen is true", () => {
renderPicker({ defaultOpen: true });
expect(screen.getByTestId("content")).toBeTruthy();
});
it("controlled open state shows content", () => {
renderPicker({ open: true, onOpenChange: vi.fn() });
expect(screen.getByTestId("content")).toBeTruthy();
});
it("open content has role=dialog", () => {
renderPicker({ defaultOpen: true });
expect(screen.getByRole("dialog")).toBeTruthy();
});
});
describe("DatePicker — schema and meta", () => {
it("schema validates valid ISO date", () => {
expect(DatePickerRootPropsSchema.safeParse({ value: "2024-06-01" }).success).toBe(true);
});
it("schema rejects non-string value", () => {
expect(DatePickerRootPropsSchema.safeParse({ value: 20240601 }).success).toBe(false);
});
it("schema accepts all optional fields absent", () => {
expect(DatePickerRootPropsSchema.safeParse({}).success).toBe(true);
});
it("meta has correct name", () => {
expect(DatePickerMeta.name).toBe("DatePicker");
});
it("meta contains required parts", () => {
expect(DatePickerMeta.parts).toContain("Root");
expect(DatePickerMeta.parts).toContain("Input");
expect(DatePickerMeta.parts).toContain("Content");
});
it("meta lists Calendar as a part", () => {
expect(DatePickerMeta.parts).toContain("Calendar");
});
});