Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/odd-maps-glow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
"@tailor-platform/app-shell": major
---

Refactor `DateField` / `DatePicker` to follow the same composition model as `Field`, `Select`, `Combobox`, and `Autocomplete`.

The date controls are now **control-first**: field chrome moved out of the control props and into `Field.Root` composition. This is a breaking change for the field chrome API (`label`, `description`, `errorMessage`, `isInvalid`), while the semantic date props (`isRequired`, `isDisabled`, `isReadOnly`, `minValue`, `maxValue`, `isDateUnavailable`) remain top-level and aligned with `Calendar`.

Before:

```tsx
<DatePicker
label="Delivery date"
description="When should we ship your order?"
minValue={today(getLocalTimeZone())}
errorMessage={error}
isInvalid={!!error}
/>
```

After:

```tsx
<Field.Root invalid={!!error}>
<Field.Label>Delivery date</Field.Label>
<DatePicker aria-label="Delivery date" minValue={today(getLocalTimeZone())} />
<Field.Description>When should we ship your order?</Field.Description>
<Field.Error match={!!error}>{error}</Field.Error>
</Field.Root>
```

Standalone usage still works with accessible naming:

```tsx
<DateField aria-label="Invoice date" />
```
172 changes: 82 additions & 90 deletions docs/components/date-picker.md

Large diffs are not rendered by default.

