Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
43 changes: 43 additions & 0 deletions .changeset/odd-maps-glow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
"@tailor-platform/app-shell": minor
---

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.

Breaking changes:

- `label`, `description`, and `errorMessage` were removed from `DateField` / `DatePicker`; compose them with `Field.Root`, `Field.Label`, `Field.Description`, and `Field.Error` instead.
- `hideTimeZone` was removed because it was unused.

`isInvalid` still remains a top-level prop for externally-controlled invalid styling, and 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" />
```
193 changes: 103 additions & 90 deletions docs/components/date-picker.md

Large diffs are not rendered by default.

164 changes: 107 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,10 @@
import { useState, type FormEvent } from "react";
import { useState, type FormEvent, type ReactElement } from "react";
import {
Layout,
Field,
DateField,
DatePicker,
Calendar,
Form,
Button,
useTimeZone,
parseDate,
Expand All @@ -14,6 +14,29 @@ import {
} from "@tailor-platform/app-shell";
import { CalendarDays } from "lucide-react";

function DemoField({
id,
label,
description,
error,
children,
}: {
id: string;
label: string;
description?: string;
error?: string;
children: ReactElement;
}) {
return (
<Field.Root name={id} invalid={!!error} className="flex flex-col gap-1 items-start">
<Field.Label>{label}</Field.Label>
{children}
{description && <Field.Description>{description}</Field.Description>}
{error && <Field.Error match={true}>{error}</Field.Error>}
</Field.Root>
);
}

const DatePickerPage = () => {
const tz = useTimeZone();
const [fieldValue, setFieldValue] = useState<CalendarDate | null>(null);
Expand All @@ -29,8 +52,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; the example uses AppShell's `Field` wiring so
// the date controls behave like the other form inputs in the library.
const handleDeliverySubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!deliveryDate) {
Expand Down Expand Up @@ -72,19 +95,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 +128,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 +173,32 @@ 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 AppShell{" "}
<code className="bg-muted px-1 py-0.5 rounded">Field</code> wiring. Submitting empty
(or with a past date) marks the date picker invalid, and the error clears as soon as a
valid date is picked.
</p>
<Form
<form

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why has this flipped for native HTML form?

Our real form pages would use RHF Form

When restoring this, we encounter a runtime error

Image

(Also occurs when a non-null defaultValue is provided)

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 +215,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 +233,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