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