feat: replace native birth date input

This commit is contained in:
Jesse_Chen
2026-07-17 22:12:19 +08:00
parent 55d105dd3b
commit 6b053e2a15
4 changed files with 101 additions and 10 deletions
+8
View File
@@ -102,6 +102,14 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3:
- **Accessibility:** native radio inputs remain focusable, every conditional field has a persistent label, status text uses live regions, and the complete flow is keyboard operable.
- **Motion:** source-dependent fields enter with the existing 180ms opacity/vertical reveal; reduced-motion removes the translation.
### Birth date picker
- **Composition:** shadcn outline Button trigger, Base UI Popover, and a single-select React DayPicker Calendar.
- **Range:** local dates from 1900-01-01 through today; future dates are disabled. Month and year dropdowns provide direct navigation, with newest years first.
- **Value:** display Chinese long dates while emitting the existing `YYYY-MM-DD` profile value without UTC conversion.
- **States:** empty, open, selected, focus-visible, disabled confirmed profile, and unavailable date.
- **Accessibility:** visible label, explicit trigger naming, 44px targets, keyboard calendar navigation, focus return, and collision-safe popup positioning.
### Model selector
- **Structure:** a compact text trigger sits below the composer and opens an upward popover aligned to its left edge. The trigger shows only the active model name; each option shows only its model name and radio selection state.
@@ -0,0 +1,72 @@
"use client";
import { format } from "date-fns";
import { zhCN } from "date-fns/locale";
import { CalendarIcon } from "lucide-react";
import { useId, useState } from "react";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { formatBirthDate, parseBirthDate } from "@/lib/birth-time-intake-model";
type BirthDatePickerProps = {
readonly value: string;
readonly disabled: boolean;
readonly onChange: (value: string) => void;
};
export function BirthDatePicker({ value, disabled, onChange }: BirthDatePickerProps) {
const labelId = useId();
const valueId = useId();
const [open, setOpen] = useState(false);
const selected = parseBirthDate(value);
const today = new Date();
today.setHours(0, 0, 0, 0);
return (
<div className="grid gap-2">
<span id={labelId}></span>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={<Button
type="button"
variant="outline"
disabled={disabled}
aria-labelledby={`${labelId} ${valueId}`}
data-empty={selected === undefined}
className="w-full justify-start px-3 text-left font-normal data-[empty=true]:text-muted-foreground"
/>}
>
<CalendarIcon aria-hidden="true" />
<span id={valueId}>
{selected === undefined
? "选择出生日期"
: format(selected, "PPP", { locale: zhCN })}
</span>
</PopoverTrigger>
<PopoverContent align="start" className="w-auto p-0">
<Calendar
key={value || "empty"}
mode="single"
className="[--cell-size:2.75rem] [&_button[data-selected-single=true]]:text-primary-foreground!"
locale={zhCN}
selected={selected}
defaultMonth={selected ?? today}
captionLayout="dropdown"
navLayout="after"
startMonth={new Date(1900, 0)}
endMonth={today}
reverseYears
disabled={{ before: new Date(1900, 0, 1), after: today }}
onSelect={(nextDate) => {
if (nextDate === undefined) return;
onChange(formatBirthDate(nextDate));
setOpen(false);
}}
/>
</PopoverContent>
</Popover>
</div>
);
}
+6 -10
View File
@@ -1,6 +1,7 @@
"use client";
import { useId } from "react";
import { BirthDatePicker } from "@/components/birth-date-picker";
import {
birthTimePeriodOptions,
birthTimeSourceOptions,
@@ -58,16 +59,11 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps)
return (
<div className="birth-time-intake">
<label>
<span></span>
<input
required
disabled={isConfirmed}
type="date"
value={value.date}
onChange={(event) => onPatch({ date: event.target.value })}
/>
</label>
<BirthDatePicker
value={value.date}
disabled={isConfirmed}
onChange={(date) => onPatch({ date })}
/>
{source === "legacy_import" && (
<p className="birth-time-legacy-note">
@@ -3,9 +3,24 @@ import { existsSync, readFileSync } from "node:fs"
import test from "node:test"
const packageJson = readFileSync(new URL("../package.json", import.meta.url), "utf8")
const intake = readFileSync(new URL("../src/components/birth-time-intake.tsx", import.meta.url), "utf8")
const pickerUrl = new URL("../src/components/birth-date-picker.tsx", import.meta.url)
test("provides the shadcn calendar and popover primitives", () => {
assert.equal(existsSync(new URL("../src/components/ui/calendar.tsx", import.meta.url)), true)
assert.equal(existsSync(new URL("../src/components/ui/popover.tsx", import.meta.url)), true)
assert.match(packageJson, /"react-day-picker"/)
})
test("replaces the native birth date input with the shadcn date picker", () => {
assert.equal(existsSync(pickerUrl), true)
assert.doesNotMatch(intake, /type="date"/)
assert.match(intake, /<BirthDatePicker/)
const picker = readFileSync(pickerUrl, "utf8")
assert.match(picker, /<PopoverTrigger/)
assert.match(picker, /render=\{<Button/)
assert.match(picker, /<Calendar/)
assert.match(picker, /captionLayout="dropdown"/)
assert.match(picker, /startMonth=\{new Date\(1900, 0\)\}/)
assert.match(picker, /reverseYears/)
})