@tangle-network/ui 11.14.0 → 11.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/dist/chat/chat-container.js +31 -9
- package/dist/chat/synthetic-text.js +52 -0
- package/dist/chat/user-message.d.ts +2 -4
- package/dist/chat/user-message.js +34 -13
- package/dist/lib/control-presentation.js +11 -0
- package/dist/primitives/button.d.ts +2 -2
- package/dist/primitives/button.js +11 -9
- package/dist/primitives/input.d.ts +3 -1
- package/dist/primitives/input.js +7 -5
- package/dist/primitives/metric-strip.d.ts +6 -28
- package/dist/primitives/metric-strip.js +15 -18
- package/dist/primitives/select.d.ts +4 -1
- package/dist/primitives/select.js +7 -7
- package/dist/run/run-group.js +89 -62
- package/package.json +1 -1
- package/src/chat/chat-container.synthetic.stories.tsx +82 -0
- package/src/chat/chat-container.synthetic.test.tsx +171 -0
- package/src/chat/chat-container.tsx +33 -16
- package/src/chat/synthetic-text.test.tsx +80 -0
- package/src/chat/synthetic-text.tsx +51 -0
- package/src/chat/user-message.tsx +54 -36
- package/src/lib/control-presentation.ts +26 -0
- package/src/primitives/button.tsx +11 -9
- package/src/primitives/control-presentation.stories.tsx +79 -0
- package/src/primitives/control-presentation.test.tsx +67 -0
- package/src/primitives/input.tsx +12 -6
- package/src/primitives/metric-strip.stories.tsx +39 -0
- package/src/primitives/metric-strip.test.tsx +82 -144
- package/src/primitives/metric-strip.tsx +27 -61
- package/src/primitives/select.tsx +16 -9
- package/src/run/run-group.tsx +99 -64
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { SessionPart } from "../types/parts";
|
|
2
|
+
|
|
3
|
+
/** Internal transcript chrome, never attributed to a user or model. */
|
|
4
|
+
export function SyntheticText({ text }: { text: string }) {
|
|
5
|
+
if (!text.trim()) return null;
|
|
6
|
+
return (
|
|
7
|
+
<div
|
|
8
|
+
role="note"
|
|
9
|
+
aria-label="Application note"
|
|
10
|
+
data-synthetic-text=""
|
|
11
|
+
className="min-w-0 rounded-md border border-dashed border-border bg-muted/30 px-3 py-2 text-sm text-muted-foreground"
|
|
12
|
+
>
|
|
13
|
+
<div className="mb-1 font-medium">Application note</div>
|
|
14
|
+
{/* Plain React text: receipts are not Markdown or executable OpenUI. */}
|
|
15
|
+
<div className="break-words whitespace-pre-wrap">{text}</div>
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface UserTextSegment {
|
|
21
|
+
/** Original part index, so subsequent streamed parts do not change keys. */
|
|
22
|
+
index: number;
|
|
23
|
+
synthetic: boolean;
|
|
24
|
+
text: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Keep the existing joined user bubble, splitting only at application notes. */
|
|
28
|
+
export function userTextSegments(parts: SessionPart[]): UserTextSegment[] {
|
|
29
|
+
const segments: UserTextSegment[] = [];
|
|
30
|
+
let pending: string[] = [];
|
|
31
|
+
let start = 0;
|
|
32
|
+
const flush = () => {
|
|
33
|
+
const text = pending.join("\n");
|
|
34
|
+
if (text.trim()) segments.push({ index: start, synthetic: false, text });
|
|
35
|
+
pending = [];
|
|
36
|
+
};
|
|
37
|
+
parts.forEach((part, index) => {
|
|
38
|
+
if (part.type !== "text") return;
|
|
39
|
+
if (part.synthetic) {
|
|
40
|
+
flush();
|
|
41
|
+
if (part.text.trim()) {
|
|
42
|
+
segments.push({ index, synthetic: true, text: part.text });
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
if (pending.length === 0) start = index;
|
|
46
|
+
pending.push(part.text);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
flush();
|
|
50
|
+
return segments;
|
|
51
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { memo, type ReactNode } from "react";
|
|
2
2
|
import type { SessionMessage } from "../types/message";
|
|
3
3
|
import type { SessionPart } from "../types/parts";
|
|
4
|
+
import { SyntheticText, userTextSegments } from "./synthetic-text";
|
|
4
5
|
|
|
5
6
|
export interface UserMessageProps {
|
|
6
7
|
/** Session-model input: text is derived from these parts. */
|
|
@@ -16,47 +17,64 @@ function formatTime(date: Date): string {
|
|
|
16
17
|
return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
function MessageActions({ children }: { children: ReactNode }) {
|
|
21
|
+
return (
|
|
22
|
+
<div className="flex flex-wrap items-center justify-end gap-1.5 text-xs text-muted-foreground">
|
|
23
|
+
{children}
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function UserBubble({ text, timestamp, actions }: {
|
|
29
|
+
text: string;
|
|
30
|
+
timestamp?: Date;
|
|
31
|
+
actions?: ReactNode;
|
|
32
|
+
}) {
|
|
33
|
+
return (
|
|
34
|
+
<div className="flex justify-end">
|
|
35
|
+
<div className="group flex min-w-0 max-w-[78%] flex-col items-end gap-2">
|
|
36
|
+
<div className="relative w-full min-w-0 rounded-2xl bg-foreground px-4 py-3 text-background">
|
|
37
|
+
{timestamp ? (
|
|
38
|
+
<span
|
|
39
|
+
data-user-message-time=""
|
|
40
|
+
className="pointer-events-none absolute right-full top-1/2 mr-3 -translate-y-1/2 whitespace-nowrap text-[var(--font-size-xs)] text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100"
|
|
41
|
+
>
|
|
42
|
+
{formatTime(timestamp)}
|
|
43
|
+
</span>
|
|
44
|
+
) : null}
|
|
45
|
+
<div className="break-words whitespace-pre-wrap text-[var(--font-size-base)] leading-[1.5]">
|
|
46
|
+
{text}
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
{actions ? <MessageActions>{actions}</MessageActions> : null}
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
19
55
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* the run/message list (session-model `parts`) and AgentTimeline (direct
|
|
23
|
-
* `content`/`timestamp`).
|
|
56
|
+
* User speech retains the inverse-theme bubble and direct-content contract.
|
|
57
|
+
* Application notes interrupt the bubble in source order, outside its attribution.
|
|
24
58
|
*/
|
|
25
59
|
export const UserMessage = memo(
|
|
26
60
|
({ message: _message, parts, content, timestamp, actions }: UserMessageProps) => {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
61
|
+
// Explicit direct content (including empty content) still overrides parts.
|
|
62
|
+
if (content != null) {
|
|
63
|
+
return content.trim() ? <UserBubble text={content} timestamp={timestamp} actions={actions} /> : null;
|
|
64
|
+
}
|
|
65
|
+
const segments = userTextSegments(parts ?? []);
|
|
66
|
+
if (segments.length === 0) return null;
|
|
67
|
+
if (segments.length === 1 && !segments[0].synthetic) {
|
|
68
|
+
return <UserBubble text={segments[0].text} timestamp={timestamp} actions={actions} />;
|
|
69
|
+
}
|
|
36
70
|
return (
|
|
37
|
-
<div className="
|
|
38
|
-
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
data-user-message-time=""
|
|
45
|
-
className="pointer-events-none absolute right-full top-1/2 mr-3 -translate-y-1/2 whitespace-nowrap text-[var(--font-size-xs)] text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100"
|
|
46
|
-
>
|
|
47
|
-
{formatTime(timestamp)}
|
|
48
|
-
</span>
|
|
49
|
-
) : null}
|
|
50
|
-
<div className="break-words whitespace-pre-wrap text-[var(--font-size-base)] leading-[1.5]">
|
|
51
|
-
{text}
|
|
52
|
-
</div>
|
|
53
|
-
</div>
|
|
54
|
-
{actions ? (
|
|
55
|
-
<div className="flex flex-wrap items-center justify-end gap-1.5 text-xs text-muted-foreground">
|
|
56
|
-
{actions}
|
|
57
|
-
</div>
|
|
58
|
-
) : null}
|
|
59
|
-
</div>
|
|
71
|
+
<div className="space-y-2">
|
|
72
|
+
{segments.map((segment) => segment.synthetic ? (
|
|
73
|
+
<SyntheticText key={segment.index} text={segment.text} />
|
|
74
|
+
) : (
|
|
75
|
+
<UserBubble key={segment.index} text={segment.text} timestamp={timestamp} />
|
|
76
|
+
))}
|
|
77
|
+
{actions ? <MessageActions>{actions}</MessageActions> : null}
|
|
60
78
|
</div>
|
|
61
79
|
);
|
|
62
80
|
},
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { focusField } from "./focus";
|
|
2
|
+
import { cn } from "./utils";
|
|
3
|
+
|
|
4
|
+
// Time-valued tokens only: --transition-fast is a legacy duration + easing
|
|
5
|
+
// shorthand, not a valid transition-duration. Reuse Brand's scoped vocabulary.
|
|
6
|
+
export const controlMotion =
|
|
7
|
+
"duration-[var(--duration-fast)] ease-[var(--ease-standard)] motion-reduce:transition-none motion-reduce:duration-0";
|
|
8
|
+
|
|
9
|
+
// Explicit opt-in sizes. Legacy defaults and sm/lg/icon sizes stay unchanged.
|
|
10
|
+
// Compact follows the consumer's density token; touch does not shrink with it.
|
|
11
|
+
export const controlSizes = {
|
|
12
|
+
compact: "h-[var(--control-height)] px-3 py-1 text-sm",
|
|
13
|
+
touch: "h-11 min-h-11 min-w-11 px-4 py-2 text-base",
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
// --input is the shadcn border/track role, NOT a field background. --bg-input
|
|
17
|
+
// resolves the recessed well inside every canonical/named light/dark scope.
|
|
18
|
+
// An autofill inset shadow coexists with the Tailwind focus ring; never hide
|
|
19
|
+
// autofill with a seconds-long background transition or clear the focus halo.
|
|
20
|
+
export const fieldPresentation = cn(
|
|
21
|
+
focusField,
|
|
22
|
+
controlMotion,
|
|
23
|
+
"min-w-0 bg-[var(--bg-input)] text-foreground",
|
|
24
|
+
"aria-invalid:border-[var(--surface-danger-border)] aria-invalid:hover:border-[var(--surface-danger-border)] aria-invalid:focus:border-[var(--focus-border-danger)] aria-invalid:focus:ring-[var(--focus-halo-danger)]",
|
|
25
|
+
"autofill:shadow-[inset_0_0_0_1000px_var(--bg-input)] autofill:[-webkit-text-fill-color:var(--text-primary)] autofill:caret-[var(--text-primary)]",
|
|
26
|
+
);
|
|
@@ -1,27 +1,28 @@
|
|
|
1
1
|
import { Slot } from "@radix-ui/react-slot";
|
|
2
2
|
import { cva } from "class-variance-authority";
|
|
3
3
|
import * as React from "react";
|
|
4
|
+
import { controlMotion, controlSizes } from "../lib/control-presentation";
|
|
4
5
|
import { focusRing } from "../lib/focus";
|
|
5
6
|
import { cn } from "../lib/utils";
|
|
6
7
|
|
|
7
8
|
const buttonVariants = cva(
|
|
8
|
-
`inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium text-sm transition-
|
|
9
|
+
`inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium text-sm transition-[color,background-color,border-color,box-shadow,transform,scale] ${controlMotion} ${focusRing} disabled:pointer-events-none disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 motion-reduce:active:scale-100 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0`,
|
|
9
10
|
{
|
|
10
11
|
variants: {
|
|
11
12
|
variant: {
|
|
12
13
|
default:
|
|
13
|
-
"bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.97]
|
|
14
|
+
"bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.97]",
|
|
14
15
|
destructive:
|
|
15
|
-
"bg-destructive text-destructive-foreground hover:bg-destructive/90 active:scale-[0.97]
|
|
16
|
+
"bg-destructive text-destructive-foreground hover:bg-destructive/90 active:scale-[0.97]",
|
|
16
17
|
outline:
|
|
17
|
-
"border border-border bg-card hover:bg-muted active:scale-[0.97]
|
|
18
|
+
"border border-border bg-card hover:bg-muted active:scale-[0.97] text-foreground",
|
|
18
19
|
secondary:
|
|
19
|
-
"bg-muted border border-border text-foreground hover:bg-muted/80 active:scale-[0.97]
|
|
20
|
+
"bg-muted border border-border text-foreground hover:bg-muted/80 active:scale-[0.97]",
|
|
20
21
|
ghost:
|
|
21
|
-
"hover:bg-muted hover:text-foreground
|
|
22
|
-
link: "text-
|
|
22
|
+
"hover:bg-muted hover:text-foreground text-muted-foreground border border-transparent",
|
|
23
|
+
link: "text-[var(--accent-text)] underline-offset-4 hover:underline",
|
|
23
24
|
sandbox:
|
|
24
|
-
"bg-[var(--btn-primary-bg)] text-[var(--btn-primary-text)] border border-[var(--border-accent)] hover:bg-[var(--btn-primary-hover)] active:scale-[0.97]
|
|
25
|
+
"bg-[var(--btn-primary-bg)] text-[var(--btn-primary-text)] border border-[var(--border-accent)] hover:bg-[var(--btn-primary-hover)] active:scale-[0.97]",
|
|
25
26
|
},
|
|
26
27
|
size: {
|
|
27
28
|
default: "h-[var(--control-height)] px-4 py-2",
|
|
@@ -29,6 +30,7 @@ const buttonVariants = cva(
|
|
|
29
30
|
lg: "h-11 rounded-lg px-7 text-sm",
|
|
30
31
|
xl: "h-13 rounded-xl px-9 text-base",
|
|
31
32
|
icon: "h-[var(--control-height)] w-[var(--control-height)]",
|
|
33
|
+
...controlSizes,
|
|
32
34
|
},
|
|
33
35
|
},
|
|
34
36
|
defaultVariants: {
|
|
@@ -41,7 +43,7 @@ const buttonVariants = cva(
|
|
|
41
43
|
export interface ButtonProps
|
|
42
44
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
43
45
|
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" | "sandbox" | null;
|
|
44
|
-
size?: "default" | "sm" | "lg" | "xl" | "icon" | null;
|
|
46
|
+
size?: "default" | "sm" | "lg" | "xl" | "icon" | "compact" | "touch" | null;
|
|
45
47
|
asChild?: boolean;
|
|
46
48
|
loading?: boolean;
|
|
47
49
|
children?: React.ReactNode;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
|
+
import { Button } from "./button";
|
|
4
|
+
import { Input, Textarea } from "./input";
|
|
5
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
|
|
6
|
+
import { Metric, MetricStrip } from "./metric-strip";
|
|
7
|
+
|
|
8
|
+
function Contract() {
|
|
9
|
+
const [actions, setActions] = React.useState(0);
|
|
10
|
+
const [choice, setChoice] = React.useState("one");
|
|
11
|
+
return (
|
|
12
|
+
<main className="mx-auto max-w-5xl space-y-6 bg-background p-4 text-foreground" data-presentation-contract>
|
|
13
|
+
<h1 className="text-xl font-semibold">Field and metric presentation</h1>
|
|
14
|
+
{(["default", "compact", "touch"] as const).map((size) => (
|
|
15
|
+
<section key={size} data-size-row={size} className="space-y-2">
|
|
16
|
+
<h2 className="font-semibold">{size}</h2>
|
|
17
|
+
<div className="flex flex-wrap items-end gap-3">
|
|
18
|
+
<Input size={size} aria-label={`${size} field`} placeholder="Project name" className="w-48 max-w-full" />
|
|
19
|
+
<Select value={choice} onValueChange={setChoice}>
|
|
20
|
+
<SelectTrigger size={size} aria-label={`${size} choice`} className="w-48 max-w-full"><SelectValue /></SelectTrigger>
|
|
21
|
+
<SelectContent>
|
|
22
|
+
<SelectItem value="one">First account</SelectItem>
|
|
23
|
+
<SelectItem value="two">Second account with a descriptive long name</SelectItem>
|
|
24
|
+
</SelectContent>
|
|
25
|
+
</Select>
|
|
26
|
+
<Button size={size} onClick={() => setActions((value) => value + 1)}>Save</Button>
|
|
27
|
+
</div>
|
|
28
|
+
<Textarea size={size} aria-label={`${size} notes`} placeholder="Details" />
|
|
29
|
+
</section>
|
|
30
|
+
))}
|
|
31
|
+
<section className="space-y-2" data-state-controls>
|
|
32
|
+
<h2 className="font-semibold">States and actions</h2>
|
|
33
|
+
<Input label="Email" autoComplete="email" name="email" defaultValue="reader@example.test" />
|
|
34
|
+
<Input label="Invalid field" hint="The hint remains available" error="Please correct this value" defaultValue="invalid" />
|
|
35
|
+
<Input label="Disabled field" disabled defaultValue="Unavailable" />
|
|
36
|
+
<div className="flex flex-wrap gap-3">
|
|
37
|
+
<Button loading data-pending>Publish</Button>
|
|
38
|
+
<Button disabled>Disabled action</Button>
|
|
39
|
+
<Button variant="link" data-link-action onClick={() => setActions((value) => value + 1)}>Review details</Button>
|
|
40
|
+
<Button asChild loading><a href="#unavailable" data-pending-link>Pending link</a></Button>
|
|
41
|
+
<Button size="touch" className="h-auto max-w-full whitespace-normal" data-long-action onClick={() => setActions((value) => value + 1)}>Save changes and return to the organization workspace</Button>
|
|
42
|
+
</div>
|
|
43
|
+
<output data-action-count>{actions}</output>
|
|
44
|
+
<output data-choice>{choice}</output>
|
|
45
|
+
</section>
|
|
46
|
+
<section data-density style={{ "--control-height": "32px" } as React.CSSProperties} className="flex flex-wrap gap-3">
|
|
47
|
+
<Input size="compact" aria-label="Dense field" className="w-48" />
|
|
48
|
+
<Select><SelectTrigger size="compact" aria-label="Dense choice" className="w-48"><SelectValue placeholder="Choose" /></SelectTrigger></Select>
|
|
49
|
+
<Button size="compact">Dense action</Button>
|
|
50
|
+
<Button size="touch">Touch action</Button>
|
|
51
|
+
</section>
|
|
52
|
+
<section data-theme="light" className="space-y-3 rounded-lg bg-card p-4 text-foreground" data-scope="light">
|
|
53
|
+
<Input aria-label="Light island" defaultValue="Light ink and well" />
|
|
54
|
+
<div data-theme="dark" className="space-y-3 rounded-lg bg-card p-4 text-foreground" data-scope="dark">
|
|
55
|
+
<Input aria-label="Dark island" defaultValue="Nested dark ink and well" />
|
|
56
|
+
<div data-theme="aubergine-light" className="rounded-lg bg-card p-4 text-foreground" data-scope="named-light">
|
|
57
|
+
<Input aria-label="Named island" defaultValue="Nested named light well" />
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</section>
|
|
61
|
+
{([3, 4, 5, 6] as const).map((columns) => (
|
|
62
|
+
<MetricStrip key={columns} columns={columns} data-columns={columns}>
|
|
63
|
+
{Array.from({ length: columns }, (_, i) => <Metric key={i}
|
|
64
|
+
label={i === 0 ? "Available balance · Organization operations wallet" : `Metric ${i}`}
|
|
65
|
+
value={i === 0 ? "$1,284,003.10" : i === 1 ? 0 : i === 2 ? "—" : "12345678901234567890"}
|
|
66
|
+
hint={i === 0 ? "LongUnbrokenAccountIdentity12345678901234567890" : i === 1 ? 0 : "Current window"} />)}
|
|
67
|
+
</MetricStrip>
|
|
68
|
+
))}
|
|
69
|
+
<MetricStrip data-legacy-summary className="sm:grid-cols-3">
|
|
70
|
+
<Metric className="max-sm:col-span-2 max-sm:border-b" label="Total spent" value="$123.45" />
|
|
71
|
+
<Metric className="max-sm:border-l-0!" label="Calls" value={0} />
|
|
72
|
+
<Metric className="max-sm:border-l!" label="Average" value="$0.000000" />
|
|
73
|
+
</MetricStrip>
|
|
74
|
+
</main>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const meta = { title: "Primitives/Control Presentation", component: Contract, parameters: { layout: "fullscreen" } } satisfies Meta<typeof Contract>;
|
|
78
|
+
export default meta;
|
|
79
|
+
export const Presentation: StoryObj<typeof meta> = {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { controlMotion, fieldPresentation } from "../lib/control-presentation";
|
|
5
|
+
import { Button, buttonVariants } from "./button";
|
|
6
|
+
import { Input, Textarea } from "./input";
|
|
7
|
+
import { Select, SelectTrigger, SelectValue } from "./select";
|
|
8
|
+
|
|
9
|
+
afterEach(cleanup);
|
|
10
|
+
|
|
11
|
+
describe("shared control presentation", () => {
|
|
12
|
+
it("uses the field well, not the border/track alias, with scoped foreground and time-only motion", () => {
|
|
13
|
+
expect(fieldPresentation).toContain("bg-[var(--bg-input)]");
|
|
14
|
+
expect(fieldPresentation.split(" ")).not.toContain("bg-input");
|
|
15
|
+
expect(fieldPresentation.split(" ")).not.toContain("bg-card");
|
|
16
|
+
expect(fieldPresentation).toContain("border-border");
|
|
17
|
+
expect(fieldPresentation).toContain("text-foreground");
|
|
18
|
+
expect(fieldPresentation).not.toContain("duration-150");
|
|
19
|
+
expect(controlMotion).toContain("duration-[var(--duration-fast)]");
|
|
20
|
+
expect(controlMotion).not.toContain("--transition-fast");
|
|
21
|
+
expect(controlMotion).toContain("motion-reduce:duration-0");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("keeps original defaults and aligns only explicit compact/touch sizes", () => {
|
|
25
|
+
const { rerender } = render(<><Input aria-label="Input" /><Textarea aria-label="Textarea" /><Button>Action</Button><Select><SelectTrigger aria-label="Select"><SelectValue /></SelectTrigger></Select></>);
|
|
26
|
+
expect(screen.getByLabelText("Input").className.split(" ")).toContain("h-11");
|
|
27
|
+
expect(screen.getByLabelText("Textarea").className).toContain("min-h-[120px]");
|
|
28
|
+
expect(screen.getByLabelText("Select").className.split(" ")).toContain("h-9");
|
|
29
|
+
expect(screen.getByText("Action").className).toContain("h-[var(--control-height)]");
|
|
30
|
+
for (const size of ["compact", "touch"] as const) {
|
|
31
|
+
rerender(<><Input size={size} aria-label="Input" /><Textarea size={size} aria-label="Textarea" /><Button size={size}>Action</Button><Select><SelectTrigger size={size} aria-label="Select"><SelectValue /></SelectTrigger></Select></>);
|
|
32
|
+
for (const name of ["Input", "Select"]) {
|
|
33
|
+
expect(screen.getByLabelText(name).className).toContain(size === "compact" ? "h-[var(--control-height)]" : "min-h-11");
|
|
34
|
+
expect(screen.getByLabelText(name)).not.toHaveAttribute("size");
|
|
35
|
+
}
|
|
36
|
+
expect(screen.getByText("Action").className).toContain(size === "compact" ? "h-[var(--control-height)]" : "min-h-11");
|
|
37
|
+
expect(screen.getByLabelText("Textarea")).not.toHaveAttribute("size");
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("preserves field and trigger refs, caller overrides, invalid/disabled and autofill attributes", () => {
|
|
42
|
+
const input = React.createRef<HTMLInputElement>();
|
|
43
|
+
const textarea = React.createRef<HTMLTextAreaElement>();
|
|
44
|
+
const trigger = React.createRef<HTMLButtonElement>();
|
|
45
|
+
render(<><Input ref={input} aria-label="Email" autoComplete="email" aria-invalid className="h-8 bg-card" /><Textarea ref={textarea} aria-label="Notes" disabled /><Select disabled><SelectTrigger ref={trigger} aria-label="Choice" size="touch" className="h-12"><SelectValue placeholder="Choose" /></SelectTrigger></Select></>);
|
|
46
|
+
expect(input.current).toBe(screen.getByLabelText("Email"));
|
|
47
|
+
expect(input.current).toHaveAttribute("autocomplete", "email");
|
|
48
|
+
expect(input.current).toHaveAttribute("aria-invalid", "true");
|
|
49
|
+
expect(input.current!.className).toContain("autofill:shadow-");
|
|
50
|
+
expect(input.current!.className.split(" ")).toContain("bg-card");
|
|
51
|
+
expect(input.current!.className).not.toContain("bg-[var(--bg-input)]");
|
|
52
|
+
expect(textarea.current).toBeDisabled();
|
|
53
|
+
expect(trigger.current).toBe(screen.getByLabelText("Choice"));
|
|
54
|
+
expect(trigger.current).toBeDisabled();
|
|
55
|
+
expect(trigger.current!.className.split(" ")).toContain("h-12");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("uses accent ink for link actions while retaining filled action foregrounds and scale motion", () => {
|
|
59
|
+
expect(buttonVariants({ variant: "link" })).toContain("text-[var(--accent-text)]");
|
|
60
|
+
expect(buttonVariants({ variant: "default" })).toContain("text-primary-foreground");
|
|
61
|
+
expect(buttonVariants({ variant: "destructive" })).toContain("text-destructive-foreground");
|
|
62
|
+
expect(buttonVariants({ variant: "sandbox" })).toContain("text-[var(--btn-primary-text)]");
|
|
63
|
+
expect(buttonVariants()).not.toContain("transition-all");
|
|
64
|
+
expect(buttonVariants()).toContain("box-shadow,transform,scale");
|
|
65
|
+
expect(buttonVariants()).toContain("motion-reduce:active:scale-100");
|
|
66
|
+
});
|
|
67
|
+
});
|
package/src/primitives/input.tsx
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
|
-
import {
|
|
4
|
+
import { controlSizes, fieldPresentation } from "../lib/control-presentation";
|
|
5
|
+
import { focusFieldInvalid } from "../lib/focus";
|
|
5
6
|
import { cn } from "../lib/utils";
|
|
6
7
|
import { Label } from "./label";
|
|
7
8
|
|
|
@@ -9,8 +10,8 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|
|
9
10
|
|
|
10
11
|
const inputVariants = cva(
|
|
11
12
|
cn(
|
|
12
|
-
"flex w-full rounded-lg border
|
|
13
|
-
|
|
13
|
+
"flex w-full rounded-lg border px-4 py-2 text-sm placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 file:border-0 file:bg-transparent file:font-medium file:text-sm",
|
|
14
|
+
fieldPresentation,
|
|
14
15
|
),
|
|
15
16
|
{
|
|
16
17
|
variants: {
|
|
@@ -24,6 +25,7 @@ const inputVariants = cva(
|
|
|
24
25
|
default: "h-11",
|
|
25
26
|
sm: "h-9 px-3",
|
|
26
27
|
lg: "h-12 px-5",
|
|
28
|
+
...controlSizes,
|
|
27
29
|
}
|
|
28
30
|
},
|
|
29
31
|
defaultVariants: {
|
|
@@ -89,6 +91,8 @@ export interface TextareaProps
|
|
|
89
91
|
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
|
90
92
|
/** Both values render the same field. */
|
|
91
93
|
variant?: "default" | "sandbox";
|
|
94
|
+
/** Opt in to density/typing presentation without changing the default 120px well. */
|
|
95
|
+
size?: "default" | "compact" | "touch";
|
|
92
96
|
label?: string;
|
|
93
97
|
error?: string;
|
|
94
98
|
hint?: string;
|
|
@@ -98,7 +102,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
|
98
102
|
(
|
|
99
103
|
// `variant` is taken out so it never reaches the DOM element.
|
|
100
104
|
{
|
|
101
|
-
className, variant: _variant, label, error, hint, id,
|
|
105
|
+
className, variant: _variant, size = "default", label, error, hint, id,
|
|
102
106
|
"aria-describedby": describedBy, "aria-invalid": invalid, ...props
|
|
103
107
|
},
|
|
104
108
|
ref,
|
|
@@ -112,11 +116,13 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
|
112
116
|
<textarea
|
|
113
117
|
id={textareaId}
|
|
114
118
|
className={cn(
|
|
115
|
-
"flex min-h-[120px] w-full resize-y rounded-lg border
|
|
119
|
+
"flex min-h-[120px] w-full resize-y rounded-lg border px-4 py-3 text-sm",
|
|
116
120
|
"placeholder:text-muted-foreground",
|
|
117
121
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
118
|
-
|
|
122
|
+
fieldPresentation,
|
|
119
123
|
error && focusFieldInvalid,
|
|
124
|
+
size === "compact" && "min-h-24 px-3 py-2",
|
|
125
|
+
size === "touch" && "text-base",
|
|
120
126
|
className,
|
|
121
127
|
)}
|
|
122
128
|
ref={ref}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { Metric, MetricStrip } from "./metric-strip";
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: "Primitives/MetricStrip",
|
|
6
|
+
component: MetricStrip,
|
|
7
|
+
parameters: { layout: "padded" },
|
|
8
|
+
} satisfies Meta<typeof MetricStrip>;
|
|
9
|
+
export default meta;
|
|
10
|
+
type Story = StoryObj<typeof meta>;
|
|
11
|
+
|
|
12
|
+
const readings = [
|
|
13
|
+
{ label: "Available balance · project wallet", value: "$1,284,003.10", hint: "This account only; not the organization total" },
|
|
14
|
+
{ label: "Calls", value: 0, hint: "Known zero" },
|
|
15
|
+
{ label: "Pending amount", value: "—", hint: "Not yet available" },
|
|
16
|
+
{ label: "Average cost / call", value: "$0.000123", hint: "Current window" },
|
|
17
|
+
{ label: "LongUnbrokenAccountIdentity1234567890", value: "12345678901234567890", hint: 0 },
|
|
18
|
+
{ label: "Errors requiring review", value: 0, hint: "No inferred warning" },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export const Default: Story = {
|
|
22
|
+
args: { children: readings.slice(0, 4).map((reading) => <Metric key={reading.label} {...reading} />) },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const ThreeToSix: Story = {
|
|
26
|
+
args: { children: null },
|
|
27
|
+
render: () => (
|
|
28
|
+
<div className="space-y-6">
|
|
29
|
+
{([3, 4, 5, 6] as const).map((columns) => (
|
|
30
|
+
<section key={columns} className="space-y-2">
|
|
31
|
+
<h2 className="text-lg font-semibold">{columns} metrics</h2>
|
|
32
|
+
<MetricStrip columns={columns} aria-label={`${columns} metrics`}>
|
|
33
|
+
{readings.slice(0, columns).map((reading) => <Metric key={reading.label} {...reading} />)}
|
|
34
|
+
</MetricStrip>
|
|
35
|
+
</section>
|
|
36
|
+
))}
|
|
37
|
+
</div>
|
|
38
|
+
),
|
|
39
|
+
};
|