From 894df74d7fce8eaa13de8fbfb333c737eb4bff81 Mon Sep 17 00:00:00 2001 From: Mats Bosson Date: Sun, 29 Mar 2026 21:20:09 +0700 Subject: [PATCH] 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. --- .../date-picker/date-picker-content.tsx | 52 +++++++++ .../date-picker/date-picker-context.ts | 38 +++++++ .../date-picker/date-picker-input.tsx | 44 ++++++++ .../date-picker/date-picker-root.tsx | 76 +++++++++++++ .../date-picker/date-picker-trigger.tsx | 34 ++++++ .../date-picker/date-picker.props.ts | 20 ++++ .../core/src/components/date-picker/index.ts | 16 +++ .../date-picker/date-picker.test.tsx | 100 ++++++++++++++++++ 8 files changed, 380 insertions(+) create mode 100644 packages/core/src/components/date-picker/date-picker-content.tsx create mode 100644 packages/core/src/components/date-picker/date-picker-context.ts create mode 100644 packages/core/src/components/date-picker/date-picker-input.tsx create mode 100644 packages/core/src/components/date-picker/date-picker-root.tsx create mode 100644 packages/core/src/components/date-picker/date-picker-trigger.tsx create mode 100644 packages/core/src/components/date-picker/date-picker.props.ts create mode 100644 packages/core/src/components/date-picker/index.ts create mode 100644 packages/core/tests/components/date-picker/date-picker.test.tsx diff --git a/packages/core/src/components/date-picker/date-picker-content.tsx b/packages/core/src/components/date-picker/date-picker-content.tsx new file mode 100644 index 0000000..9b12b02 --- /dev/null +++ b/packages/core/src/components/date-picker/date-picker-content.tsx @@ -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 { + 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 ( + +
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} +
+
+ ); +} + +function styleToString(style: JSX.CSSProperties): string { + return Object.entries(style).map(([k, v]) => `${k}:${v}`).join(";"); +} diff --git a/packages/core/src/components/date-picker/date-picker-context.ts b/packages/core/src/components/date-picker/date-picker-context.ts new file mode 100644 index 0000000..0b2b490 --- /dev/null +++ b/packages/core/src/components/date-picker/date-picker-context.ts @@ -0,0 +1,38 @@ +import type { Accessor } from "solid-js"; +import { createContext, useContext } from "solid-js"; + +export interface DatePickerContextValue { + isOpen: Accessor; + setOpen: (open: boolean) => void; + toggle: () => void; + value: Accessor; + setValue: (iso: string) => void; + formatDate: (iso: string) => string; + placeholder: Accessor; + disabled: Accessor; + name: Accessor; + minDate: Accessor; + maxDate: Accessor; + locale: Accessor; + contentId: Accessor; + triggerRef: Accessor; + setTriggerRef: (el: HTMLElement | null) => void; + contentRef: Accessor; + setContentRef: (el: HTMLElement | null) => void; + floatingStyle: Accessor; +} + +const DatePickerContext = createContext(); + +/** 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 . Wrap your DatePicker.Input, DatePicker.Content, etc. inside .", + ); + } + return ctx; +} + +export const DatePickerContextProvider = DatePickerContext.Provider; diff --git a/packages/core/src/components/date-picker/date-picker-input.tsx b/packages/core/src/components/date-picker/date-picker-input.tsx new file mode 100644 index 0000000..445598d --- /dev/null +++ b/packages/core/src/components/date-picker/date-picker-input.tsx @@ -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 { + 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 = (e) => { + if (typeof props.onClick === "function") props.onClick(e); + if (!ctx.disabled()) ctx.toggle(); + }; + + return ( + <> + + + + + + ); +} diff --git a/packages/core/src/components/date-picker/date-picker-root.tsx b/packages/core/src/components/date-picker/date-picker-root.tsx new file mode 100644 index 0000000..8943bb7 --- /dev/null +++ b/packages/core/src/components/date-picker/date-picker-root.tsx @@ -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({ + value: () => props.value, + defaultValue: () => props.defaultValue, + onChange: props.onValueChange, + }); + + const contentId = createUniqueId(); + const [triggerRef, setTriggerRef] = createSignal(null); + const [contentRef, setContentRef] = createSignal(null); + + const floating = createFloating({ + anchor: triggerRef, + floating: contentRef, + placement: (() => "bottom-start") as Accessor, + middleware: (() => [offset(8), flip(), shift({ padding: 8 })]) as Accessor, + 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 ( + + {props.children} + + ); +} diff --git a/packages/core/src/components/date-picker/date-picker-trigger.tsx b/packages/core/src/components/date-picker/date-picker-trigger.tsx new file mode 100644 index 0000000..d454fa7 --- /dev/null +++ b/packages/core/src/components/date-picker/date-picker-trigger.tsx @@ -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 { + 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 = (e) => { + if (typeof local.onClick === "function") local.onClick(e); + if (!ctx.disabled()) ctx.toggle(); + }; + + return ( + + ); +} diff --git a/packages/core/src/components/date-picker/date-picker.props.ts b/packages/core/src/components/date-picker/date-picker.props.ts new file mode 100644 index 0000000..6d9c4ca --- /dev/null +++ b/packages/core/src/components/date-picker/date-picker.props.ts @@ -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 { + 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; diff --git a/packages/core/src/components/date-picker/index.ts b/packages/core/src/components/date-picker/index.ts new file mode 100644 index 0000000..a7d9968 --- /dev/null +++ b/packages/core/src/components/date-picker/index.ts @@ -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, +}); diff --git a/packages/core/tests/components/date-picker/date-picker.test.tsx b/packages/core/tests/components/date-picker/date-picker.test.tsx new file mode 100644 index 0000000..01bc035 --- /dev/null +++ b/packages/core/tests/components/date-picker/date-picker.test.tsx @@ -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 = {}) { + return render(() => ( + + + Open + + Calendar goes here + + + )); +} + +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"); + }); +});