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:
parent
ea18b5b62c
commit
894df74d7f
@ -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(";");
|
||||||
|
}
|
||||||
@ -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;
|
||||||
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
16
packages/core/src/components/date-picker/index.ts
Normal file
16
packages/core/src/components/date-picker/index.ts
Normal 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,
|
||||||
|
});
|
||||||
100
packages/core/tests/components/date-picker/date-picker.test.tsx
Normal file
100
packages/core/tests/components/date-picker/date-picker.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user