Skip to content

Commit f1a7080

Browse files
RhysSullivanmrzmyr
andauthored
feat(react): add UI primitives for source forms (#216)
Introduces four shared UI primitives that the upcoming source-form refactor relies on, keeping the refactor PR focused on wiring rather than new atoms: - <FilterTabs> — compact tab selector used by the shared auth section. - <FloatActions> — sticky bottom action bar for save/cancel on long forms (Claude / ElevenLabs-style editing UX). - <IOSSpinner> — iOS-style blade spinner for subtle inline loading states, paired with a new keyframe in globals.css. - <Textarea> — adds an optional maxRows prop so source-form preview/error panels can cap their height without bespoke wrappers. No consumers updated in this PR; follow-up PRs adopt them. Co-authored-by: mrzmyr <mrzmyr@users.noreply.github.com>
1 parent 7337765 commit f1a7080

5 files changed

Lines changed: 140 additions & 2 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"use client";
2+
3+
import type { ReactNode } from "react";
4+
import { Button } from "./button";
5+
import { cn } from "../lib/utils";
6+
7+
export interface FilterTab<T extends string = string> {
8+
label: ReactNode;
9+
value: T;
10+
count?: number;
11+
}
12+
13+
interface FilterTabsProps<T extends string = string> {
14+
tabs: FilterTab<T>[];
15+
value: T;
16+
onChange: (value: T) => void;
17+
}
18+
19+
export function FilterTabs<T extends string = string>({
20+
tabs,
21+
value,
22+
onChange,
23+
}: FilterTabsProps<T>) {
24+
return (
25+
<div className="flex flex-wrap items-center gap-1">
26+
{tabs.map((tab) => {
27+
const isActive = value === tab.value;
28+
return (
29+
<Button
30+
variant="outline"
31+
size="sm"
32+
key={tab.value}
33+
onClick={() => onChange(tab.value)}
34+
className={cn(
35+
"inline-flex items-center justify-center gap-1.5 rounded-full px-2.5 py-1 text-sm font-medium shadow-none transition-transform duration-100 active:scale-[0.98]",
36+
isActive
37+
? "border-border bg-background text-foreground"
38+
: "border-transparent bg-transparent text-muted-foreground hover:bg-muted hover:text-foreground",
39+
)}
40+
>
41+
{tab.label}
42+
{tab.count !== undefined && (
43+
<span
44+
className={cn(
45+
"inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full px-1 text-xs tabular-nums",
46+
isActive ? "bg-muted text-foreground" : "bg-muted/60 text-muted-foreground",
47+
)}
48+
>
49+
{tab.count}
50+
</span>
51+
)}
52+
</Button>
53+
);
54+
})}
55+
</div>
56+
);
57+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import * as React from "react";
2+
import { useRouterState } from "@tanstack/react-router";
3+
4+
import { cn } from "../lib/utils";
5+
import { CardStack } from "./card-stack";
6+
7+
/**
8+
* A floating action bar that pins itself to the bottom of the nearest
9+
* positioned ancestor (usually a scroll container with `position: relative`).
10+
*
11+
* Rendered as a `CardStack` so it picks up the card visual — wrap action
12+
* buttons as children; they'll be right-aligned inside the card.
13+
*
14+
* To pin to the bottom even when content is short, the parent should be a
15+
* flex column filling the scroll container (so `mt-auto` pushes the actions
16+
* down). `sticky bottom-4` also keeps it visible while scrolling long content.
17+
*
18+
* Hidden while the router is navigating/loading so the actions don't flash
19+
* on top of a loading state.
20+
*/
21+
function FloatActions({ className, children }: { className?: string; children: React.ReactNode }) {
22+
const isLoading = useRouterState({ select: (s) => s.isLoading });
23+
24+
if (isLoading) {
25+
return null;
26+
}
27+
28+
return (
29+
<CardStack className={cn("sticky shadow-lg bottom-4 left-0 right-0 mt-auto w-full", className)}>
30+
<div className="flex items-center justify-end gap-3 px-4 py-3">{children}</div>
31+
</CardStack>
32+
);
33+
}
34+
35+
export { FloatActions };

packages/react/src/components/spinner.tsx

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,36 @@ function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
1313
);
1414
}
1515

16-
export { Spinner };
16+
const IOS_SPINNER_BLADES = 12;
17+
18+
function IOSSpinner({ className, ...props }: React.ComponentProps<"svg">) {
19+
return (
20+
<svg
21+
role="status"
22+
aria-label="Loading"
23+
viewBox="0 0 24 24"
24+
className={cn("size-4 text-muted-foreground", className)}
25+
{...props}
26+
>
27+
{Array.from({ length: IOS_SPINNER_BLADES }).map((_, i) => (
28+
<rect
29+
key={i}
30+
x="11"
31+
y="2"
32+
width="2"
33+
height="6"
34+
rx="1"
35+
fill="currentColor"
36+
transform={`rotate(${(360 / IOS_SPINNER_BLADES) * i} 12 12)`}
37+
style={{
38+
animation: "ios-spinner-fade 1s linear infinite",
39+
animationDelay: `${(i / IOS_SPINNER_BLADES) * 1 - 1}s`,
40+
opacity: 0.25,
41+
}}
42+
/>
43+
))}
44+
</svg>
45+
);
46+
}
47+
48+
export { Spinner, IOSSpinner };

packages/react/src/components/textarea.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import * as React from "react";
22

33
import { cn } from "../lib/utils";
44

5-
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
5+
function Textarea({
6+
className,
7+
maxRows,
8+
...props
9+
}: React.ComponentProps<"textarea"> & { maxRows?: number }) {
610
return (
711
// oxlint-disable-next-line react/forbid-elements
812
<textarea
@@ -12,6 +16,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
1216
className,
1317
)}
1418
{...props}
19+
style={{ maxHeight: maxRows ? `${maxRows * 1.5}rem` : undefined }}
1520
/>
1621
);
1722
}

packages/react/src/styles/globals.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,12 @@
159159
--sidebar-border: hsl(240 3.7% 15.9%);
160160
--sidebar-ring: hsl(217.2 91.2% 59.8%);
161161
}
162+
163+
@keyframes ios-spinner-fade {
164+
0% {
165+
opacity: 1;
166+
}
167+
100% {
168+
opacity: 0.25;
169+
}
170+
}

0 commit comments

Comments
 (0)