Merge branch 'fix/coach-panel-search-composer-ring' into release/v1.40.6

This commit is contained in:
Marc-André Bombeck committed 2026-10-05 21:38:14 +02:00
commit 861d1ba868
22 files changed
+174 -68

No files matched your search

+5 -2
View File
@@ -472,7 +472,7 @@ test.describe("Coach page frame", () => {
expect(Math.abs(closed.composer - open.composer)).toBeLessThanOrEqual(1);
// Closed, nothing inside the panel is reachable.
await expect(
panel(page).locator('[data-slot="coach-history-search"]'),
panel(page).locator('[data-slot="coach-history-select"]').first(),
).not.toBeInViewport();
await expectNoSidewaysScroll(page);
await shot(page, testInfo, `coach-frame-${width}-closed`);
@@ -484,7 +484,10 @@ test.describe("Coach page frame", () => {
// Open again, then Escape from inside closes it, focus to the toggle.
await toggle(page).click();
await expect(panel(page)).toHaveAttribute("data-state", "open");
await panel(page).locator('[data-slot="coach-history-search"]').focus();
await panel(page)
.locator('[data-slot="coach-history-select"]')
.first()
.focus();
await page.keyboard.press("Escape");
await expect(panel(page)).toHaveAttribute("data-state", "closed");
await expect(toggle(page)).toBeFocused();
@@ -0,0 +1,121 @@
/**
* Structural guard on the focus treatment of text-entry fields.
*
* A text field shows focus by its border alone: `border-input` turns the
* neutral `input-focus` tone (a foreground-based grey, at least 3:1 on the
* background in both themes). No colour ring, no purple. The ring was the
* loudest thing on a form, and for a text field it is on for every mouse
* click too, because `:focus-visible` always matches a field that takes
* typed input. Buttons, links, checkboxes and switches keep their rings;
* they are not in scope here.
*
* The one ring a field still draws is the destructive one on an invalid
* field that has focus (`aria-invalid:focus-visible:ring-[3px]`): its border
* is already red, so the ring is what tells focus apart there.
*
* Two checks:
*
* 1. Every text-entry primitive (and the Coach composer) uses the token.
* 2. Every class string in `src/` that styles a text field (it names
* `border-input`, the focus token, or a `placeholder:` colour) carries no focus ring and no
* primary/ring-coloured focus border. The sweep must find a floor of
* such strings, so a matcher that stops matching fails instead of
* passing on nothing.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = join(__dirname, "..", "..");
const SRC = join(ROOT, "src");
/** Text-entry primitives and the hand-built text fields that own a focus style. */
const TEXT_FIELD_FILES = [
"src/components/ui/input.tsx",
"src/components/ui/textarea.tsx",
"src/components/ui/select.tsx",
"src/components/ui/native-select.tsx",
"src/components/ui/date-field.tsx",
"src/components/ui/time-field.tsx",
"src/components/ui/calendar.tsx",
"src/components/insights/coach-panel/coach-input.tsx",
"src/components/cycle/cycle-settings.tsx",
"src/components/records/about-me-note-manager.tsx",
"src/components/records/conditions-manager.tsx",
"src/components/records/allergy-free-text-note.tsx",
];
const FOCUS_VARIANT = /(^|:)(focus|focus-visible|focus-within|has-focus)(:|$)/;
const BANNED_UTILITY =
/^(ring(-\[[^\]]+\]|-\d+)?$|ring-(ring|primary|offset)|border-(ring|primary)|outline-(ring|primary))/;
/** Focus tokens in a class string that put a ring or a colour border on focus. */
function bannedFocusTokens(classString: string): string[] {
return classString.split(/\s+/).filter((token) => {
if (!token.includes(":")) return false;
const parts = token.split(":");
const utility = parts.pop()!;
const variants = parts.join(":");
if (!FOCUS_VARIANT.test(variants)) return false;
// The invalid field's destructive focus ring is the documented exception.
if (/(^|:)aria-invalid(:|$)/.test(variants)) return false;
return BANNED_UTILITY.test(utility);
});
}
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (name === "generated" || name === "__tests__") continue;
if (statSync(path).isDirectory()) walk(path, out);
else if (/\.(ts|tsx)$/.test(name) && !/\.test\.tsx?$/.test(name))
out.push(path);
}
return out;
}
const STRING_LITERAL = /"([^"\n]*)"|`([^`]*)`|'([^'\n]*)'/g;
const TEXT_FIELD_MARKER =
/(^|\s)(border-input|placeholder:text-)|border-input-focus/;
describe("text-field focus", () => {
it("the detector flags a ring and a purple border, and spares the exceptions", () => {
expect(
bannedFocusTokens(
"focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-within:border-primary/50 focus-within:ring-2 has-focus:border-ring focus-visible:ring-offset-2",
),
).toHaveLength(6);
expect(
bannedFocusTokens(
"focus-visible:border-input-focus aria-invalid:focus-visible:ring-[3px] focus-visible:outline-none aria-invalid:ring-destructive/20",
),
).toEqual([]);
});
it.each(TEXT_FIELD_FILES)("%s uses the neutral focus border", (file) => {
const source = readFileSync(join(ROOT, file), "utf8");
expect(source).toContain("border-input-focus");
expect(source).not.toMatch(/ring-primary/);
});
it("no class string that styles a text field draws a focus ring", () => {
const offenders: string[] = [];
let fieldStrings = 0;
for (const file of walk(SRC)) {
const source = readFileSync(file, "utf8");
for (const match of source.matchAll(STRING_LITERAL)) {
const literal = match[1] ?? match[2] ?? match[3] ?? "";
if (!TEXT_FIELD_MARKER.test(literal)) continue;
fieldStrings++;
const bad = bannedFocusTokens(literal);
if (bad.length > 0)
offenders.push(`${relative(ROOT, file)}: ${bad.join(" ")}`);
}
}
// The primitives alone account for more than this; fewer means the
// matcher went blind, not that the fields went away.
expect(fieldStrings).toBeGreaterThanOrEqual(10);
expect(offenders).toEqual([]);
});
});
+6
View File
@@ -56,6 +56,7 @@
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-input-focus: var(--input-focus);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
@@ -241,6 +242,9 @@
--destructive: #ff5555;
--border: #44475a;
--input: #44475a;
/* Text-field focus border: a neutral, foreground-based tone, no colour
* ring. ~5.2:1 on --background, ~6:1 on --card. */
--input-focus: #9a9b9d;
--ring: #bd93f9;
--chart-1: #bd93f9;
--chart-2: #50fa7b;
@@ -369,6 +373,8 @@
--success-foreground: #ffffff;
--border: oklch(0.885 0.008 300);
--input: oklch(0.87 0.009 300);
/* Text-field focus border, neutral: ~4.2:1 on --background. */
--input-focus: oklch(0.56 0.012 295);
--ring: oklch(0.6 0.13 292);
--chart-1: #644ac9;
--chart-2: #14720a;
+1 -1
View File
@@ -371,7 +371,7 @@ function PriorField({
max={max}
value={value}
onChange={(e) => onChange(e.target.value)}
className="border-input bg-background focus-visible:ring-ring/50 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
className="border-input bg-background focus-visible:border-input-focus w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none"
/>
</div>
);
@@ -989,7 +989,7 @@ export function DocumentDetailSheet({
setTitleDraft(doc.title ?? "");
setEditingTitle(true);
}}
className="border-input hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-10 w-full items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm focus-visible:ring-[3px] focus-visible:outline-none"
className="border-input hover:bg-muted/50 focus-visible:border-input-focus flex min-h-10 w-full items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm focus-visible:outline-none"
>
<span
className={cn(
@@ -141,15 +141,15 @@ describe("<HistoryRail>", () => {
expect(html).toContain("Your conversations will appear here.");
});
it("mounts the search input with the localised placeholder", () => {
it("carries no search field (search lives on the full conversations page)", () => {
const client = makeClientWithConversations(samplePage);
const html = render(
<HistoryRail activeId={null} onSelect={() => {}} />,
client,
);
expect(html).toMatch(
/data-slot="coach-history-search"[^>]*placeholder="Search conversations…"/,
);
expect(html).toContain('data-slot="coach-history-list"');
expect(html).not.toContain('type="search"');
expect(html).not.toContain("Search conversations");
});
it("uses German strings when locale is 'de'", () => {
@@ -176,7 +176,7 @@ export function CoachDrawer({
// v1.21.4 (Coach-UI B) — the drawer's "Conversations" affordance now hands
// off to the dedicated conversation-history page (`/coach/conversations`),
// which renders the search + recency-grouped list as a sibling of the
// which renders the recency-grouped list as a sibling of the
// Coach page. The former `?view=conversations` in-page slide-in drawer is
// gone; selecting a row there routes back to `/coach?c=<id>`.
const handleOpenConversations = useCallback(() => {
@@ -343,7 +343,7 @@ export function CoachInput({
className={cn(
"border-border/60 bg-muted/40 group rounded-2xl border",
"shadow-sm transition-colors",
"focus-within:border-primary/50 focus-within:ring-primary/50 focus-within:bg-background focus-within:ring-2",
"focus-within:border-input-focus focus-within:bg-background",
// One row at every width. Mobile used to stack the controls under the
// textarea, which made an EMPTY composer two rows tall: a 44 px line
// plus a 44 px touch-target row plus padding, around a tenth of a
@@ -366,9 +366,8 @@ export function ConversationsPanel({
data-slot="coach-conversations-panel"
className="w-[22rem] max-w-[90vw] gap-0 p-0 sm:max-w-[22rem]"
onOpenAutoFocus={(event) => {
// Land on the sheet itself, not its first control: on a phone
// focusing the search field would raise the keyboard over the
// list the person opened the sheet to read.
// Land on the sheet itself, not its first control, so opening
// the sheet does not paint a focus ring on a header button.
event.preventDefault();
(event.currentTarget as HTMLElement | null)?.focus();
}}
@@ -8,7 +8,6 @@ import {
Paperclip,
Pencil,
RotateCw,
Search,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
@@ -20,10 +19,8 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useLoadMoreSentinel } from "@/hooks/use-load-more-sentinel";
import type { CoachConversationDTO } from "@/lib/ai/coach/types";
import { groupConversationsByRecency } from "@/lib/insights/coach-conversation-groups";
@@ -36,14 +33,14 @@ import {
} from "./use-coach";
/**
* The Coach's conversation list: search, the full history grouped by recency
* The Coach's conversation list: the full history grouped by recency
* (Today / Yesterday / This week / Earlier), and one row per conversation with
* a "⋯" menu for Rename and Delete.
*
* It fills the Coach page's conversations panel and the drawer's history
* tray. The history is cursor-paginated (`useInfiniteQuery`, loaded as the
* list scrolls near its end) and searched server-side on the title, debounced
* 200 ms.
* list scrolls near its end). Title search lives on the standalone
* `/coach/conversations` page, not here.
*
* Rename edits the title in place (`ConversationRename`, Enter saves, Escape
* cancels without closing the panel around it) and returns focus to the row's
@@ -88,8 +85,6 @@ export function HistoryRail({
onUndoDeleteActive,
}: HistoryRailProps) {
const { t } = useTranslations();
const [filter, setFilter] = useState<string>("");
const debouncedFilter = useDebouncedValue(filter, 200);
const {
conversations,
isLoading,
@@ -98,19 +93,17 @@ export function HistoryRail({
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useCoachConversationHistory({ search: debouncedFilter });
} = useCoachConversationHistory();
const ownDeletion = useDeleteCoachConversationWithUndo();
const { pendingDeleteIds, requestDelete, undoDelete } =
deletion ?? ownDeletion;
// `pendingDeleteIds` hides rows inside the undo window; the server-side
// search already resolved everything else.
// `pendingDeleteIds` hides rows inside the undo window.
const visible = useMemo(
() => conversations.filter((c) => !pendingDeleteIds.has(c.id)),
[conversations, pendingDeleteIds],
);
const groups = useMemo(() => groupConversationsByRecency(visible), [visible]);
const isSearching = debouncedFilter.trim().length > 0;
// A `useState`-backed callback ref so the sentinel hook re-runs once the
// scroll container exists (null on the very first render).
@@ -153,21 +146,6 @@ export function HistoryRail({
{t("insights.coach.historyTitle")}
</h3>
)}
<div className="relative w-full">
<Search
aria-hidden="true"
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
/>
<Input
type="search"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder={t("insights.coach.historySearchPlaceholder")}
aria-label={t("insights.coach.historySearchPlaceholder")}
data-slot="coach-history-search"
className="h-10 w-full pl-9"
/>
</div>
<div
ref={setListNode}
data-slot="coach-history-list"
@@ -202,9 +180,7 @@ export function HistoryRail({
data-slot="coach-history-empty"
className="text-muted-foreground px-2 py-3 text-sm leading-relaxed"
>
{isSearching
? t("insights.coach.historySearchEmpty")
: t("insights.coach.historyEmpty")}
{t("insights.coach.historyEmpty")}
</p>
) : (
groups.map((group) => (
@@ -57,7 +57,7 @@ interface AboutMeData {
}
const FIELD_CLASSES =
"border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-1 focus-visible:outline-none";
"border-input bg-background placeholder:text-muted-foreground focus-visible:border-input-focus w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none";
export function AboutMeNoteManager() {
const { t } = useTranslations();
@@ -47,7 +47,7 @@ interface AboutMeData {
}
const FIELD_CLASSES =
"border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-1 focus-visible:outline-none";
"border-input bg-background placeholder:text-muted-foreground focus-visible:border-input-focus w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none";
export function AllergyFreeTextNote() {
const { t } = useTranslations();
@@ -44,7 +44,7 @@ interface AboutMeData {
}
const FIELD_CLASSES =
"border-input bg-background placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-1 focus-visible:outline-none";
"border-input bg-background placeholder:text-muted-foreground focus-visible:border-input-focus w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none";
export function ConditionsManager() {
const { t } = useTranslations();
@@ -111,13 +111,13 @@ describe("<DateField>", () => {
expect(html).toContain('aria-label="Open date picker"');
});
it("uses the Input focus vocabulary — a ring that hugs the box, no offset", () => {
// Drift guard: the field must share the app's focus ring (soft 3 px, no
// offset) so the highlight never paints wider than the div or jumps on
// focus. The old hard `ring-offset-2` ring is banned here.
it("uses the Input focus vocabulary — a neutral border, no ring", () => {
// Drift guard: the field shares the app's text-field focus (the border
// turns the neutral input-focus tone, no ring), so the highlight never
// paints wider than the div or jumps on focus.
const html = render(<DateField value="2026-02-19" />);
expect(html).toContain("focus-within:ring-ring/50");
expect(html).toContain("focus-within:ring-[3px]");
expect(html).toContain("focus-within:border-input-focus");
expect(html).not.toMatch(/focus-within:ring-/);
expect(html).not.toContain("ring-offset-2");
expect(html).not.toContain("ring-offset-background");
});
@@ -132,12 +132,12 @@ describe("<TimeField> SSR", () => {
expect(html).toContain("min-h-11");
expect(html).toContain("sm:h-10");
});
it("uses the Input focus vocabulary — a ring that hugs the box, no offset", () => {
// Drift guard shared with <DateField>: soft 3 px ring, no offset, so the
it("uses the Input focus vocabulary — a neutral border, no ring", () => {
// Drift guard shared with <DateField>: neutral border, no ring, so the
// highlight never overflows the div or jumps on focus.
const html = render(<TimeField value="08:30" />, "de");
expect(html).toContain("focus-within:ring-ring/50");
expect(html).toContain("focus-within:ring-[3px]");
expect(html).toContain("focus-within:border-input-focus");
expect(html).not.toMatch(/focus-within:ring-/);
expect(html).not.toContain("ring-offset-2");
expect(html).not.toContain("ring-offset-background");
});
+1 -1
View File
@@ -74,7 +74,7 @@ function Calendar({
defaultClassNames.dropdowns,
),
dropdown_root: cn(
"relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50",
"relative rounded-md border border-input shadow-xs has-focus:border-input-focus",
defaultClassNames.dropdown_root,
),
dropdown: cn(
+3 -3
View File
@@ -56,15 +56,15 @@ const FIELD_HEIGHT_CLASSES = "min-h-11 h-11 sm:min-h-10 sm:h-10";
// ~230 px no matter what width the parent hands it, and the surplus spills out
// of whatever dialog or sheet it sits in. See the matching note on the overlay
// input below; both ends are needed, one alone still floors the box.
// The focus vocabulary matches `<Input>` exactly: a soft translucent 3 px ring
// that hugs the border box with no offset. The earlier `ring-2 ring-offset-2`
// The focus vocabulary matches `<Input>` exactly: the border turns the neutral
// `input-focus` tone, with no ring. The earlier `ring-2 ring-offset-2`
// painted a hard 4 px ring OUTSIDE the box — wider than the field's div and
// visibly mismatched next to the app's other inputs, and it shifted the
// highlight box on focus. `text-base md:text-sm` mirrors `<Input>` too: a
// sub-16 px input triggers iOS Safari's focus auto-zoom, which reads as a
// layout jump; 16 px below `md:` suppresses it.
const FIELD_BASE_CLASSES =
"border-input bg-background text-foreground placeholder:text-muted-foreground relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-base md:text-sm shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px] focus-within:outline-none";
"border-input bg-background text-foreground placeholder:text-muted-foreground relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-base md:text-sm shadow-xs transition-[color,box-shadow] focus-within:border-input-focus focus-within:outline-none";
export interface DateFieldProps {
id?: string;
+1 -1
View File
@@ -77,7 +77,7 @@ function Input({
// v1.4.34 IW-G — floor at 44 px on mobile to clear WCAG 2.5.5;
// shrink to 40 px on sm+ where pointer precision is higher.
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-11 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 sm:h-10 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"focus-visible:border-input-focus aria-invalid:focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
+1 -1
View File
@@ -55,7 +55,7 @@ import { cn } from "@/lib/utils";
// chevron uses.
const NATIVE_SELECT_CLASS =
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-11 sm:h-10 w-full appearance-none rounded-md border ps-3 pe-9 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none bg-[image:var(--native-select-chevron)] bg-[length:1rem] bg-[position:right_0.75rem_center] bg-no-repeat";
"border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:border-input-focus flex h-11 sm:h-10 w-full appearance-none rounded-md border ps-3 pe-9 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:outline-none bg-[image:var(--native-select-chevron)] bg-[length:1rem] bg-[position:right_0.75rem_center] bg-no-repeat";
export type NativeSelectProps = React.SelectHTMLAttributes<HTMLSelectElement>;
+1 -1
View File
@@ -49,7 +49,7 @@ function SelectTrigger({
// value are inset equally; `gap-2` between them keeps the chevron
// from crowding a long value, and `shrink-0` guarantees it never
// clips.
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-11 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 data-[size=default]:sm:h-10 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-input-focus aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:focus-visible:ring-[3px] data-[size=default]:h-11 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 data-[size=default]:sm:h-10 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
+1 -1
View File
@@ -66,7 +66,7 @@ function Textarea({
// iOS zoom defence + WCAG 2.5.5 tap-target floor; see the
// primitive's docblock for the full reasoning.
"border-input bg-background text-foreground placeholder:text-muted-foreground dark:bg-input/30 min-h-11 w-full rounded-md border px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 sm:min-h-9 sm:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"focus-visible:border-input-focus aria-invalid:focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
+5 -4
View File
@@ -42,12 +42,13 @@ const FIELD_HEIGHT_CLASSES = "min-h-11 h-11 sm:min-h-10 sm:h-10";
// automatic minimum size would otherwise be the 20-character intrinsic width of
// the text input inside, which floors the whole field at ~230 px and overflows
// the dialog that hosts it. See the note in `date-field.tsx`.
// Focus vocabulary and font-size parity with `<Input>` / `<DateField>`: a soft
// 3 px ring hugging the box (not the old hard `ring-2 ring-offset-2`, which
// painted 4 px outside the div and jumped on focus) and `text-base md:text-sm`
// Focus vocabulary and font-size parity with `<Input>` / `<DateField>`: the
// border turns the neutral `input-focus` tone with no ring (not the old hard
// `ring-2 ring-offset-2`, which painted 4 px outside the div and jumped on
// focus) and `text-base md:text-sm`
// to keep iOS Safari from auto-zooming a sub-16 px input.
const FIELD_BASE_CLASSES =
"border-input bg-background text-foreground placeholder:text-muted-foreground relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-base md:text-sm shadow-xs transition-[color,box-shadow] focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px] focus-within:outline-none";
"border-input bg-background text-foreground placeholder:text-muted-foreground relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-base md:text-sm shadow-xs transition-[color,box-shadow] focus-within:border-input-focus focus-within:outline-none";
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;