feat: replace native birth date input
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user