188 changes: 131 additions & 57 deletions examples/vite-app/src/pages/date-picker/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { useState, type FormEvent } from "react";
import { cloneElement, useState, type FormEvent, type ReactElement } from "react";
import {
Layout,
DateField,
DatePicker,
Calendar,
Form,
Button,
useTimeZone,
parseDate,
Expand All @@ -14,6 +13,55 @@ import {
} from "@tailor-platform/app-shell";
import { CalendarDays } from "lucide-react";

type DemoFieldControlProps = {
id?: string;
"aria-labelledby"?: string;
"aria-describedby"?: string;
isInvalid?: boolean;
};

function DemoField({
id,
label,
description,
error,
children,
}: {
id: string;
label: string;
description?: string;
error?: string;
children: ReactElement<DemoFieldControlProps>;
}) {
const describedBy = [description && `${id}-description`, error && `${id}-error`]
.filter(Boolean)
.join(" ");

return (
<div className="flex flex-col gap-1 items-start">
<label id={`${id}-label`} htmlFor={id} className="text-sm font-medium">
{label}
</label>
{cloneElement(children, {
id,
"aria-labelledby": `${id}-label`,
"aria-describedby": describedBy || undefined,
isInvalid: !!error || children.props.isInvalid,
})}
{description && (
<p id={`${id}-description`} className="text-sm text-muted-foreground">
{description}
</p>
)}
{error && (
<p id={`${id}-error`} className="text-sm font-medium text-destructive">
{error}
</p>
)}
</div>
);
}

const DatePickerPage = () => {
const tz = useTimeZone();
const [fieldValue, setFieldValue] = useState<CalendarDate | null>(null);
Expand All @@ -29,8 +77,8 @@ const DatePickerPage = () => {
const tomorrow = tz.today().add({ days: 1 });
const threeMonths = tz.today().add({ months: 3 });

// Validation runs on submit; the DatePicker surfaces the message through its
// own `errorMessage` / `isInvalid` props (it isn't a Base UI Field control).
// Validation runs on submit; label / description / error wiring stays plain
// HTML + ARIA so the date controls don't depend on Base UI's internal Field plumbing.
const handleDeliverySubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!deliveryDate) {
Expand Down Expand Up @@ -72,19 +120,25 @@ const DatePickerPage = () => {
<h2 className="text-base font-semibold border-b pb-2">DateField</h2>

<div className="flex flex-wrap gap-6 items-start">
<DateField
label="Basic"
value={fieldValue}
onChange={(v) => setFieldValue(v as CalendarDate | null)}
/>
<DateField
<DemoField id="date-field-basic" label="Basic">
<DateField
value={fieldValue}
onChange={(v) => setFieldValue(v as CalendarDate | null)}
/>
</DemoField>
<DemoField
id="date-field-with-description"
label="With description"
description="Select a date within the next 3 months"
minValue={tomorrow}
maxValue={threeMonths}
/>
<DateField label="Disabled" isDisabled defaultValue={parseDate("2025-06-15")} />
<DateField label="Required" isRequired errorMessage="Date is required" />
>
<DateField minValue={tomorrow} maxValue={threeMonths} />
</DemoField>
<DemoField id="date-field-disabled" label="Disabled">
<DateField isDisabled defaultValue={parseDate("2025-06-15")} />
</DemoField>
<DemoField id="date-field-required" label="Required">
<DateField isRequired />
</DemoField>
</div>

{fieldValue && (
Expand All @@ -99,30 +153,38 @@ const DatePickerPage = () => {
<h2 className="text-base font-semibold border-b pb-2">DatePicker</h2>

<div className="flex flex-wrap gap-6 items-start">
<DatePicker
label="Basic"
value={pickerValue}
onChange={(v) => setPickerValue(v as CalendarDate | null)}
/>
<DatePicker
<DemoField id="date-picker-basic" label="Basic">
<DatePicker
value={pickerValue}
onChange={(v) => setPickerValue(v as CalendarDate | null)}
/>
</DemoField>
<DemoField
id="date-picker-future"
label="Future dates only"
description="Minimum: tomorrow"
minValue={tomorrow}
/>
<DatePicker
>
<DatePicker minValue={tomorrow} />
</DemoField>
<DemoField
id="date-picker-weekdays"
label="No weekends"
description="Weekday dates only"
isDateUnavailable={(d) => {
const day = d.toDate(tz.value).getDay();
return day === 0 || day === 6;
}}
/>
<DatePicker
>
<DatePicker
isDateUnavailable={(d) => {
const day = d.toDate(tz.value).getDay();
return day === 0 || day === 6;
}}
/>
</DemoField>
<DemoField
id="date-picker-range"
label="With range"
minValue={tz.today()}
maxValue={threeMonths}
description={`Today → ${threeMonths.toString()}`}
/>
>
<DatePicker minValue={tz.today()} maxValue={threeMonths} />
</DemoField>
</div>

{pickerValue && (
Expand All @@ -136,31 +198,31 @@ const DatePickerPage = () => {
<section className="flex flex-col gap-4">
<h2 className="text-base font-semibold border-b pb-2">In a form (submit validation)</h2>
<p className="text-sm text-muted-foreground">
Standard <code className="bg-muted px-1 py-0.5 rounded">Form</code> +{" "}
<code className="bg-muted px-1 py-0.5 rounded">Button</code>. Submitting empty (or
with a past date) triggers validation — the error surfaces through the DatePicker's
own <code className="bg-muted px-1 py-0.5 rounded">errorMessage</code> /{" "}
<code className="bg-muted px-1 py-0.5 rounded">isInvalid</code> props, and clears as
soon as a valid date is picked.
Standard form submit with a manually wired label + description + error. Submitting
empty (or with a past date) marks the date picker invalid through its public props,
and the error clears as soon as a valid date is picked.
</p>
<Form
<form
onSubmit={handleDeliverySubmit}
className="flex flex-col items-start gap-4 max-w-sm"
>
<DatePicker
<DemoField
id="delivery-date"
label="Delivery date"
description="When should we ship your order?"
isRequired
value={deliveryDate}
onChange={(v) => {
setDeliveryDate(v as CalendarDate | null);
if (v) setDeliveryError(undefined);
}}
errorMessage={deliveryError}
isInvalid={!!deliveryError}
/>
error={deliveryError}
>
<DatePicker
isRequired
value={deliveryDate}
onChange={(v) => {
setDeliveryDate(v as CalendarDate | null);
if (v) setDeliveryError(undefined);
}}
/>
</DemoField>
<Button type="submit">Schedule delivery</Button>
</Form>
</form>
{confirmedDate && (
<p className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
✓ Delivery scheduled for <strong>{confirmedDate}</strong>
Expand All @@ -177,9 +239,15 @@ const DatePickerPage = () => {
explicitly to force a specific start day regardless of locale.
</p>
<div className="flex flex-wrap gap-6 items-start">
<DatePicker label="Forced Sunday" firstDayOfWeek="sun" />
<DatePicker label="Forced Monday" firstDayOfWeek="mon" />
<DatePicker label="Locale default" />
<DemoField id="date-picker-sun" label="Forced Sunday">
<DatePicker firstDayOfWeek="sun" />
</DemoField>
<DemoField id="date-picker-mon" label="Forced Monday">
<DatePicker firstDayOfWeek="mon" />
</DemoField>
<DemoField id="date-picker-locale" label="Locale default">
<DatePicker />
</DemoField>
</div>
</section>

Expand All @@ -189,9 +257,15 @@ const DatePickerPage = () => {
Locale (segment order + names)
</h2>
<div className="flex flex-wrap gap-6 items-start">
<DatePicker label="en-US (MM/DD/YYYY)" locale="en-US" />
<DatePicker label="en-GB (DD/MM/YYYY, Mon-first)" locale="en-GB" />
<DatePicker label="ja-JP (YYYY/MM/DD)" locale="ja-JP" />
<DemoField id="date-picker-en-us" label="en-US (MM/DD/YYYY)">
<DatePicker locale="en-US" />
</DemoField>
<DemoField id="date-picker-en-gb" label="en-GB (DD/MM/YYYY, Mon-first)">
<DatePicker locale="en-GB" />
</DemoField>
<DemoField id="date-picker-ja-jp" label="ja-JP (YYYY/MM/DD)">
<DatePicker locale="ja-JP" />
</DemoField>
</div>
</section>

Expand Down
Loading
Loading