feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
1
frontend/src/shared/.gitkeep
Normal file
1
frontend/src/shared/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
# Shared UI kit, xterm.js wrapper, design system (ARCHITECTURE §10).
|
||||
33
frontend/src/shared/index.ts
Normal file
33
frontend/src/shared/index.ts
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* IdeA design system (LD) — public surface of the shared UI kit.
|
||||
*
|
||||
* Features import components and the `cn` helper from `@/shared`; the global
|
||||
* theme (`styles/theme.css`) is imported once at the app entry point.
|
||||
*/
|
||||
|
||||
export { cn } from "./lib/cn";
|
||||
export type { ClassValue } from "./lib/cn";
|
||||
|
||||
export { Button } from "./ui/Button";
|
||||
export type { ButtonProps, ButtonVariant, ButtonSize } from "./ui/Button";
|
||||
|
||||
export { IconButton } from "./ui/IconButton";
|
||||
export type { IconButtonProps, IconButtonSize } from "./ui/IconButton";
|
||||
|
||||
export { Input } from "./ui/Input";
|
||||
export type { InputProps } from "./ui/Input";
|
||||
|
||||
export { Field } from "./ui/Field";
|
||||
export type { FieldProps } from "./ui/Field";
|
||||
|
||||
export { Panel } from "./ui/Panel";
|
||||
export type { PanelProps } from "./ui/Panel";
|
||||
|
||||
export { Tabs } from "./ui/Tabs";
|
||||
export type { TabsProps, TabItem } from "./ui/Tabs";
|
||||
|
||||
export { Toolbar } from "./ui/Toolbar";
|
||||
export type { ToolbarProps } from "./ui/Toolbar";
|
||||
|
||||
export { Spinner } from "./ui/Spinner";
|
||||
export type { SpinnerProps } from "./ui/Spinner";
|
||||
23
frontend/src/shared/lib/cn.test.ts
Normal file
23
frontend/src/shared/lib/cn.test.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { cn } from "./cn";
|
||||
|
||||
describe("cn", () => {
|
||||
it("joins truthy fragments with single spaces", () => {
|
||||
expect(cn("a", "b", "c")).toBe("a b c");
|
||||
});
|
||||
|
||||
it("drops falsy fragments", () => {
|
||||
expect(cn("a", false, null, undefined, "", "b")).toBe("a b");
|
||||
});
|
||||
|
||||
it("supports conditional fragments", () => {
|
||||
const active = true;
|
||||
const disabled = false;
|
||||
expect(cn("base", active && "on", disabled && "off")).toBe("base on");
|
||||
});
|
||||
|
||||
it("returns an empty string when everything is falsy", () => {
|
||||
expect(cn(false, null, undefined)).toBe("");
|
||||
});
|
||||
});
|
||||
16
frontend/src/shared/lib/cn.ts
Normal file
16
frontend/src/shared/lib/cn.ts
Normal file
@ -0,0 +1,16 @@
|
||||
/**
|
||||
* `cn` — minimal className combiner for the design system.
|
||||
*
|
||||
* Joins truthy class fragments with a single space, dropping `false`,
|
||||
* `null`, `undefined` and empty strings. Deliberately dependency-free (no
|
||||
* `clsx`/`tailwind-merge`): the kit composes a small, controlled set of
|
||||
* classes, and conditional fragments cover every call site.
|
||||
*
|
||||
* @example
|
||||
* cn("px-2", isActive && "bg-primary", disabled && "opacity-50")
|
||||
*/
|
||||
export type ClassValue = string | false | null | undefined;
|
||||
|
||||
export function cn(...parts: ClassValue[]): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
76
frontend/src/shared/styles/theme.css
Normal file
76
frontend/src/shared/styles/theme.css
Normal file
@ -0,0 +1,76 @@
|
||||
/*
|
||||
* IdeA design system — global theme (LD).
|
||||
*
|
||||
* Tailwind v4, CSS-first: semantic tokens declared under `@theme` become
|
||||
* utilities (e.g. `--color-surface` → `bg-surface text-surface border-surface`).
|
||||
* The palette is a **dark IDE** theme by default — IdeA is an IDE, not a website.
|
||||
* Features should consume these tokens + the `shared/ui` components rather than
|
||||
* hard-coding colours.
|
||||
*/
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
/* Surfaces (back-to-front) */
|
||||
--color-canvas: #0e1116; /* app background */
|
||||
--color-surface: #161b22; /* panels, cards */
|
||||
--color-raised: #1c2430; /* inputs, hovered rows */
|
||||
--color-overlay: #222c3a; /* popovers, menus */
|
||||
|
||||
/* Lines & text */
|
||||
--color-border: #2b3440;
|
||||
--color-border-strong: #3b4757;
|
||||
--color-content: #e6edf3; /* primary text */
|
||||
--color-muted: #9aa7b4; /* secondary text */
|
||||
--color-faint: #6b7785; /* disabled / placeholder */
|
||||
|
||||
/* Accent & status */
|
||||
--color-primary: #4c8dff;
|
||||
--color-primary-hover: #3b7bf0;
|
||||
--color-on-primary: #ffffff;
|
||||
--color-danger: #f85149;
|
||||
--color-success: #3fb950;
|
||||
--color-warning: #d29922;
|
||||
|
||||
/* Typography */
|
||||
--font-sans:
|
||||
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica,
|
||||
Arial, sans-serif;
|
||||
--font-mono:
|
||||
"JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo,
|
||||
Consolas, monospace;
|
||||
|
||||
/* Radii */
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
/* Tell the engine (incl. the Tauri WebKitGTK webview) to render native form
|
||||
controls — <select> popups, <option>, checkboxes, scrollbars — in dark mode.
|
||||
Without this, WebKitGTK paints them with the light system GTK theme, which is
|
||||
why dropdowns looked white in the packaged app but not in a dark browser. */
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--color-canvas);
|
||||
color: var(--color-content);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* A consistent focus ring across the kit (keyboard accessibility). */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
54
frontend/src/shared/ui/Button.tsx
Normal file
54
frontend/src/shared/ui/Button.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
/** Button — the primary action control of the design system (LD). */
|
||||
|
||||
import { forwardRef } from "react";
|
||||
|
||||
import { cn } from "../lib/cn";
|
||||
import { Spinner } from "./Spinner";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
|
||||
export type ButtonSize = "sm" | "md";
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
/** Visual emphasis. Defaults to `secondary`. */
|
||||
variant?: ButtonVariant;
|
||||
/** Control height. Defaults to `md`. */
|
||||
size?: ButtonSize;
|
||||
/** When true, shows a spinner and disables the button. */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const BASE =
|
||||
"inline-flex items-center justify-center gap-1.5 rounded-md font-medium " +
|
||||
"transition-colors select-none disabled:opacity-50 disabled:cursor-not-allowed " +
|
||||
"focus-visible:outline-none";
|
||||
|
||||
const SIZES: Record<ButtonSize, string> = {
|
||||
sm: "h-7 px-2.5 text-xs",
|
||||
md: "h-9 px-3.5 text-sm",
|
||||
};
|
||||
|
||||
const VARIANTS: Record<ButtonVariant, string> = {
|
||||
primary: "bg-primary text-on-primary hover:bg-primary-hover",
|
||||
secondary: "bg-raised text-content border border-border hover:border-border-strong",
|
||||
ghost: "text-muted hover:text-content hover:bg-raised",
|
||||
danger: "bg-danger text-white hover:brightness-110",
|
||||
};
|
||||
|
||||
/** A themeable button with variants, sizes and a loading state. */
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{ variant = "secondary", size = "md", loading = false, disabled, className, children, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={rest.type ?? "button"}
|
||||
disabled={disabled || loading}
|
||||
className={cn(BASE, SIZES[size], VARIANTS[variant], className)}
|
||||
{...rest}
|
||||
>
|
||||
{loading && <Spinner size={size === "sm" ? 12 : 14} />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
53
frontend/src/shared/ui/Field.tsx
Normal file
53
frontend/src/shared/ui/Field.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
/** Field — a labelled wrapper for form controls with hint/error text (LD). */
|
||||
|
||||
import { useId } from "react";
|
||||
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export interface FieldProps {
|
||||
/** Visible label text. */
|
||||
label: string;
|
||||
/** Optional helper text shown under the control. */
|
||||
hint?: string;
|
||||
/** Optional error message; when set, it replaces the hint and is announced. */
|
||||
error?: string;
|
||||
/** Visually hide the label (kept for screen readers). */
|
||||
hideLabel?: boolean;
|
||||
className?: string;
|
||||
/**
|
||||
* Render-prop receiving the generated `id` so the control can wire
|
||||
* `id`/`aria-describedby` to the label and message.
|
||||
*/
|
||||
children: (props: { id: string; describedBy?: string }) => React.ReactNode;
|
||||
}
|
||||
|
||||
/** Associates a label (and optional hint/error) with a single control. */
|
||||
export function Field({ label, hint, error, hideLabel, className, children }: FieldProps) {
|
||||
const id = useId();
|
||||
const msgId = `${id}-msg`;
|
||||
const message = error ?? hint;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1", className)}>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
"text-xs font-medium text-muted",
|
||||
hideLabel && "sr-only",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{children({ id, describedBy: message ? msgId : undefined })}
|
||||
{message && (
|
||||
<p
|
||||
id={msgId}
|
||||
role={error ? "alert" : undefined}
|
||||
className={cn("text-xs", error ? "text-danger" : "text-faint")}
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
frontend/src/shared/ui/IconButton.tsx
Normal file
42
frontend/src/shared/ui/IconButton.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
/** IconButton — a square, icon-only button (close ×, toolbar actions) (LD). */
|
||||
|
||||
import { forwardRef } from "react";
|
||||
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export type IconButtonSize = "sm" | "md";
|
||||
|
||||
export interface IconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
/** Required accessible name (icon-only buttons have no text). */
|
||||
"aria-label": string;
|
||||
/** Control size. Defaults to `md`. */
|
||||
size?: IconButtonSize;
|
||||
}
|
||||
|
||||
const SIZES: Record<IconButtonSize, string> = {
|
||||
sm: "h-6 w-6 text-xs",
|
||||
md: "h-8 w-8 text-sm",
|
||||
};
|
||||
|
||||
/** A compact, icon-only button styled as a ghost control. */
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
|
||||
{ size = "md", className, children, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={rest.type ?? "button"}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md text-muted transition-colors",
|
||||
"hover:bg-raised hover:text-content focus-visible:outline-none",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
SIZES[size],
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
31
frontend/src/shared/ui/Input.tsx
Normal file
31
frontend/src/shared/ui/Input.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
/** Input — a single-line text field (LD). */
|
||||
|
||||
import { forwardRef } from "react";
|
||||
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
/** When true, paints the danger border (paired with a Field error). */
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
/** A themed text input; width is controlled by the parent (defaults to full). */
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{ invalid = false, className, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
aria-invalid={invalid || undefined}
|
||||
className={cn(
|
||||
"h-9 w-full rounded-md bg-raised px-3 text-sm text-content",
|
||||
"border placeholder:text-faint outline-none transition-colors",
|
||||
invalid ? "border-danger" : "border-border focus:border-primary",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
35
frontend/src/shared/ui/Panel.tsx
Normal file
35
frontend/src/shared/ui/Panel.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
/** Panel — a bordered surface/card, optionally with a header (LD). */
|
||||
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export interface PanelProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
|
||||
/** Optional header title. */
|
||||
title?: React.ReactNode;
|
||||
/** Optional content rendered on the right of the header (actions). */
|
||||
actions?: React.ReactNode;
|
||||
/** Remove the default body padding (e.g. to host a flush list/terminal). */
|
||||
flush?: boolean;
|
||||
}
|
||||
|
||||
/** A raised surface used to group related content. */
|
||||
export function Panel({ title, actions, flush, className, children, ...rest }: PanelProps) {
|
||||
const hasHeader = title != null || actions != null;
|
||||
return (
|
||||
<section
|
||||
className={cn("rounded-lg border border-border bg-surface", className)}
|
||||
{...rest}
|
||||
>
|
||||
{hasHeader && (
|
||||
<header className="flex items-center justify-between gap-2 border-b border-border px-4 py-2.5">
|
||||
{typeof title === "string" ? (
|
||||
<h3 className="text-sm font-semibold text-content">{title}</h3>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
{actions}
|
||||
</header>
|
||||
)}
|
||||
<div className={cn(!flush && "p-4")}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
27
frontend/src/shared/ui/Spinner.tsx
Normal file
27
frontend/src/shared/ui/Spinner.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
/** Spinner — an indeterminate loading indicator (LD). */
|
||||
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export interface SpinnerProps {
|
||||
/** Diameter in pixels. Defaults to 16. */
|
||||
size?: number;
|
||||
/** Extra classes (e.g. a colour override). */
|
||||
className?: string;
|
||||
/** Accessible label; defaults to "Loading". */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** A CSS-only spinning ring that inherits the current text colour. */
|
||||
export function Spinner({ size = 16, className, label = "Loading" }: SpinnerProps) {
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"inline-block animate-spin rounded-full border-2 border-current border-t-transparent",
|
||||
className,
|
||||
)}
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
76
frontend/src/shared/ui/Tabs.tsx
Normal file
76
frontend/src/shared/ui/Tabs.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
/** Tabs — a horizontal, optionally closable tab bar (LD).
|
||||
*
|
||||
* Presentation-only and controlled: the parent owns the active id and the open
|
||||
* set. Used for the project tab bar (one tab per open project, ARCHITECTURE §10)
|
||||
* but generic enough for any tabbed surface.
|
||||
*/
|
||||
|
||||
import { cn } from "../lib/cn";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
export interface TabItem {
|
||||
/** Stable id, returned by `onSelect`/`onClose`. */
|
||||
id: string;
|
||||
/** Visible label. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface TabsProps {
|
||||
/** Tabs to render, left to right. */
|
||||
items: TabItem[];
|
||||
/** Currently-active tab id. */
|
||||
value: string | null;
|
||||
/** Called with the id of the tab the user activates. */
|
||||
onSelect: (id: string) => void;
|
||||
/** When provided, each tab shows a close (×) control. */
|
||||
onClose?: (id: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** A themed tab strip with selection and optional per-tab close. */
|
||||
export function Tabs({ items, value, onSelect, onClose, className }: TabsProps) {
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
className={cn("flex flex-wrap items-stretch gap-1", className)}
|
||||
>
|
||||
{items.map((tab) => {
|
||||
const active = tab.id === value;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cn(
|
||||
"group flex items-center gap-1 rounded-md border px-1 transition-colors",
|
||||
active
|
||||
? "border-border-strong bg-raised"
|
||||
: "border-transparent hover:bg-raised",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => onSelect(tab.id)}
|
||||
className={cn(
|
||||
"px-2 py-1 text-sm focus-visible:outline-none",
|
||||
active ? "font-semibold text-content" : "text-muted hover:text-content",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{onClose && (
|
||||
<IconButton
|
||||
size="sm"
|
||||
aria-label={`close ${tab.label}`}
|
||||
onClick={() => onClose(tab.id)}
|
||||
className="opacity-60 group-hover:opacity-100"
|
||||
>
|
||||
×
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
frontend/src/shared/ui/Toolbar.tsx
Normal file
27
frontend/src/shared/ui/Toolbar.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
/** Toolbar — a thin horizontal action bar (LD). */
|
||||
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export interface ToolbarProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/** Push trailing children to the right with an auto margin. */
|
||||
justify?: "start" | "between" | "end";
|
||||
}
|
||||
|
||||
const JUSTIFY = {
|
||||
start: "justify-start",
|
||||
between: "justify-between",
|
||||
end: "justify-end",
|
||||
} as const;
|
||||
|
||||
/** A flex row for grouping buttons/controls with consistent spacing. */
|
||||
export function Toolbar({ justify = "start", className, children, ...rest }: ToolbarProps) {
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
className={cn("flex items-center gap-2", JUSTIFY[justify], className)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
frontend/src/shared/ui/ui.test.tsx
Normal file
117
frontend/src/shared/ui/ui.test.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
/**
|
||||
* LD — design-system component tests. Pure UI, rendered with React Testing
|
||||
* Library; no backend, no gateways. We assert behaviour and accessibility
|
||||
* (roles, names, aria-*) rather than exact Tailwind classes, so the visual
|
||||
* design can evolve without breaking the suite.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
|
||||
import { Button } from "./Button";
|
||||
import { Input } from "./Input";
|
||||
import { Field } from "./Field";
|
||||
import { Tabs } from "./Tabs";
|
||||
|
||||
describe("Button", () => {
|
||||
it("renders its label and fires onClick", () => {
|
||||
const onClick = vi.fn();
|
||||
render(<Button onClick={onClick}>Save</Button>);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("is disabled and shows a spinner while loading", () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<Button loading onClick={onClick}>
|
||||
Save
|
||||
</Button>,
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: /Save/ }) as HTMLButtonElement;
|
||||
expect(btn.disabled).toBe(true);
|
||||
expect(screen.getByRole("status")).toBeTruthy(); // spinner
|
||||
fireEvent.click(btn);
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defaults to type=button to avoid accidental form submits", () => {
|
||||
render(<Button>Go</Button>);
|
||||
expect(screen.getByRole("button", { name: "Go" }).getAttribute("type")).toBe(
|
||||
"button",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Input", () => {
|
||||
it("flags aria-invalid when invalid", () => {
|
||||
render(<Input aria-label="email" invalid />);
|
||||
expect(screen.getByLabelText("email").getAttribute("aria-invalid")).toBe("true");
|
||||
});
|
||||
|
||||
it("is not aria-invalid by default", () => {
|
||||
render(<Input aria-label="email" />);
|
||||
expect(screen.getByLabelText("email").getAttribute("aria-invalid")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Field", () => {
|
||||
it("associates the label with the control via id", () => {
|
||||
render(
|
||||
<Field label="Project name">
|
||||
{({ id, describedBy }) => (
|
||||
<input id={id} aria-describedby={describedBy} />
|
||||
)}
|
||||
</Field>,
|
||||
);
|
||||
// getByLabelText resolves the label→control association.
|
||||
expect(screen.getByLabelText("Project name")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("announces an error and wires aria-describedby", () => {
|
||||
render(
|
||||
<Field label="Root" error="must be absolute">
|
||||
{({ id, describedBy }) => (
|
||||
<input id={id} aria-describedby={describedBy} />
|
||||
)}
|
||||
</Field>,
|
||||
);
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert.textContent).toBe("must be absolute");
|
||||
const input = screen.getByLabelText("Root");
|
||||
expect(input.getAttribute("aria-describedby")).toBe(alert.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tabs", () => {
|
||||
const items = [
|
||||
{ id: "a", label: "alpha" },
|
||||
{ id: "b", label: "beta" },
|
||||
];
|
||||
|
||||
it("marks the active tab with aria-selected and reports selection", () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<Tabs items={items} value="a" onSelect={onSelect} />);
|
||||
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
expect(tabs).toHaveLength(2);
|
||||
const [alpha, beta] = tabs;
|
||||
expect(alpha.getAttribute("aria-selected")).toBe("true");
|
||||
expect(beta.getAttribute("aria-selected")).toBe("false");
|
||||
|
||||
fireEvent.click(beta);
|
||||
expect(onSelect).toHaveBeenCalledWith("b");
|
||||
});
|
||||
|
||||
it("renders a close control per tab only when onClose is given", () => {
|
||||
const onClose = vi.fn();
|
||||
const { rerender } = render(
|
||||
<Tabs items={items} value="a" onSelect={() => {}} />,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "close alpha" })).toBeNull();
|
||||
|
||||
rerender(<Tabs items={items} value="a" onSelect={() => {}} onClose={onClose} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "close beta" }));
|
||||
expect(onClose).toHaveBeenCalledWith("b");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user