mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
Merge branch 'fix/coach-panel-search-composer-ring' into release/v1.40.6
This commit is contained in:
commit
861d1ba868
22 files changed
+174
-68
No files matched your search
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -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)$/;
|
||||
|
||||
|
||||
Reference in new issue
Block a user